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 { 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<AutomationEditorProps> = ({
const [label, setLabel] = useState('');
const [schedule, setSchedule] = useState('0 22 * * *');
const [active, setActive] = useState(false);
const [allowedProviders, setAllowedProviders] = useState<string[]>([]);
const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection());
const { allowedProviders: billingProviders } = useBilling();
// Template multilingual fields
const [labelMulti, setLabelMulti] = useState<LocalTextMultilingual>({ en: '', de: '' });
@ -537,7 +540,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
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<AutomationEditorProps> = ({
active,
template: templateJson,
placeholders,
allowedProviders
allowedProviders: _toBackendProviders(providerSelection, billingProviders),
};
}
@ -709,7 +712,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
} 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<AutomationEditorProps> = ({
{/* Allowed AI Providers */}
<div className={styles.formGroup}>
<ProviderMultiSelect
selectedProviders={allowedProviders}
onChange={setAllowedProviders}
selection={providerSelection}
onChange={setProviderSelection}
label="Erlaubte AI-Provider"
/>
<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>
</div>
</div>

View file

@ -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<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.',
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<OnboardingAssistantProps> = ({ onDismiss })
const [steps, setSteps] = useState<OnboardingStep[]>([]);
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<OnboardingAssistantProps> = ({ 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<OnboardingAssistantProps> = ({ 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<OnboardingAssistantProps> = ({ 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<OnboardingAssistantProps> = ({ onDismiss })
onDismiss?.();
};
if (showWizard) {
return (
<OnboardingWizard
onComplete={() => {
setShowWizard(false);
_checkOnboardingState();
}}
onDismiss={() => setShowWizard(false)}
/>
);
}
if (hidden || loading) return null;
const completedCount = steps.filter(s => s.completed).length;

View file

@ -8,7 +8,7 @@ interface OnboardingWizardProps {
const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ 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<string | null>(null);
@ -16,10 +16,15 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ 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<OnboardingWizardProps> = ({ 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)',
}}>
<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' }}>
Wähle dein Abo und leg los.
Erstelle deinen eigenen Arbeitsbereich mit Abo-Auswahl.
</p>
<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>
</label>
<input
type="text" value={companyName}
onChange={(e) => 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<OnboardingWizardProps> = ({ onComplete, onDismi
padding: '10px 20px', borderRadius: '6px', border: '1px solid var(--border, #d1d5db)',
background: 'transparent', cursor: 'pointer',
}}>
Später
Abbrechen
</button>
<button onClick={_handleSubmit} disabled={loading}
style={{
@ -106,7 +111,7 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
background: 'var(--accent, #4f46e5)', color: '#fff', cursor: 'pointer',
opacity: loading ? 0.6 : 1,
}}>
{loading ? 'Wird eingerichtet...' : 'Loslegen'}
{loading ? 'Wird eingerichtet...' : 'Mandant erstellen'}
</button>
</div>
</div>

View file

@ -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
============================================================================ */

View file

@ -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<string, string> = {
anthropic: 'Anthropic (Claude)',
@ -25,7 +86,6 @@ const PROVIDER_LABELS: Record<string, string> = {
internal: 'Internal',
};
// Provider icons (emojis for simplicity)
const PROVIDER_ICONS: Record<string, string> = {
anthropic: '🤖',
openai: '💬',
@ -58,20 +118,20 @@ export const ProviderSelect: React.FC<ProviderSelectProps> = ({
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 (
<div className={`${styles.providerSelect} ${className || ''}`}>
{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 {
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<ProviderMultiSelectProps> = ({
selectedProviders,
selection,
onChange,
disabled = false,
className,
@ -121,97 +181,100 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
const [initialExcludeApplied, setInitialExcludeApplied] = useState(false);
const containerRef = useRef<HTMLDivElement>(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 (
<div
<div
ref={containerRef}
className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}
>
{/* Trigger Button - styled like iconButton */}
<button
<button
type="button"
className={styles.triggerButton}
onClick={() => setIsExpanded(!isExpanded)}
@ -220,36 +283,35 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
>
<span className={styles.buttonIcon}>{summaryIcon}</span>
</button>
{/* Dropdown Content */}
{isExpanded && (
<div className={styles.dropdownContent}>
{showLabel && <div className={styles.dropdownHeader}>{label}</div>}
<div className={styles.selectActions}>
<button
type="button"
onClick={handleSelectAll}
<button
type="button"
onClick={_handleSelectAll}
disabled={disabled}
className={`${styles.actionButton} ${isAllSelected ? styles.active : ''}`}
className={`${styles.actionButton} ${allSelected ? styles.active : ''}`}
>
Alle
</button>
</div>
{loading ? (
<div className={styles.loading}>Lade...</div>
) : (
<div className={styles.checkboxList}>
{allowedProviders.map((provider) => (
<label
key={provider}
<label
key={provider}
className={`${styles.checkboxItem} ${disabled ? styles.disabled : ''}`}
>
<input
type="checkbox"
checked={effectiveSelection.includes(provider)}
onChange={() => handleToggle(provider)}
onChange={() => _handleToggle(provider)}
disabled={disabled}
/>
<span className={styles.icon}>{PROVIDER_ICONS[provider] || '🔌'}</span>
@ -260,12 +322,8 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
))}
</div>
)}
{isAllSelected && !loading && (
<div className={styles.hint}>
Alle Provider aktiv (kein Filter)
</div>
)}
<div className={styles.hint}>{summaryHint}</div>
</div>
)}
</div>
@ -288,7 +346,7 @@ export const ProviderBadges: React.FC<ProviderBadgesProps> = ({
if (providers.length === 0) {
return <span className={styles.allProviders}>Alle Provider</span>;
}
return (
<div className={`${styles.providerBadges} ${className || ''}`}>
{providers.map((provider) => (
@ -300,5 +358,4 @@ export const ProviderBadges: React.FC<ProviderBadgesProps> = ({
);
};
// Default export
export default ProviderSelect;

View file

@ -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';

View file

@ -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%',

View file

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

View file

@ -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<RegisterFormData>({
@ -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<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(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<HTMLInputElement>) => {
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() {
<div className={styles.loginSection}>
<div className={styles.loginBox}>
<div className={styles.loginForm}>
{/* Pending invitation notice */}
{hasPendingInvitation && !successMessage && (
<div className={styles.invitationNotice}>
<FaEnvelopeOpenText className={styles.invitationIcon} />
@ -165,8 +139,8 @@ function Register() {
</div>
)}
{getErrorMessage() && (
<div className={styles.error}>{getErrorMessage()}</div>
{_getErrorMessage() && (
<div className={styles.error}>{_getErrorMessage()}</div>
)}
{successMessage && (
@ -203,22 +177,6 @@ function Register() {
<label className={emailFocused || formData.email ? styles.focusedLabel : styles.label}>E-Mail</label>
</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}>
<input
type="text"
@ -248,7 +206,7 @@ function Register() {
onClick={handleSubmit}
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>
</>
)}

View file

@ -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 };

View file

@ -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<WorkspaceInputProps> = ({
onRemovePendingFile,
onFileUploadClick,
uploading = false,
selectedProviders = [],
onProvidersChange,
providerSelection,
onProviderSelectionChange,
isMobile = false,
onTreeItemsDrop,
onPasteAsFile,
@ -653,12 +654,11 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
</div>
)}
{onProvidersChange && (
{onProviderSelectionChange && providerSelection && (
<ProviderMultiSelect
selectedProviders={selectedProviders}
onChange={onProvidersChange}
selection={providerSelection}
onChange={onProviderSelectionChange}
showLabel={false}
excludeByDefault={['privatellm']}
disabled={isProcessing}
/>
)}

View file

@ -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<WorkspacePageProps> = ({ persistentInstance
const [udbTab, setUdbTab] = useState<UdbTab>('chats');
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
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 [draftAppend, setDraftAppend] = useState('');
const dragCounterRef = useRef(0);
@ -414,7 +418,8 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ 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<WorkspacePageProps> = ({ persistentInstance
onRemovePendingFile={_handleRemovePendingFile}
onFileUploadClick={() => fileInputRef.current?.click()}
uploading={fileOps.uploadingFile}
selectedProviders={selectedProviders}
onProvidersChange={setSelectedProviders}
providerSelection={providerSelection}
onProviderSelectionChange={setProviderSelection}
isMobile={isMobile}
onTreeItemsDrop={_handleTreeItemsDrop}
onPasteAsFile={_uploadAndAttach}