fixed onboarding flow

This commit is contained in:
ValueOn AG 2026-03-30 23:03:33 +02:00
parent 9d4e5bc90d
commit 9ea6ed4613
12 changed files with 308 additions and 241 deletions

View file

@ -14,7 +14,9 @@ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import { FaSave, FaChevronLeft, FaChevronRight, FaCode, FaExclamationTriangle, FaMagic, FaFolder, FaFolderOpen, FaArrowUp, FaSpinner } from 'react-icons/fa'; import { FaSave, FaChevronLeft, FaChevronRight, FaCode, FaExclamationTriangle, FaMagic, FaFolder, FaFolderOpen, FaArrowUp, FaSpinner } from 'react-icons/fa';
import { Popup } from '../UiComponents/Popup'; import { Popup } from '../UiComponents/Popup';
import { ActionsPanel } from '../ActionsPanel'; import { ActionsPanel } from '../ActionsPanel';
import { ProviderMultiSelect } from '../ProviderSelector'; import { ProviderMultiSelect, _defaultProviderSelection, _migrateFromLegacy, _toBackendProviders } from '../ProviderSelector';
import type { ProviderSelection } from '../ProviderSelector';
import { useBilling } from '../../hooks/useBilling';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { useWorkflowActions } from '../../hooks/useAutomations'; import { useWorkflowActions } from '../../hooks/useAutomations';
@ -374,7 +376,8 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [schedule, setSchedule] = useState('0 22 * * *'); const [schedule, setSchedule] = useState('0 22 * * *');
const [active, setActive] = useState(false); const [active, setActive] = useState(false);
const [allowedProviders, setAllowedProviders] = useState<string[]>([]); const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection());
const { allowedProviders: billingProviders } = useBilling();
// Template multilingual fields // Template multilingual fields
const [labelMulti, setLabelMulti] = useState<LocalTextMultilingual>({ en: '', de: '' }); const [labelMulti, setLabelMulti] = useState<LocalTextMultilingual>({ en: '', de: '' });
@ -537,7 +540,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
setLabel(def.label || ''); setLabel(def.label || '');
setSchedule(def.schedule || '0 22 * * *'); setSchedule(def.schedule || '0 22 * * *');
setActive(def.active ?? false); setActive(def.active ?? false);
setAllowedProviders(def.allowedProviders || []); setProviderSelection(_migrateFromLegacy(def.allowedProviders || []));
} }
// Extract template JSON // Extract template JSON
@ -693,7 +696,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
active, active,
template: templateJson, template: templateJson,
placeholders, placeholders,
allowedProviders allowedProviders: _toBackendProviders(providerSelection, billingProviders),
}; };
} }
@ -709,7 +712,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [label, schedule, active, allowedProviders, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]); }, [label, schedule, active, providerSelection, billingProviders, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]);
// Computed values // Computed values
const editorTitle = title || (mode === 'template' const editorTitle = title || (mode === 'template'
@ -864,12 +867,12 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
{/* Allowed AI Providers */} {/* Allowed AI Providers */}
<div className={styles.formGroup}> <div className={styles.formGroup}>
<ProviderMultiSelect <ProviderMultiSelect
selectedProviders={allowedProviders} selection={providerSelection}
onChange={setAllowedProviders} onChange={setProviderSelection}
label="Erlaubte AI-Provider" label="Erlaubte AI-Provider"
/> />
<p className={styles.formHint}> <p className={styles.formHint}>
Beschränkt die Automation auf bestimmte AI-Provider. Leer = alle erlaubt. Beschränkt die Automation auf bestimmte AI-Provider. «Alle» = dynamisch alle erlaubten.
</p> </p>
</div> </div>
</div> </div>

View file

@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import api from '../api'; import api from '../api';
import OnboardingWizard from './OnboardingWizard';
interface OnboardingStep { interface OnboardingStep {
id: string; id: string;
@ -17,7 +18,7 @@ interface OnboardingAssistantProps {
const _STORAGE_KEY = 'onboarding_hidden'; const _STORAGE_KEY = 'onboarding_hidden';
const _CALLOUTS: Record<string, string> = { const _CALLOUTS: Record<string, string> = {
mandate: 'Tipp: Ein Mandant ist Ihr persoenlicher Arbeitsbereich. Sie koennen spaeter weitere Mandanten fuer Teams oder Projekte erstellen.', mandate: 'Tipp: Ein Mandant ist Ihr Arbeitsbereich. Sie koennen spaeter weitere Mandanten fuer Teams oder Projekte erstellen.',
feature: 'Tipp: Im Store finden Sie AI-Workspace, CommCoach und weitere Features. Aktivieren Sie mindestens eines, um loszulegen.', feature: 'Tipp: Im Store finden Sie AI-Workspace, CommCoach und weitere Features. Aktivieren Sie mindestens eines, um loszulegen.',
connection: 'Tipp: Verbinden Sie Ihre Datenquellen (z.B. SharePoint, Google Drive), damit der AI-Assistent auf Ihre Dokumente zugreifen kann.', connection: 'Tipp: Verbinden Sie Ihre Datenquellen (z.B. SharePoint, Google Drive), damit der AI-Assistent auf Ihre Dokumente zugreifen kann.',
chat: 'Tipp: Starten Sie einen Chat mit dem AI-Assistenten. Er kann Ihre verbundenen Daten analysieren und Fragen beantworten.', chat: 'Tipp: Starten Sie einen Chat mit dem AI-Assistenten. Er kann Ihre verbundenen Daten analysieren und Fragen beantworten.',
@ -50,46 +51,59 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss })
const [steps, setSteps] = useState<OnboardingStep[]>([]); const [steps, setSteps] = useState<OnboardingStep[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dontShowAgain, setDontShowAgain] = useState(false); const [dontShowAgain, setDontShowAgain] = useState(false);
const [showWizard, setShowWizard] = useState(false);
const _checkOnboardingState = useCallback(async () => { const _checkOnboardingState = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const onboardingSteps: OnboardingStep[] = []; const onboardingSteps: OnboardingStep[] = [];
let hasMandate = false; // Check admin mandates (user-owned or where user is admin)
let hasAdminMandate = false;
try { try {
const mandatesRes = await api.get('/api/store/mandates'); const mandatesRes = await api.get('/api/store/mandates');
const mandates = mandatesRes.data?.mandates || mandatesRes.data || []; const mandates = mandatesRes.data?.mandates || mandatesRes.data || [];
hasMandate = Array.isArray(mandates) && mandates.length > 0; hasAdminMandate = Array.isArray(mandates) && mandates.length > 0;
} catch { /* ignore */ } } catch { /* ignore */ }
onboardingSteps.push({ // Check if user has any feature access (via navigation = mandate member)
id: 'mandate',
label: 'Mandant einrichten',
description: hasMandate
? 'Dein Mandant ist eingerichtet.'
: 'Richte deinen ersten Mandanten ein.',
completed: hasMandate,
action: hasMandate ? undefined : () => navigate('/store'),
});
let hasFeature = false; let hasFeature = false;
let firstInstancePath: string | undefined; let workspaceInstancePath: string | undefined;
let workspaceInstanceIds: string[] = [];
try { try {
const navRes = await api.get('/api/navigation?language=de'); const navRes = await api.get('/api/navigation?language=de');
const mandates = navRes.data?.mandates || []; const blocks = navRes.data?.blocks || [];
const dynamicBlock = blocks.find((b: { type: string }) => b.type === 'dynamic');
const mandates = dynamicBlock?.mandates || [];
for (const m of mandates) { for (const m of mandates) {
for (const f of m.features || []) { for (const f of m.features || []) {
for (const inst of f.instances || []) { for (const inst of f.instances || []) {
if (!hasFeature) hasFeature = true; hasFeature = true;
if (!firstInstancePath && inst.views?.length > 0) { if (f.uiComponent === 'feature.workspace' && inst.views?.length > 0) {
firstInstancePath = inst.views[0].uiPath; workspaceInstanceIds.push(inst.id);
if (!workspaceInstancePath) {
workspaceInstancePath = inst.views[0].uiPath;
}
} }
} }
} }
} }
} catch { /* ignore */ } } catch { /* ignore */ }
const mandateStepDone = hasAdminMandate || hasFeature;
onboardingSteps.push({
id: 'mandate',
label: 'Mandant einrichten',
description: hasAdminMandate
? 'Dein Mandant ist eingerichtet.'
: hasFeature
? 'Du bist Mitglied eines Mandanten.'
: 'Erstelle deinen Arbeitsbereich.',
completed: mandateStepDone,
action: mandateStepDone ? undefined : () => setShowWizard(true),
});
onboardingSteps.push({ onboardingSteps.push({
id: 'feature', id: 'feature',
label: 'Erstes Feature aktivieren', label: 'Erstes Feature aktivieren',
@ -103,8 +117,8 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss })
let hasConnection = false; let hasConnection = false;
try { try {
const connRes = await api.get('/api/connections/'); const connRes = await api.get('/api/connections/');
const connections = connRes.data?.data || connRes.data || []; const items = connRes.data?.items || connRes.data?.data || connRes.data || [];
hasConnection = Array.isArray(connections) && connections.length > 0; hasConnection = Array.isArray(items) && items.length > 0;
} catch { /* ignore */ } } catch { /* ignore */ }
onboardingSteps.push({ onboardingSteps.push({
@ -118,25 +132,16 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss })
}); });
let hasChat = false; let hasChat = false;
if (hasFeature && firstInstancePath) { for (const instId of workspaceInstanceIds) {
if (hasChat) break;
try { try {
const featuresRes = await api.get('/api/store/features'); const wfRes = await api.get(`/api/workspace/${instId}/workflows`);
const features = featuresRes.data || []; const wfs = wfRes.data?.workflows || wfRes.data?.data || wfRes.data?.items || [];
for (const f of features) { if (Array.isArray(wfs) && wfs.length > 0) hasChat = true;
if (hasChat) break;
for (const inst of f.instances || []) {
if (hasChat) break;
try {
const wfRes = await api.get(`/api/workspace/${inst.id}/workflows`);
const wfs = wfRes.data?.workflows || wfRes.data?.data || [];
if (Array.isArray(wfs) && wfs.length > 0) hasChat = true;
} catch { /* ignore */ }
}
}
} catch { /* ignore */ } } catch { /* ignore */ }
} }
const _chatAction = firstInstancePath ? () => navigate(firstInstancePath!) : undefined; const chatAction = workspaceInstancePath ? () => navigate(workspaceInstancePath!) : undefined;
onboardingSteps.push({ onboardingSteps.push({
id: 'chat', id: 'chat',
label: 'Ersten AI-Chat starten', label: 'Ersten AI-Chat starten',
@ -144,7 +149,7 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss })
? 'Du hast bereits Chats gestartet.' ? 'Du hast bereits Chats gestartet.'
: 'Starte deinen ersten Chat mit dem AI-Assistenten.', : 'Starte deinen ersten Chat mit dem AI-Assistenten.',
completed: hasChat, completed: hasChat,
action: hasChat ? undefined : _chatAction, action: hasChat ? undefined : chatAction,
}); });
setSteps(onboardingSteps); setSteps(onboardingSteps);
@ -180,6 +185,18 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss })
onDismiss?.(); onDismiss?.();
}; };
if (showWizard) {
return (
<OnboardingWizard
onComplete={() => {
setShowWizard(false);
_checkOnboardingState();
}}
onDismiss={() => setShowWizard(false)}
/>
);
}
if (hidden || loading) return null; if (hidden || loading) return null;
const completedCount = steps.filter(s => s.completed).length; const completedCount = steps.filter(s => s.completed).length;

View file

@ -8,7 +8,7 @@ interface OnboardingWizardProps {
const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismiss }) => { const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismiss }) => {
const [planKey, setPlanKey] = useState<'TRIAL_7D' | 'STANDARD_MONTHLY'>('TRIAL_7D'); const [planKey, setPlanKey] = useState<'TRIAL_7D' | 'STANDARD_MONTHLY'>('TRIAL_7D');
const [companyName, setCompanyName] = useState(''); const [mandateName, setMandateName] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -16,10 +16,15 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
await api.post('/api/local/onboarding', { const res = await api.post('/api/local/onboarding', {
planKey, planKey,
companyName: companyName.trim() || undefined, companyName: mandateName.trim() || undefined,
}); });
if (res.data?.alreadyProvisioned) {
setError('Du hast bereits einen Mandanten mit Admin-Zugang.');
return;
}
window.dispatchEvent(new CustomEvent('features-changed'));
onComplete(); onComplete();
} catch (err: any) { } catch (err: any) {
setError(err?.response?.data?.detail || 'Fehler bei der Einrichtung'); setError(err?.response?.data?.detail || 'Fehler bei der Einrichtung');
@ -38,9 +43,9 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
background: 'var(--bg-primary, #fff)', borderRadius: '12px', padding: '32px', background: 'var(--bg-primary, #fff)', borderRadius: '12px', padding: '32px',
maxWidth: '480px', width: '90%', boxShadow: '0 8px 32px rgba(0,0,0,0.2)', maxWidth: '480px', width: '90%', boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
}}> }}>
<h2 style={{ margin: '0 0 8px', fontSize: '1.5rem' }}>Willkommen bei PowerOn</h2> <h2 style={{ margin: '0 0 8px', fontSize: '1.5rem' }}>Mandant erstellen</h2>
<p style={{ color: 'var(--text-secondary, #666)', margin: '0 0 24px' }}> <p style={{ color: 'var(--text-secondary, #666)', margin: '0 0 24px' }}>
Wähle dein Abo und leg los. Erstelle deinen eigenen Arbeitsbereich mit Abo-Auswahl.
</p> </p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', marginBottom: '20px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '12px', marginBottom: '20px' }}>
@ -80,8 +85,8 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
Name des Mandanten <span style={{ fontWeight: 400, color: 'var(--text-secondary, #666)' }}>(optional)</span> Name des Mandanten <span style={{ fontWeight: 400, color: 'var(--text-secondary, #666)' }}>(optional)</span>
</label> </label>
<input <input
type="text" value={companyName} type="text" value={mandateName}
onChange={(e) => setCompanyName(e.target.value)} onChange={(e) => setMandateName(e.target.value)}
placeholder="z. B. Firmenname oder Projektname" placeholder="z. B. Firmenname oder Projektname"
style={{ style={{
width: '100%', padding: '10px 12px', borderRadius: '6px', width: '100%', padding: '10px 12px', borderRadius: '6px',
@ -98,7 +103,7 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
padding: '10px 20px', borderRadius: '6px', border: '1px solid var(--border, #d1d5db)', padding: '10px 20px', borderRadius: '6px', border: '1px solid var(--border, #d1d5db)',
background: 'transparent', cursor: 'pointer', background: 'transparent', cursor: 'pointer',
}}> }}>
Später Abbrechen
</button> </button>
<button onClick={_handleSubmit} disabled={loading} <button onClick={_handleSubmit} disabled={loading}
style={{ style={{
@ -106,7 +111,7 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
background: 'var(--accent, #4f46e5)', color: '#fff', cursor: 'pointer', background: 'var(--accent, #4f46e5)', color: '#fff', cursor: 'pointer',
opacity: loading ? 0.6 : 1, opacity: loading ? 0.6 : 1,
}}> }}>
{loading ? 'Wird eingerichtet...' : 'Loslegen'} {loading ? 'Wird eingerichtet...' : 'Mandant erstellen'}
</button> </button>
</div> </div>
</div> </div>

View file

@ -53,17 +53,17 @@
justify-content: center; justify-content: center;
width: 36px; width: 36px;
height: 36px; height: 36px;
border: 1px solid var(--border-color, #3a3a3a); border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px; border-radius: 6px;
background: var(--surface-color, #2d2d2d); background: var(--surface-color, #ffffff);
color: var(--text-secondary, #888); color: var(--text-secondary, #666666);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.triggerButton:hover:not(:disabled) { .triggerButton:hover:not(:disabled) {
background: var(--bg-secondary, #3a3a3a); background: var(--hover-bg, rgba(0, 0, 0, 0.06));
color: var(--text-primary, #fff); color: var(--text-primary, #1a1a1a);
} }
.triggerButton:disabled { .triggerButton:disabled {
@ -83,20 +83,20 @@
transform: translateX(-50%); transform: translateX(-50%);
z-index: 1000; z-index: 1000;
padding: 8px; padding: 8px;
background: var(--surface-color, #2d2d2d); background: var(--surface-color, #ffffff);
border: 1px solid var(--border-color, #3a3a3a); border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px; border-radius: 6px;
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.5); box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
min-width: 220px; min-width: 220px;
} }
.dropdownHeader { .dropdownHeader {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
color: var(--text-secondary, #888); color: var(--text-secondary, #666666);
padding: 4px 8px; padding: 4px 8px;
margin-bottom: 4px; margin-bottom: 4px;
border-bottom: 1px solid var(--border-color, #3a3a3a); border-bottom: 1px solid var(--border-color, #e0e0e0);
} }
.selectActions { .selectActions {
@ -108,18 +108,18 @@
.actionButton { .actionButton {
flex: 1; flex: 1;
padding: 4px 8px; padding: 4px 8px;
border: 1px solid var(--border-color, #3a3a3a); border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px; border-radius: 4px;
background: var(--bg-secondary, #252525); background: var(--bg-secondary, #f8f9fa);
color: var(--text-secondary, #888); color: var(--text-secondary, #666666);
font-size: 0.75rem; font-size: 0.75rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.actionButton:hover:not(:disabled) { .actionButton:hover:not(:disabled) {
background: var(--bg-hover, #3a3a3a); background: var(--hover-bg, rgba(0, 0, 0, 0.06));
color: var(--text-primary, #fff); color: var(--text-primary, #1a1a1a);
} }
.actionButton.active { .actionButton.active {
@ -138,7 +138,7 @@
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
padding: 4px; padding: 4px;
background: var(--bg-secondary, #252525); background: var(--bg-secondary, #f8f9fa);
border-radius: 4px; border-radius: 4px;
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
@ -151,12 +151,13 @@
padding: 6px 8px; padding: 6px 8px;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
transition: background 0.15s ease; transition: background 0.15s ease, color 0.15s ease;
color: var(--text-primary, #e0e0e0); color: var(--text-primary, #1a1a1a);
} }
.checkboxItem:hover { .checkboxItem:hover {
background: var(--bg-hover, #3a3a3a); background: var(--hover-bg, rgba(0, 0, 0, 0.06));
color: var(--text-primary, #1a1a1a);
} }
.checkboxItem.disabled { .checkboxItem.disabled {
@ -177,12 +178,12 @@
.providerName { .providerName {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-primary, #e0e0e0); color: inherit;
} }
.hint { .hint {
font-size: 0.7rem; font-size: 0.7rem;
color: var(--text-tertiary, #666); color: var(--text-tertiary, #888888);
text-align: center; text-align: center;
padding: 4px 0; padding: 4px 0;
} }
@ -192,10 +193,24 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 12px; padding: 12px;
color: var(--text-secondary, #888); color: var(--text-secondary, #666666);
font-size: 0.8rem; font-size: 0.8rem;
} }
/* Dark theme: list hover stays a light lift, not a black wash */
:global(.dark-theme) .checkboxItem:hover {
background: var(--hover-bg, rgba(255, 255, 255, 0.08));
color: var(--text-primary, #e5e7eb);
}
:global(.dark-theme) .checkboxItem {
color: var(--text-primary, #e5e7eb);
}
:global(.dark-theme) .dropdownContent {
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.45);
}
/* ============================================================================ /* ============================================================================
PROVIDER BADGES PROVIDER BADGES
============================================================================ */ ============================================================================ */

View file

@ -1,19 +1,80 @@
/** /**
* ProviderSelector Component * ProviderSelector Component
* *
* Wiederverwendbare Komponente zur Auswahl von AICore-Providern. * Wiederverwendbare Komponente zur Auswahl von AICore-Providern.
* Kann im AI Workspace und Automation Editor verwendet werden. * Kann im AI Workspace und Automation Editor verwendet werden.
* *
* Features: * Selektionsmodell:
* - Dropdown für Einzelauswahl * ProviderSelection { include: string[], exclude: string[] }
* - Checkbox-Liste für Mehrfachauswahl * - include(["ALL"]), exclude([]) alle verfügbaren Provider (dynamisch)
* - Lädt verfügbare Provider aus dem Billing-System * - include(["ALL"]), exclude(["private"]) alle ausser "private" (dynamisch)
* - include(["anthropic"]), exclude([]) nur Anthropic
* - include([]), exclude([]) keiner ausgewählt
*
* resolveProviders(selection, allowedProviders) liefert die konkrete Liste.
*/ */
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react'; import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { useBilling } from '../../hooks/useBilling'; import { useBilling } from '../../hooks/useBilling';
import styles from './ProviderSelector.module.css'; import styles from './ProviderSelector.module.css';
// ============================================================================
// TYPES & HELPERS
// ============================================================================
export const PROVIDER_ALL = 'ALL';
export interface ProviderSelection {
include: string[];
exclude: string[];
}
export function _defaultProviderSelection(): ProviderSelection {
return { include: [PROVIDER_ALL], exclude: [] };
}
export function _resolveProviders(
selection: ProviderSelection,
allowedProviders: string[],
): string[] {
if (selection.include.includes(PROVIDER_ALL)) {
return allowedProviders.filter((p) => !selection.exclude.includes(p));
}
return selection.include.filter((p) => allowedProviders.includes(p));
}
export function _isAllSelected(selection: ProviderSelection): boolean {
return selection.include.includes(PROVIDER_ALL) && selection.exclude.length === 0;
}
export function _isNoneSelected(
selection: ProviderSelection,
allowedProviders: string[],
): boolean {
return _resolveProviders(selection, allowedProviders).length === 0;
}
/**
* Migrate legacy string[] (old model) to ProviderSelection.
* [] ALL, [...ids] include those ids.
*/
export function _migrateFromLegacy(providers: string[]): ProviderSelection {
if (providers.length === 0) return _defaultProviderSelection();
return { include: providers, exclude: [] };
}
/**
* Convert ProviderSelection to flat list for backend API calls.
* Returns [] when ALL are selected (= no restriction / legacy behaviour).
*/
export function _toBackendProviders(
selection: ProviderSelection,
allowedProviders: string[],
): string[] {
if (_isAllSelected(selection)) return [];
return _resolveProviders(selection, allowedProviders);
}
// Provider display names // Provider display names
const PROVIDER_LABELS: Record<string, string> = { const PROVIDER_LABELS: Record<string, string> = {
anthropic: 'Anthropic (Claude)', anthropic: 'Anthropic (Claude)',
@ -25,7 +86,6 @@ const PROVIDER_LABELS: Record<string, string> = {
internal: 'Internal', internal: 'Internal',
}; };
// Provider icons (emojis for simplicity)
const PROVIDER_ICONS: Record<string, string> = { const PROVIDER_ICONS: Record<string, string> = {
anthropic: '🤖', anthropic: '🤖',
openai: '💬', openai: '💬',
@ -58,20 +118,20 @@ export const ProviderSelect: React.FC<ProviderSelectProps> = ({
showLabel = true, showLabel = true,
}) => { }) => {
const { allowedProviders, loadAllowedProviders, loading } = useBilling(); const { allowedProviders, loadAllowedProviders, loading } = useBilling();
useEffect(() => { useEffect(() => {
if (allowedProviders.length === 0 && !loading) { if (allowedProviders.length === 0 && !loading) {
loadAllowedProviders(); loadAllowedProviders();
} }
}, []); }, []);
const providerOptions = useMemo(() => { const providerOptions = useMemo(() => {
return allowedProviders.map((provider) => ({ return allowedProviders.map((provider) => ({
value: provider, value: provider,
label: `${PROVIDER_ICONS[provider] || '🔌'} ${PROVIDER_LABELS[provider] || provider}`, label: `${PROVIDER_ICONS[provider] || '🔌'} ${PROVIDER_LABELS[provider] || provider}`,
})); }));
}, [allowedProviders]); }, [allowedProviders]);
return ( return (
<div className={`${styles.providerSelect} ${className || ''}`}> <div className={`${styles.providerSelect} ${className || ''}`}>
{showLabel && <label className={styles.label}>{label}</label>} {showLabel && <label className={styles.label}>{label}</label>}
@ -93,12 +153,12 @@ export const ProviderSelect: React.FC<ProviderSelectProps> = ({
}; };
// ============================================================================ // ============================================================================
// MULTI SELECT COMPONENT (Checkbox List) // MULTI SELECT COMPONENT (Checkbox List) — include / exclude model
// ============================================================================ // ============================================================================
interface ProviderMultiSelectProps { interface ProviderMultiSelectProps {
selectedProviders: string[]; selection: ProviderSelection;
onChange: (providers: string[]) => void; onChange: (selection: ProviderSelection) => void;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
label?: string; label?: string;
@ -108,7 +168,7 @@ interface ProviderMultiSelectProps {
} }
export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
selectedProviders, selection,
onChange, onChange,
disabled = false, disabled = false,
className, className,
@ -121,97 +181,100 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
const [initialExcludeApplied, setInitialExcludeApplied] = useState(false); const [initialExcludeApplied, setInitialExcludeApplied] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const { allowedProviders, loadAllowedProviders, loading } = useBilling(); const { allowedProviders, loadAllowedProviders, loading } = useBilling();
useEffect(() => { useEffect(() => {
if (allowedProviders.length === 0 && !loading) { if (allowedProviders.length === 0 && !loading) {
loadAllowedProviders(); loadAllowedProviders();
} }
}, []); }, []);
// Apply default exclusions when providers first load // Apply default exclusions once when providers first load
useEffect(() => { useEffect(() => {
if ( if (
!initialExcludeApplied && !initialExcludeApplied &&
allowedProviders.length > 0 && allowedProviders.length > 0 &&
excludeByDefault.length > 0 && excludeByDefault.length > 0 &&
selectedProviders.length === 0 _isAllSelected(selection)
) { ) {
const initialSelection = allowedProviders.filter( onChange({ include: [PROVIDER_ALL], exclude: [...excludeByDefault] });
(p) => !excludeByDefault.includes(p)
);
// Only apply if there's actually something to exclude
if (initialSelection.length < allowedProviders.length) {
onChange(initialSelection);
}
setInitialExcludeApplied(true); setInitialExcludeApplied(true);
} }
}, [allowedProviders, excludeByDefault, initialExcludeApplied, selectedProviders.length, onChange]); }, [allowedProviders, excludeByDefault, initialExcludeApplied, selection, onChange]);
// Click outside handler const _handleClickOutside = useCallback((event: MouseEvent) => {
const handleClickOutside = useCallback((event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) { if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsExpanded(false); setIsExpanded(false);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
if (isExpanded) { if (isExpanded) {
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', _handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', _handleClickOutside);
} }
}, [isExpanded, handleClickOutside]); }, [isExpanded, _handleClickOutside]);
// Effective selection: empty array = all providers active (no restriction) const effectiveSelection = useMemo(
const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders; () => _resolveProviders(selection, allowedProviders),
[selection, allowedProviders],
// "Alle" is active when no restriction is set (empty array) OR all explicitly selected );
const isAllSelected = selectedProviders.length === 0 ||
(allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length); const allSelected = _isAllSelected(selection);
const noneSelected = effectiveSelection.length === 0;
const handleToggle = (provider: string) => {
if (selectedProviders.length === 0) { const _handleToggle = (provider: string) => {
// Currently "all active" (no restriction) -> make explicit: all except the toggled one const isChecked = effectiveSelection.includes(provider);
onChange(allowedProviders.filter((p) => p !== provider));
} else if (selectedProviders.includes(provider)) { if (selection.include.includes(PROVIDER_ALL)) {
// Deactivate: remove from selection // Currently ALL-based: toggle modifies exclude list
const remaining = selectedProviders.filter((p) => p !== provider); if (isChecked) {
// If removing leaves all others selected, reset to [] (= all, no restriction) onChange({ include: [PROVIDER_ALL], exclude: [...selection.exclude, provider] });
if (remaining.length === allowedProviders.length) {
onChange([]);
} else { } else {
onChange(remaining); const nextExclude = selection.exclude.filter((p) => p !== provider);
onChange({ include: [PROVIDER_ALL], exclude: nextExclude });
} }
} else { } else {
// Activate: add to selection // Explicit include list
const updated = [...selectedProviders, provider]; if (isChecked) {
// If all are now selected, reset to [] (= all, no restriction) onChange({ include: selection.include.filter((p) => p !== provider), exclude: [] });
if (updated.length === allowedProviders.length) {
onChange([]);
} else { } else {
onChange(updated); const nextInclude = [...selection.include, provider];
if (nextInclude.length === allowedProviders.length) {
onChange({ include: [PROVIDER_ALL], exclude: [] });
} else {
onChange({ include: nextInclude, exclude: [] });
}
} }
} }
}; };
const handleSelectAll = () => { const _handleSelectAll = () => {
onChange([]); // Empty = all active, no restriction onChange({ include: [PROVIDER_ALL], exclude: [] });
}; };
// Summary icon for button
const summaryIcon = useMemo(() => { const summaryIcon = useMemo(() => {
if (noneSelected) return '⊘';
if (effectiveSelection.length === 1) { if (effectiveSelection.length === 1) {
return PROVIDER_ICONS[effectiveSelection[0]] || '🔌'; return PROVIDER_ICONS[effectiveSelection[0]] || '🔌';
} }
return '🤖'; return '⚡';
}, [effectiveSelection]); }, [effectiveSelection, noneSelected]);
const summaryHint = useMemo(() => {
if (noneSelected) return 'Kein Provider ausgewählt';
if (allSelected) return 'Alle Provider aktiv (dynamisch)';
if (selection.include.includes(PROVIDER_ALL)) {
return `Alle ausser ${selection.exclude.length} Provider`;
}
return `${effectiveSelection.length} von ${allowedProviders.length} Provider`;
}, [noneSelected, allSelected, selection, effectiveSelection, allowedProviders]);
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`} className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}
> >
{/* Trigger Button - styled like iconButton */} <button
<button
type="button" type="button"
className={styles.triggerButton} className={styles.triggerButton}
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
@ -220,36 +283,35 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
> >
<span className={styles.buttonIcon}>{summaryIcon}</span> <span className={styles.buttonIcon}>{summaryIcon}</span>
</button> </button>
{/* Dropdown Content */}
{isExpanded && ( {isExpanded && (
<div className={styles.dropdownContent}> <div className={styles.dropdownContent}>
{showLabel && <div className={styles.dropdownHeader}>{label}</div>} {showLabel && <div className={styles.dropdownHeader}>{label}</div>}
<div className={styles.selectActions}> <div className={styles.selectActions}>
<button <button
type="button" type="button"
onClick={handleSelectAll} onClick={_handleSelectAll}
disabled={disabled} disabled={disabled}
className={`${styles.actionButton} ${isAllSelected ? styles.active : ''}`} className={`${styles.actionButton} ${allSelected ? styles.active : ''}`}
> >
Alle Alle
</button> </button>
</div> </div>
{loading ? ( {loading ? (
<div className={styles.loading}>Lade...</div> <div className={styles.loading}>Lade...</div>
) : ( ) : (
<div className={styles.checkboxList}> <div className={styles.checkboxList}>
{allowedProviders.map((provider) => ( {allowedProviders.map((provider) => (
<label <label
key={provider} key={provider}
className={`${styles.checkboxItem} ${disabled ? styles.disabled : ''}`} className={`${styles.checkboxItem} ${disabled ? styles.disabled : ''}`}
> >
<input <input
type="checkbox" type="checkbox"
checked={effectiveSelection.includes(provider)} checked={effectiveSelection.includes(provider)}
onChange={() => handleToggle(provider)} onChange={() => _handleToggle(provider)}
disabled={disabled} disabled={disabled}
/> />
<span className={styles.icon}>{PROVIDER_ICONS[provider] || '🔌'}</span> <span className={styles.icon}>{PROVIDER_ICONS[provider] || '🔌'}</span>
@ -260,12 +322,8 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
))} ))}
</div> </div>
)} )}
{isAllSelected && !loading && ( <div className={styles.hint}>{summaryHint}</div>
<div className={styles.hint}>
Alle Provider aktiv (kein Filter)
</div>
)}
</div> </div>
)} )}
</div> </div>
@ -288,7 +346,7 @@ export const ProviderBadges: React.FC<ProviderBadgesProps> = ({
if (providers.length === 0) { if (providers.length === 0) {
return <span className={styles.allProviders}>Alle Provider</span>; return <span className={styles.allProviders}>Alle Provider</span>;
} }
return ( return (
<div className={`${styles.providerBadges} ${className || ''}`}> <div className={`${styles.providerBadges} ${className || ''}`}>
{providers.map((provider) => ( {providers.map((provider) => (
@ -300,5 +358,4 @@ export const ProviderBadges: React.FC<ProviderBadgesProps> = ({
); );
}; };
// Default export
export default ProviderSelect; export default ProviderSelect;

View file

@ -5,6 +5,19 @@
export { export {
ProviderSelect, ProviderSelect,
ProviderMultiSelect, ProviderMultiSelect,
ProviderBadges ProviderBadges,
} from './ProviderSelector'; } from './ProviderSelector';
export {
PROVIDER_ALL,
_defaultProviderSelection,
_resolveProviders,
_isAllSelected,
_isNoneSelected,
_migrateFromLegacy,
_toBackendProviders,
} from './ProviderSelector';
export type { ProviderSelection } from './ProviderSelector';
export { default } from './ProviderSelector'; export { default } from './ProviderSelector';

View file

@ -82,7 +82,7 @@ export function usePrompt() {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ style={{
background: 'var(--surface-color, #1a1a2e)', background: 'var(--surface-color, #1a1a2e)',
border: '1px solid var(--color-border, #333)', border: '1px solid var(--border-color, var(--color-border, #333))',
borderRadius: '12px', borderRadius: '12px',
padding: '1.5rem', padding: '1.5rem',
minWidth: 360, maxWidth: 500, minWidth: 360, maxWidth: 500,
@ -116,9 +116,9 @@ export function usePrompt() {
style={{ style={{
padding: '10px 14px', padding: '10px 14px',
borderRadius: '8px', borderRadius: '8px',
border: '1px solid var(--color-border, #444)', border: '1px solid var(--border-color, var(--color-border, #ccc))',
background: 'var(--input-bg, #0d0d1a)', background: 'var(--input-bg, var(--bg-primary, #ffffff))',
color: 'var(--text-primary, #e0e0e0)', color: 'var(--text-primary, #1a1a1a)',
fontSize: '0.9rem', fontSize: '0.9rem',
outline: 'none', outline: 'none',
width: '100%', width: '100%',

View file

@ -240,16 +240,9 @@ function Login() {
<button <button
type="button" type="button"
className={styles.ctaPrimary} className={styles.ctaPrimary}
onClick={() => navigate('/register?type=personal', { state: location.state })} onClick={() => navigate('/register', { state: location.state })}
> >
Kostenlos testen Kostenlos registrieren
</button>
<button
type="button"
className={styles.ctaSecondary}
onClick={() => navigate('/register?type=company', { state: location.state })}
>
Für Unternehmen
</button> </button>
</div> </div>
</div> </div>

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { FaEnvelopeOpenText } from 'react-icons/fa'; import { FaEnvelopeOpenText } from 'react-icons/fa';
import styles from './Register.module.css'; import styles from './Register.module.css';
@ -19,7 +19,6 @@ function Register() {
const { register, error: registerError, isLoading } = useRegister(); const { register, error: registerError, isLoading } = useRegister();
const { error: msalError } = useMsalRegister(); const { error: msalError } = useMsalRegister();
const { checkAvailability, isChecking, error: availabilityError } = useUsernameAvailability(); const { checkAvailability, isChecking, error: availabilityError } = useUsernameAvailability();
// Pre-fill from invitation if provided via location.state
const invitationUsername = (location.state as any)?.invitationUsername || ''; const invitationUsername = (location.state as any)?.invitationUsername || '';
const invitationEmail = (location.state as any)?.invitationEmail || ''; const invitationEmail = (location.state as any)?.invitationEmail || '';
const [formData, setFormData] = useState<RegisterFormData>({ const [formData, setFormData] = useState<RegisterFormData>({
@ -27,10 +26,6 @@ function Register() {
email: invitationEmail, email: invitationEmail,
fullName: '' fullName: ''
}); });
const [searchParams] = useSearchParams();
const registrationType = searchParams.get('type') === 'company' ? 'company' : 'personal';
const [companyName, setCompanyName] = useState('');
const [companyNameFocused, setCompanyNameFocused] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [usernameFocused, setUsernameFocused] = useState(false); const [usernameFocused, setUsernameFocused] = useState(false);
@ -38,19 +33,13 @@ function Register() {
const [fullNameFocused, setFullNameFocused] = useState(false); const [fullNameFocused, setFullNameFocused] = useState(false);
const [usernameHighlight, setUsernameHighlight] = useState(false); const [usernameHighlight, setUsernameHighlight] = useState(false);
// Check for pending invitation
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY); const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
const hasPendingInvitation = !!pendingInvitationToken; const hasPendingInvitation = !!pendingInvitationToken;
// Set page title and generate CSRF token
useEffect(() => { useEffect(() => {
document.title = registrationType === 'company' document.title = "PowerOn AI Platform - Registrieren";
? "PowerOn AI Platform - Unternehmenskonto erstellen"
: "PowerOn AI Platform - Kostenlos testen";
// Generate CSRF token for new security implementation
generateAndStoreCSRFToken(); generateAndStoreCSRFToken();
}, [registrationType]); }, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target; const { name, value } = e.target;
@ -59,13 +48,12 @@ function Register() {
[name]: value [name]: value
})); }));
setValidationError(null); setValidationError(null);
// Reset username highlight when user starts typing in username field
if (name === 'username') { if (name === 'username') {
setUsernameHighlight(false); setUsernameHighlight(false);
} }
}; };
const validateForm = (): boolean => { const _validateForm = (): boolean => {
if (!formData.username || !formData.email || !formData.fullName) { if (!formData.username || !formData.email || !formData.fullName) {
setValidationError('Bitte füllen Sie alle Pflichtfelder aus.'); setValidationError('Bitte füllen Sie alle Pflichtfelder aus.');
return false; return false;
@ -76,27 +64,20 @@ function Register() {
return false; return false;
} }
if (registrationType === 'company' && !companyName.trim()) {
setValidationError('Bitte geben Sie einen Firmennamen ein.');
return false;
}
return true; return true;
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!validateForm()) { if (!_validateForm()) {
return; return;
} }
try { try {
// First check username availability
const availabilityResult = await checkAvailability(formData.username, 'local'); const availabilityResult = await checkAvailability(formData.username, 'local');
if (!availabilityResult.available) { if (!availabilityResult.available) {
// Check if the error message is about username being taken
const errorMessage = availabilityResult.message || 'Username is not available'; const errorMessage = availabilityResult.message || 'Username is not available';
if (errorMessage === 'Username is already taken') { if (errorMessage === 'Username is already taken') {
setValidationError('Benutzername ist bereits vergeben'); setValidationError('Benutzername ist bereits vergeben');
@ -107,25 +88,20 @@ function Register() {
return; return;
} }
// Username is available, proceed with registration (no password - magic link flow) await register({ ...formData, registrationType: 'personal' });
await register({ ...formData, registrationType, companyName: registrationType === 'company' ? companyName : undefined });
// Build success message
let message = 'Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.'; let message = 'Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.';
if (hasPendingInvitation) { if (hasPendingInvitation) {
message += ' Nach dem Setzen Ihres Passworts können Sie sich anmelden und Ihre Einladung annehmen.'; message += ' Nach dem Setzen Ihres Passworts können Sie sich anmelden und Ihre Einladung annehmen.';
} }
// Show success message instead of immediate redirect
setSuccessMessage(message); setSuccessMessage(message);
// Redirect to login page after delay
setTimeout(() => { setTimeout(() => {
navigate('/login', { navigate('/login', {
state: { state: {
registered: true, registered: true,
message: 'Registrierung erfolgreich. Bitte prüfen Sie Ihre E-Mail für den Passwort-Link.', message: 'Registrierung erfolgreich. Bitte prüfen Sie Ihre E-Mail für den Passwort-Link.',
// Pass along invitation state
...(location.state || {}) ...(location.state || {})
} }
}); });
@ -135,8 +111,7 @@ function Register() {
} }
}; };
// Helper function to safely get error message const _getErrorMessage = () => {
const getErrorMessage = () => {
if (validationError) return validationError; if (validationError) return validationError;
if (registerError) return typeof registerError === 'string' ? registerError : 'Registration failed'; if (registerError) return typeof registerError === 'string' ? registerError : 'Registration failed';
if (msalError) return typeof msalError === 'string' ? msalError : 'Microsoft registration failed'; if (msalError) return typeof msalError === 'string' ? msalError : 'Microsoft registration failed';
@ -157,7 +132,6 @@ function Register() {
<div className={styles.loginSection}> <div className={styles.loginSection}>
<div className={styles.loginBox}> <div className={styles.loginBox}>
<div className={styles.loginForm}> <div className={styles.loginForm}>
{/* Pending invitation notice */}
{hasPendingInvitation && !successMessage && ( {hasPendingInvitation && !successMessage && (
<div className={styles.invitationNotice}> <div className={styles.invitationNotice}>
<FaEnvelopeOpenText className={styles.invitationIcon} /> <FaEnvelopeOpenText className={styles.invitationIcon} />
@ -165,8 +139,8 @@ function Register() {
</div> </div>
)} )}
{getErrorMessage() && ( {_getErrorMessage() && (
<div className={styles.error}>{getErrorMessage()}</div> <div className={styles.error}>{_getErrorMessage()}</div>
)} )}
{successMessage && ( {successMessage && (
@ -203,22 +177,6 @@ function Register() {
<label className={emailFocused || formData.email ? styles.focusedLabel : styles.label}>E-Mail</label> <label className={emailFocused || formData.email ? styles.focusedLabel : styles.label}>E-Mail</label>
</div> </div>
{registrationType === 'company' && (
<div className={styles.floatingLabelInput}>
<input
type="text"
name="companyName"
placeholder=" "
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
onFocus={() => setCompanyNameFocused(true)}
onBlur={() => setCompanyNameFocused(false)}
className={`${styles.input} ${companyNameFocused || companyName ? styles.focused : ''}`}
/>
<label className={companyNameFocused || companyName ? styles.focusedLabel : styles.label}>Firmenname *</label>
</div>
)}
<div className={styles.floatingLabelInput}> <div className={styles.floatingLabelInput}>
<input <input
type="text" type="text"
@ -248,7 +206,7 @@ function Register() {
onClick={handleSubmit} onClick={handleSubmit}
disabled={isLoading || isChecking} disabled={isLoading || isChecking}
> >
{isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : registrationType === 'company' ? 'Unternehmenskonto erstellen' : 'Kostenlos testen'} {isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : 'Kostenlos registrieren'}
</button> </button>
</> </>
)} )}

View file

@ -236,6 +236,7 @@ export const AdminMandateWizardPage: React.FC = () => {
if (billingSaved) { if (billingSaved) {
showSuccess('Erstellt', 'Mandant inkl. Abrechnung gespeichert'); showSuccess('Erstellt', 'Mandant inkl. Abrechnung gespeichert');
} }
window.dispatchEvent(new CustomEvent('features-changed'));
await loadMandates(); await loadMandates();
} catch (err: unknown) { } catch (err: unknown) {
const e = err as { response?: { data?: { detail?: string } }; message?: string }; const e = err as { response?: { data?: { detail?: string } }; message?: string };

View file

@ -5,6 +5,7 @@
import React, { useState, useCallback, useRef, useEffect } from 'react'; import React, { useState, useCallback, useRef, useEffect } from 'react';
import { ProviderMultiSelect } from '../../../components/ProviderSelector'; import { ProviderMultiSelect } from '../../../components/ProviderSelector';
import type { ProviderSelection } from '../../../components/ProviderSelector';
import { getPageIcon } from '../../../config/pageRegistry'; import { getPageIcon } from '../../../config/pageRegistry';
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture'; import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace'; import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace';
@ -48,8 +49,8 @@ interface WorkspaceInputProps {
onRemovePendingFile?: (fileId: string) => void; onRemovePendingFile?: (fileId: string) => void;
onFileUploadClick?: () => void; onFileUploadClick?: () => void;
uploading?: boolean; uploading?: boolean;
selectedProviders?: string[]; providerSelection?: ProviderSelection;
onProvidersChange?: (providers: string[]) => void; onProviderSelectionChange?: (selection: ProviderSelection) => void;
isMobile?: boolean; isMobile?: boolean;
onTreeItemsDrop?: (items: TreeItemDrop[]) => void; onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
onPasteAsFile?: (file: File) => void; onPasteAsFile?: (file: File) => void;
@ -69,8 +70,8 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
onRemovePendingFile, onRemovePendingFile,
onFileUploadClick, onFileUploadClick,
uploading = false, uploading = false,
selectedProviders = [], providerSelection,
onProvidersChange, onProviderSelectionChange,
isMobile = false, isMobile = false,
onTreeItemsDrop, onTreeItemsDrop,
onPasteAsFile, onPasteAsFile,
@ -653,12 +654,11 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
</div> </div>
)} )}
{onProvidersChange && ( {onProviderSelectionChange && providerSelection && (
<ProviderMultiSelect <ProviderMultiSelect
selectedProviders={selectedProviders} selection={providerSelection}
onChange={onProvidersChange} onChange={onProviderSelectionChange}
showLabel={false} showLabel={false}
excludeByDefault={['privatellm']}
disabled={isProcessing} disabled={isProcessing}
/> />
)} )}

View file

@ -19,6 +19,9 @@ import { ToolActivityLog } from './ToolActivityLog';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar'; import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import api from '../../../api'; import api from '../../../api';
import { _defaultProviderSelection, _toBackendProviders } from '../../../components/ProviderSelector';
import type { ProviderSelection } from '../../../components/ProviderSelector';
import { useBilling } from '../../../hooks/useBilling';
function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) { function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) {
const [width, setWidth] = useState(initialWidth); const [width, setWidth] = useState(initialWidth);
@ -81,7 +84,8 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
const [udbTab, setUdbTab] = useState<UdbTab>('chats'); const [udbTab, setUdbTab] = useState<UdbTab>('chats');
const [selectedFileId, setSelectedFileId] = useState<string | null>(null); const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]); const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
const [selectedProviders, setSelectedProviders] = useState<string[]>([]); const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection());
const { allowedProviders } = useBilling();
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [draftAppend, setDraftAppend] = useState(''); const [draftAppend, setDraftAppend] = useState('');
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
@ -414,7 +418,8 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
instanceId={instanceId} instanceId={instanceId}
onSend={(prompt, fileIds, dataSourceIds, featureDataSourceIds, options) => { onSend={(prompt, fileIds, dataSourceIds, featureDataSourceIds, options) => {
const allFileIds = [...new Set([...pendingFiles.map(f => f.fileId), ...(fileIds || [])])]; const allFileIds = [...new Set([...pendingFiles.map(f => f.fileId), ...(fileIds || [])])];
workspace.sendMessage(prompt, allFileIds, dataSourceIds, selectedProviders, featureDataSourceIds, options); const resolvedProviders = _toBackendProviders(providerSelection, allowedProviders);
workspace.sendMessage(prompt, allFileIds, dataSourceIds, resolvedProviders, featureDataSourceIds, options);
setPendingFiles([]); setPendingFiles([]);
}} }}
isProcessing={workspace.isProcessing} isProcessing={workspace.isProcessing}
@ -426,8 +431,8 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
onRemovePendingFile={_handleRemovePendingFile} onRemovePendingFile={_handleRemovePendingFile}
onFileUploadClick={() => fileInputRef.current?.click()} onFileUploadClick={() => fileInputRef.current?.click()}
uploading={fileOps.uploadingFile} uploading={fileOps.uploadingFile}
selectedProviders={selectedProviders} providerSelection={providerSelection}
onProvidersChange={setSelectedProviders} onProviderSelectionChange={setProviderSelection}
isMobile={isMobile} isMobile={isMobile}
onTreeItemsDrop={_handleTreeItemsDrop} onTreeItemsDrop={_handleTreeItemsDrop}
onPasteAsFile={_uploadAndAttach} onPasteAsFile={_uploadAndAttach}