diff --git a/src/components/AutomationEditor/AutomationEditor.tsx b/src/components/AutomationEditor/AutomationEditor.tsx index 56524d9..aa81bc0 100644 --- a/src/components/AutomationEditor/AutomationEditor.tsx +++ b/src/components/AutomationEditor/AutomationEditor.tsx @@ -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 { Popup } from '../UiComponents/Popup'; 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 { useLanguage } from '../../providers/language/LanguageContext'; import { useWorkflowActions } from '../../hooks/useAutomations'; @@ -374,7 +376,8 @@ export const AutomationEditor: React.FC = ({ const [label, setLabel] = useState(''); const [schedule, setSchedule] = useState('0 22 * * *'); const [active, setActive] = useState(false); - const [allowedProviders, setAllowedProviders] = useState([]); + const [providerSelection, setProviderSelection] = useState(_defaultProviderSelection()); + const { allowedProviders: billingProviders } = useBilling(); // Template multilingual fields const [labelMulti, setLabelMulti] = useState({ en: '', de: '' }); @@ -537,7 +540,7 @@ export const AutomationEditor: React.FC = ({ setLabel(def.label || ''); setSchedule(def.schedule || '0 22 * * *'); setActive(def.active ?? false); - setAllowedProviders(def.allowedProviders || []); + setProviderSelection(_migrateFromLegacy(def.allowedProviders || [])); } // Extract template JSON @@ -693,7 +696,7 @@ export const AutomationEditor: React.FC = ({ active, template: templateJson, placeholders, - allowedProviders + allowedProviders: _toBackendProviders(providerSelection, billingProviders), }; } @@ -709,7 +712,7 @@ export const AutomationEditor: React.FC = ({ } finally { 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 const editorTitle = title || (mode === 'template' @@ -864,12 +867,12 @@ export const AutomationEditor: React.FC = ({ {/* Allowed AI Providers */}

- Beschränkt die Automation auf bestimmte AI-Provider. Leer = alle erlaubt. + Beschränkt die Automation auf bestimmte AI-Provider. «Alle» = dynamisch alle erlaubten.

diff --git a/src/components/OnboardingAssistant.tsx b/src/components/OnboardingAssistant.tsx index 689d624..2ea90ec 100644 --- a/src/components/OnboardingAssistant.tsx +++ b/src/components/OnboardingAssistant.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import api from '../api'; +import OnboardingWizard from './OnboardingWizard'; interface OnboardingStep { id: string; @@ -17,7 +18,7 @@ interface OnboardingAssistantProps { const _STORAGE_KEY = 'onboarding_hidden'; const _CALLOUTS: Record = { - 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.', 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.', @@ -50,46 +51,59 @@ const OnboardingAssistant: React.FC = ({ onDismiss }) const [steps, setSteps] = useState([]); const [loading, setLoading] = useState(true); const [dontShowAgain, setDontShowAgain] = useState(false); + const [showWizard, setShowWizard] = useState(false); const _checkOnboardingState = useCallback(async () => { setLoading(true); try { const onboardingSteps: OnboardingStep[] = []; - let hasMandate = false; + // Check admin mandates (user-owned or where user is admin) + let hasAdminMandate = false; try { const mandatesRes = await api.get('/api/store/mandates'); const mandates = mandatesRes.data?.mandates || mandatesRes.data || []; - hasMandate = Array.isArray(mandates) && mandates.length > 0; + hasAdminMandate = Array.isArray(mandates) && mandates.length > 0; } catch { /* ignore */ } - onboardingSteps.push({ - id: 'mandate', - label: 'Mandant einrichten', - description: hasMandate - ? 'Dein Mandant ist eingerichtet.' - : 'Richte deinen ersten Mandanten ein.', - completed: hasMandate, - action: hasMandate ? undefined : () => navigate('/store'), - }); - + // Check if user has any feature access (via navigation = mandate member) let hasFeature = false; - let firstInstancePath: string | undefined; + let workspaceInstancePath: string | undefined; + let workspaceInstanceIds: string[] = []; try { 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 f of m.features || []) { for (const inst of f.instances || []) { - if (!hasFeature) hasFeature = true; - if (!firstInstancePath && inst.views?.length > 0) { - firstInstancePath = inst.views[0].uiPath; + hasFeature = true; + if (f.uiComponent === 'feature.workspace' && inst.views?.length > 0) { + workspaceInstanceIds.push(inst.id); + if (!workspaceInstancePath) { + workspaceInstancePath = inst.views[0].uiPath; + } } } } } } 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({ id: 'feature', label: 'Erstes Feature aktivieren', @@ -103,8 +117,8 @@ const OnboardingAssistant: React.FC = ({ onDismiss }) let hasConnection = false; try { const connRes = await api.get('/api/connections/'); - const connections = connRes.data?.data || connRes.data || []; - hasConnection = Array.isArray(connections) && connections.length > 0; + const items = connRes.data?.items || connRes.data?.data || connRes.data || []; + hasConnection = Array.isArray(items) && items.length > 0; } catch { /* ignore */ } onboardingSteps.push({ @@ -118,25 +132,16 @@ const OnboardingAssistant: React.FC = ({ onDismiss }) }); let hasChat = false; - if (hasFeature && firstInstancePath) { + for (const instId of workspaceInstanceIds) { + if (hasChat) break; try { - const featuresRes = await api.get('/api/store/features'); - const features = featuresRes.data || []; - for (const f of features) { - 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 */ } - } - } + const wfRes = await api.get(`/api/workspace/${instId}/workflows`); + const wfs = wfRes.data?.workflows || wfRes.data?.data || wfRes.data?.items || []; + if (Array.isArray(wfs) && wfs.length > 0) hasChat = true; } catch { /* ignore */ } } - const _chatAction = firstInstancePath ? () => navigate(firstInstancePath!) : undefined; + const chatAction = workspaceInstancePath ? () => navigate(workspaceInstancePath!) : undefined; onboardingSteps.push({ id: 'chat', label: 'Ersten AI-Chat starten', @@ -144,7 +149,7 @@ const OnboardingAssistant: React.FC = ({ onDismiss }) ? 'Du hast bereits Chats gestartet.' : 'Starte deinen ersten Chat mit dem AI-Assistenten.', completed: hasChat, - action: hasChat ? undefined : _chatAction, + action: hasChat ? undefined : chatAction, }); setSteps(onboardingSteps); @@ -180,6 +185,18 @@ const OnboardingAssistant: React.FC = ({ onDismiss }) onDismiss?.(); }; + if (showWizard) { + return ( + { + setShowWizard(false); + _checkOnboardingState(); + }} + onDismiss={() => setShowWizard(false)} + /> + ); + } + if (hidden || loading) return null; const completedCount = steps.filter(s => s.completed).length; diff --git a/src/components/OnboardingWizard.tsx b/src/components/OnboardingWizard.tsx index a1e9fa4..ae8f4c1 100644 --- a/src/components/OnboardingWizard.tsx +++ b/src/components/OnboardingWizard.tsx @@ -8,7 +8,7 @@ interface OnboardingWizardProps { const OnboardingWizard: React.FC = ({ onComplete, onDismiss }) => { const [planKey, setPlanKey] = useState<'TRIAL_7D' | 'STANDARD_MONTHLY'>('TRIAL_7D'); - const [companyName, setCompanyName] = useState(''); + const [mandateName, setMandateName] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -16,10 +16,15 @@ const OnboardingWizard: React.FC = ({ onComplete, onDismi setLoading(true); setError(null); try { - await api.post('/api/local/onboarding', { + const res = await api.post('/api/local/onboarding', { 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(); } catch (err: any) { setError(err?.response?.data?.detail || 'Fehler bei der Einrichtung'); @@ -38,9 +43,9 @@ const OnboardingWizard: React.FC = ({ onComplete, onDismi background: 'var(--bg-primary, #fff)', borderRadius: '12px', padding: '32px', maxWidth: '480px', width: '90%', boxShadow: '0 8px 32px rgba(0,0,0,0.2)', }}> -

Willkommen bei PowerOn

+

Mandant erstellen

- Wähle dein Abo und leg los. + Erstelle deinen eigenen Arbeitsbereich mit Abo-Auswahl.

@@ -80,8 +85,8 @@ const OnboardingWizard: React.FC = ({ onComplete, onDismi Name des Mandanten (optional) setCompanyName(e.target.value)} + type="text" value={mandateName} + onChange={(e) => setMandateName(e.target.value)} placeholder="z. B. Firmenname oder Projektname" style={{ width: '100%', padding: '10px 12px', borderRadius: '6px', @@ -98,7 +103,7 @@ const OnboardingWizard: React.FC = ({ onComplete, onDismi padding: '10px 20px', borderRadius: '6px', border: '1px solid var(--border, #d1d5db)', background: 'transparent', cursor: 'pointer', }}> - Später + Abbrechen
diff --git a/src/components/ProviderSelector/ProviderSelector.module.css b/src/components/ProviderSelector/ProviderSelector.module.css index 04d2be7..384c1c5 100644 --- a/src/components/ProviderSelector/ProviderSelector.module.css +++ b/src/components/ProviderSelector/ProviderSelector.module.css @@ -53,17 +53,17 @@ justify-content: center; width: 36px; height: 36px; - border: 1px solid var(--border-color, #3a3a3a); + border: 1px solid var(--border-color, #e0e0e0); border-radius: 6px; - background: var(--surface-color, #2d2d2d); - color: var(--text-secondary, #888); + background: var(--surface-color, #ffffff); + color: var(--text-secondary, #666666); cursor: pointer; transition: all 0.2s; } .triggerButton:hover:not(:disabled) { - background: var(--bg-secondary, #3a3a3a); - color: var(--text-primary, #fff); + background: var(--hover-bg, rgba(0, 0, 0, 0.06)); + color: var(--text-primary, #1a1a1a); } .triggerButton:disabled { @@ -83,20 +83,20 @@ transform: translateX(-50%); z-index: 1000; padding: 8px; - background: var(--surface-color, #2d2d2d); - border: 1px solid var(--border-color, #3a3a3a); + background: var(--surface-color, #ffffff); + border: 1px solid var(--border-color, #e0e0e0); 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; } .dropdownHeader { font-size: 0.75rem; font-weight: 500; - color: var(--text-secondary, #888); + color: var(--text-secondary, #666666); padding: 4px 8px; margin-bottom: 4px; - border-bottom: 1px solid var(--border-color, #3a3a3a); + border-bottom: 1px solid var(--border-color, #e0e0e0); } .selectActions { @@ -108,18 +108,18 @@ .actionButton { flex: 1; padding: 4px 8px; - border: 1px solid var(--border-color, #3a3a3a); + border: 1px solid var(--border-color, #e0e0e0); border-radius: 4px; - background: var(--bg-secondary, #252525); - color: var(--text-secondary, #888); + background: var(--bg-secondary, #f8f9fa); + color: var(--text-secondary, #666666); font-size: 0.75rem; cursor: pointer; transition: all 0.2s ease; } .actionButton:hover:not(:disabled) { - background: var(--bg-hover, #3a3a3a); - color: var(--text-primary, #fff); + background: var(--hover-bg, rgba(0, 0, 0, 0.06)); + color: var(--text-primary, #1a1a1a); } .actionButton.active { @@ -138,7 +138,7 @@ flex-direction: column; gap: 2px; padding: 4px; - background: var(--bg-secondary, #252525); + background: var(--bg-secondary, #f8f9fa); border-radius: 4px; max-height: 200px; overflow-y: auto; @@ -151,12 +151,13 @@ padding: 6px 8px; border-radius: 4px; cursor: pointer; - transition: background 0.15s ease; - color: var(--text-primary, #e0e0e0); + transition: background 0.15s ease, color 0.15s ease; + color: var(--text-primary, #1a1a1a); } .checkboxItem:hover { - background: var(--bg-hover, #3a3a3a); + background: var(--hover-bg, rgba(0, 0, 0, 0.06)); + color: var(--text-primary, #1a1a1a); } .checkboxItem.disabled { @@ -177,12 +178,12 @@ .providerName { font-size: 0.8rem; - color: var(--text-primary, #e0e0e0); + color: inherit; } .hint { font-size: 0.7rem; - color: var(--text-tertiary, #666); + color: var(--text-tertiary, #888888); text-align: center; padding: 4px 0; } @@ -192,10 +193,24 @@ align-items: center; justify-content: center; padding: 12px; - color: var(--text-secondary, #888); + color: var(--text-secondary, #666666); 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 ============================================================================ */ diff --git a/src/components/ProviderSelector/ProviderSelector.tsx b/src/components/ProviderSelector/ProviderSelector.tsx index e41cefb..24cfebc 100644 --- a/src/components/ProviderSelector/ProviderSelector.tsx +++ b/src/components/ProviderSelector/ProviderSelector.tsx @@ -1,19 +1,80 @@ /** * ProviderSelector Component - * + * * Wiederverwendbare Komponente zur Auswahl von AICore-Providern. * Kann im AI Workspace und Automation Editor verwendet werden. - * - * Features: - * - Dropdown für Einzelauswahl - * - Checkbox-Liste für Mehrfachauswahl - * - Lädt verfügbare Provider aus dem Billing-System + * + * Selektionsmodell: + * ProviderSelection { include: string[], exclude: string[] } + * - include(["ALL"]), exclude([]) → alle verfügbaren Provider (dynamisch) + * - 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 { useBilling } from '../../hooks/useBilling'; 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 const PROVIDER_LABELS: Record = { anthropic: 'Anthropic (Claude)', @@ -25,7 +86,6 @@ const PROVIDER_LABELS: Record = { internal: 'Internal', }; -// Provider icons (emojis for simplicity) const PROVIDER_ICONS: Record = { anthropic: '🤖', openai: '💬', @@ -58,20 +118,20 @@ export const ProviderSelect: React.FC = ({ showLabel = true, }) => { const { allowedProviders, loadAllowedProviders, loading } = useBilling(); - + useEffect(() => { if (allowedProviders.length === 0 && !loading) { loadAllowedProviders(); } }, []); - + const providerOptions = useMemo(() => { return allowedProviders.map((provider) => ({ value: provider, label: `${PROVIDER_ICONS[provider] || '🔌'} ${PROVIDER_LABELS[provider] || provider}`, })); }, [allowedProviders]); - + return (
{showLabel && } @@ -93,12 +153,12 @@ export const ProviderSelect: React.FC = ({ }; // ============================================================================ -// MULTI SELECT COMPONENT (Checkbox List) +// MULTI SELECT COMPONENT (Checkbox List) — include / exclude model // ============================================================================ interface ProviderMultiSelectProps { - selectedProviders: string[]; - onChange: (providers: string[]) => void; + selection: ProviderSelection; + onChange: (selection: ProviderSelection) => void; disabled?: boolean; className?: string; label?: string; @@ -108,7 +168,7 @@ interface ProviderMultiSelectProps { } export const ProviderMultiSelect: React.FC = ({ - selectedProviders, + selection, onChange, disabled = false, className, @@ -121,97 +181,100 @@ export const ProviderMultiSelect: React.FC = ({ const [initialExcludeApplied, setInitialExcludeApplied] = useState(false); const containerRef = useRef(null); const { allowedProviders, loadAllowedProviders, loading } = useBilling(); - + useEffect(() => { if (allowedProviders.length === 0 && !loading) { loadAllowedProviders(); } }, []); - - // Apply default exclusions when providers first load + + // Apply default exclusions once when providers first load useEffect(() => { if ( !initialExcludeApplied && allowedProviders.length > 0 && excludeByDefault.length > 0 && - selectedProviders.length === 0 + _isAllSelected(selection) ) { - const initialSelection = allowedProviders.filter( - (p) => !excludeByDefault.includes(p) - ); - // Only apply if there's actually something to exclude - if (initialSelection.length < allowedProviders.length) { - onChange(initialSelection); - } + onChange({ include: [PROVIDER_ALL], exclude: [...excludeByDefault] }); setInitialExcludeApplied(true); } - }, [allowedProviders, excludeByDefault, initialExcludeApplied, selectedProviders.length, onChange]); - - // Click outside handler - const handleClickOutside = useCallback((event: MouseEvent) => { + }, [allowedProviders, excludeByDefault, initialExcludeApplied, selection, onChange]); + + const _handleClickOutside = useCallback((event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setIsExpanded(false); } }, []); - + useEffect(() => { if (isExpanded) { - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); + document.addEventListener('mousedown', _handleClickOutside); + return () => document.removeEventListener('mousedown', _handleClickOutside); } - }, [isExpanded, handleClickOutside]); - - // Effective selection: empty array = all providers active (no restriction) - const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders; - - // "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 handleToggle = (provider: string) => { - if (selectedProviders.length === 0) { - // Currently "all active" (no restriction) -> make explicit: all except the toggled one - onChange(allowedProviders.filter((p) => p !== provider)); - } else if (selectedProviders.includes(provider)) { - // Deactivate: remove from selection - const remaining = selectedProviders.filter((p) => p !== provider); - // If removing leaves all others selected, reset to [] (= all, no restriction) - if (remaining.length === allowedProviders.length) { - onChange([]); + }, [isExpanded, _handleClickOutside]); + + const effectiveSelection = useMemo( + () => _resolveProviders(selection, allowedProviders), + [selection, allowedProviders], + ); + + const allSelected = _isAllSelected(selection); + const noneSelected = effectiveSelection.length === 0; + + const _handleToggle = (provider: string) => { + const isChecked = effectiveSelection.includes(provider); + + if (selection.include.includes(PROVIDER_ALL)) { + // Currently ALL-based: toggle modifies exclude list + if (isChecked) { + onChange({ include: [PROVIDER_ALL], exclude: [...selection.exclude, provider] }); } else { - onChange(remaining); + const nextExclude = selection.exclude.filter((p) => p !== provider); + onChange({ include: [PROVIDER_ALL], exclude: nextExclude }); } } else { - // Activate: add to selection - const updated = [...selectedProviders, provider]; - // If all are now selected, reset to [] (= all, no restriction) - if (updated.length === allowedProviders.length) { - onChange([]); + // Explicit include list + if (isChecked) { + onChange({ include: selection.include.filter((p) => p !== provider), exclude: [] }); } 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 = () => { - onChange([]); // Empty = all active, no restriction + + const _handleSelectAll = () => { + onChange({ include: [PROVIDER_ALL], exclude: [] }); }; - - // Summary icon for button + const summaryIcon = useMemo(() => { + if (noneSelected) return '⊘'; if (effectiveSelection.length === 1) { return PROVIDER_ICONS[effectiveSelection[0]] || '🔌'; } - return '🤖'; - }, [effectiveSelection]); - + return '⚡'; + }, [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 ( -
- {/* Trigger Button - styled like iconButton */} - - - {/* Dropdown Content */} + {isExpanded && (
{showLabel &&
{label}
} - +
-
- + {loading ? (
Lade...
) : (
{allowedProviders.map((provider) => ( -
)} - - {isAllSelected && !loading && ( -
- Alle Provider aktiv (kein Filter) -
- )} + +
{summaryHint}
)}
@@ -288,7 +346,7 @@ export const ProviderBadges: React.FC = ({ if (providers.length === 0) { return Alle Provider; } - + return (
{providers.map((provider) => ( @@ -300,5 +358,4 @@ export const ProviderBadges: React.FC = ({ ); }; -// Default export export default ProviderSelect; diff --git a/src/components/ProviderSelector/index.ts b/src/components/ProviderSelector/index.ts index afe1b42..a2f6c79 100644 --- a/src/components/ProviderSelector/index.ts +++ b/src/components/ProviderSelector/index.ts @@ -5,6 +5,19 @@ export { ProviderSelect, ProviderMultiSelect, - ProviderBadges + ProviderBadges, } from './ProviderSelector'; + +export { + PROVIDER_ALL, + _defaultProviderSelection, + _resolveProviders, + _isAllSelected, + _isNoneSelected, + _migrateFromLegacy, + _toBackendProviders, +} from './ProviderSelector'; + +export type { ProviderSelection } from './ProviderSelector'; + export { default } from './ProviderSelector'; diff --git a/src/hooks/usePrompt.tsx b/src/hooks/usePrompt.tsx index b3117a2..c0c8837 100644 --- a/src/hooks/usePrompt.tsx +++ b/src/hooks/usePrompt.tsx @@ -82,7 +82,7 @@ export function usePrompt() { onClick={(e) => e.stopPropagation()} style={{ background: 'var(--surface-color, #1a1a2e)', - border: '1px solid var(--color-border, #333)', + border: '1px solid var(--border-color, var(--color-border, #333))', borderRadius: '12px', padding: '1.5rem', minWidth: 360, maxWidth: 500, @@ -116,9 +116,9 @@ export function usePrompt() { style={{ padding: '10px 14px', borderRadius: '8px', - border: '1px solid var(--color-border, #444)', - background: 'var(--input-bg, #0d0d1a)', - color: 'var(--text-primary, #e0e0e0)', + border: '1px solid var(--border-color, var(--color-border, #ccc))', + background: 'var(--input-bg, var(--bg-primary, #ffffff))', + color: 'var(--text-primary, #1a1a1a)', fontSize: '0.9rem', outline: 'none', width: '100%', diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 6fa20a7..4ac295f 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -240,16 +240,9 @@ function Login() { -
diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 95051cd..8060b2c 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -1,5 +1,5 @@ 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 styles from './Register.module.css'; @@ -19,7 +19,6 @@ function Register() { const { register, error: registerError, isLoading } = useRegister(); const { error: msalError } = useMsalRegister(); const { checkAvailability, isChecking, error: availabilityError } = useUsernameAvailability(); - // Pre-fill from invitation if provided via location.state const invitationUsername = (location.state as any)?.invitationUsername || ''; const invitationEmail = (location.state as any)?.invitationEmail || ''; const [formData, setFormData] = useState({ @@ -27,10 +26,6 @@ function Register() { email: invitationEmail, 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(null); const [successMessage, setSuccessMessage] = useState(null); const [usernameFocused, setUsernameFocused] = useState(false); @@ -38,19 +33,13 @@ function Register() { const [fullNameFocused, setFullNameFocused] = useState(false); const [usernameHighlight, setUsernameHighlight] = useState(false); - // Check for pending invitation const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY); const hasPendingInvitation = !!pendingInvitationToken; - // Set page title and generate CSRF token useEffect(() => { - document.title = registrationType === 'company' - ? "PowerOn AI Platform - Unternehmenskonto erstellen" - : "PowerOn AI Platform - Kostenlos testen"; - - // Generate CSRF token for new security implementation + document.title = "PowerOn AI Platform - Registrieren"; generateAndStoreCSRFToken(); - }, [registrationType]); + }, []); const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; @@ -59,13 +48,12 @@ function Register() { [name]: value })); setValidationError(null); - // Reset username highlight when user starts typing in username field if (name === 'username') { setUsernameHighlight(false); } }; - const validateForm = (): boolean => { + const _validateForm = (): boolean => { if (!formData.username || !formData.email || !formData.fullName) { setValidationError('Bitte füllen Sie alle Pflichtfelder aus.'); return false; @@ -76,27 +64,20 @@ function Register() { return false; } - if (registrationType === 'company' && !companyName.trim()) { - setValidationError('Bitte geben Sie einen Firmennamen ein.'); - return false; - } - return true; }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!validateForm()) { + if (!_validateForm()) { return; } try { - // First check username availability const availabilityResult = await checkAvailability(formData.username, 'local'); if (!availabilityResult.available) { - // Check if the error message is about username being taken const errorMessage = availabilityResult.message || 'Username is not available'; if (errorMessage === 'Username is already taken') { setValidationError('Benutzername ist bereits vergeben'); @@ -107,25 +88,20 @@ function Register() { return; } - // Username is available, proceed with registration (no password - magic link flow) - await register({ ...formData, registrationType, companyName: registrationType === 'company' ? companyName : undefined }); + await register({ ...formData, registrationType: 'personal' }); - // 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.'; if (hasPendingInvitation) { message += ' Nach dem Setzen Ihres Passworts können Sie sich anmelden und Ihre Einladung annehmen.'; } - // Show success message instead of immediate redirect setSuccessMessage(message); - // Redirect to login page after delay setTimeout(() => { navigate('/login', { state: { registered: true, message: 'Registrierung erfolgreich. Bitte prüfen Sie Ihre E-Mail für den Passwort-Link.', - // Pass along invitation 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 (registerError) return typeof registerError === 'string' ? registerError : 'Registration failed'; if (msalError) return typeof msalError === 'string' ? msalError : 'Microsoft registration failed'; @@ -157,7 +132,6 @@ function Register() {
- {/* Pending invitation notice */} {hasPendingInvitation && !successMessage && (
@@ -165,8 +139,8 @@ function Register() {
)} - {getErrorMessage() && ( -
{getErrorMessage()}
+ {_getErrorMessage() && ( +
{_getErrorMessage()}
)} {successMessage && ( @@ -203,22 +177,6 @@ function Register() {
- {registrationType === 'company' && ( -
- setCompanyName(e.target.value)} - onFocus={() => setCompanyNameFocused(true)} - onBlur={() => setCompanyNameFocused(false)} - className={`${styles.input} ${companyNameFocused || companyName ? styles.focused : ''}`} - /> - -
- )} -
- {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'} )} diff --git a/src/pages/admin/wizards/AdminMandateWizardPage.tsx b/src/pages/admin/wizards/AdminMandateWizardPage.tsx index 43814f8..1597127 100644 --- a/src/pages/admin/wizards/AdminMandateWizardPage.tsx +++ b/src/pages/admin/wizards/AdminMandateWizardPage.tsx @@ -236,6 +236,7 @@ export const AdminMandateWizardPage: React.FC = () => { if (billingSaved) { showSuccess('Erstellt', 'Mandant inkl. Abrechnung gespeichert'); } + window.dispatchEvent(new CustomEvent('features-changed')); await loadMandates(); } catch (err: unknown) { const e = err as { response?: { data?: { detail?: string } }; message?: string }; diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx index 9bc03a8..7138661 100644 --- a/src/pages/views/workspace/WorkspaceInput.tsx +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -5,6 +5,7 @@ import React, { useState, useCallback, useRef, useEffect } from 'react'; import { ProviderMultiSelect } from '../../../components/ProviderSelector'; +import type { ProviderSelection } from '../../../components/ProviderSelector'; import { getPageIcon } from '../../../config/pageRegistry'; import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture'; import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace'; @@ -48,8 +49,8 @@ interface WorkspaceInputProps { onRemovePendingFile?: (fileId: string) => void; onFileUploadClick?: () => void; uploading?: boolean; - selectedProviders?: string[]; - onProvidersChange?: (providers: string[]) => void; + providerSelection?: ProviderSelection; + onProviderSelectionChange?: (selection: ProviderSelection) => void; isMobile?: boolean; onTreeItemsDrop?: (items: TreeItemDrop[]) => void; onPasteAsFile?: (file: File) => void; @@ -69,8 +70,8 @@ export const WorkspaceInput: React.FC = ({ onRemovePendingFile, onFileUploadClick, uploading = false, - selectedProviders = [], - onProvidersChange, + providerSelection, + onProviderSelectionChange, isMobile = false, onTreeItemsDrop, onPasteAsFile, @@ -653,12 +654,11 @@ export const WorkspaceInput: React.FC = ({
)} - {onProvidersChange && ( + {onProviderSelectionChange && providerSelection && ( )} diff --git a/src/pages/views/workspace/WorkspacePage.tsx b/src/pages/views/workspace/WorkspacePage.tsx index 01f965b..d333bd5 100644 --- a/src/pages/views/workspace/WorkspacePage.tsx +++ b/src/pages/views/workspace/WorkspacePage.tsx @@ -19,6 +19,9 @@ import { ToolActivityLog } from './ToolActivityLog'; import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar'; 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) { const [width, setWidth] = useState(initialWidth); @@ -81,7 +84,8 @@ export const WorkspacePage: React.FC = ({ persistentInstance const [udbTab, setUdbTab] = useState('chats'); const [selectedFileId, setSelectedFileId] = useState(null); const [pendingFiles, setPendingFiles] = useState([]); - const [selectedProviders, setSelectedProviders] = useState([]); + const [providerSelection, setProviderSelection] = useState(_defaultProviderSelection()); + const { allowedProviders } = useBilling(); const [isDragOver, setIsDragOver] = useState(false); const [draftAppend, setDraftAppend] = useState(''); const dragCounterRef = useRef(0); @@ -414,7 +418,8 @@ export const WorkspacePage: React.FC = ({ persistentInstance instanceId={instanceId} onSend={(prompt, fileIds, dataSourceIds, featureDataSourceIds, options) => { 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([]); }} isProcessing={workspace.isProcessing} @@ -426,8 +431,8 @@ export const WorkspacePage: React.FC = ({ persistentInstance onRemovePendingFile={_handleRemovePendingFile} onFileUploadClick={() => fileInputRef.current?.click()} uploading={fileOps.uploadingFile} - selectedProviders={selectedProviders} - onProvidersChange={setSelectedProviders} + providerSelection={providerSelection} + onProviderSelectionChange={setProviderSelection} isMobile={isMobile} onTreeItemsDrop={_handleTreeItemsDrop} onPasteAsFile={_uploadAndAttach}