diff --git a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.module.css b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.module.css index b1d0f13..cb5afca 100644 --- a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.module.css +++ b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.module.css @@ -143,6 +143,9 @@ text-transform: uppercase; letter-spacing: 0.5px; margin: 0 0 0.75rem 0; + position: relative; + z-index: 1; + flex-shrink: 0; } .sectionDescription { @@ -199,8 +202,8 @@ .chartWrapperSmall { width: 100%; - height: 220px; - min-height: 220px; + height: 250px; + min-height: 250px; min-width: 0; } diff --git a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx index 2082edd..0240eed 100644 --- a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx +++ b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx @@ -129,7 +129,7 @@ const _renderBarChart = (section: ReportSectionBarChart, currencyCode: string): return (
- + - + - + - + = ({ @@ -112,8 +113,10 @@ export const ProviderMultiSelect: React.FC = ({ label = 'AI-Provider', showLabel = true, defaultExpanded = false, + excludeByDefault = [], }) => { const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const [initialExcludeApplied, setInitialExcludeApplied] = useState(false); const containerRef = useRef(null); const { allowedProviders, loadAllowedProviders, loading } = useBilling(); @@ -123,6 +126,25 @@ export const ProviderMultiSelect: React.FC = ({ } }, []); + // Apply default exclusions when providers first load + useEffect(() => { + if ( + !initialExcludeApplied && + allowedProviders.length > 0 && + excludeByDefault.length > 0 && + selectedProviders.length === 0 + ) { + const initialSelection = allowedProviders.filter( + (p) => !excludeByDefault.includes(p) + ); + // Only apply if there's actually something to exclude + if (initialSelection.length < allowedProviders.length) { + onChange(initialSelection); + } + setInitialExcludeApplied(true); + } + }, [allowedProviders, excludeByDefault, initialExcludeApplied, selectedProviders.length, onChange]); + // Click outside handler const handleClickOutside = useCallback((event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { @@ -137,37 +159,49 @@ export const ProviderMultiSelect: React.FC = ({ } }, [isExpanded, handleClickOutside]); - // Check if all providers are explicitly selected - const isAllSelected = allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length; + // Effective selection: empty array = all providers active (no restriction) + const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders; - // Check if no providers are selected (= no restriction, all allowed by default) - const isNoneSelected = selectedProviders.length === 0; + // "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.includes(provider)) { + 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 - onChange(selectedProviders.filter((p) => p !== provider)); + const remaining = selectedProviders.filter((p) => p !== provider); + // If removing leaves all others selected, reset to [] (= all, no restriction) + if (remaining.length === allowedProviders.length) { + onChange([]); + } else { + onChange(remaining); + } } else { // Activate: add to selection - onChange([...selectedProviders, provider]); + const updated = [...selectedProviders, provider]; + // If all are now selected, reset to [] (= all, no restriction) + if (updated.length === allowedProviders.length) { + onChange([]); + } else { + onChange(updated); + } } }; const handleSelectAll = () => { - onChange([...allowedProviders]); - }; - - const handleSelectNone = () => { - onChange([]); + onChange([]); // Empty = all active, no restriction }; // Summary icon for button const summaryIcon = useMemo(() => { - if (selectedProviders.length === 1) { - return PROVIDER_ICONS[selectedProviders[0]] || '🔌'; + if (effectiveSelection.length === 1) { + return PROVIDER_ICONS[effectiveSelection[0]] || '🔌'; } return '🤖'; - }, [selectedProviders]); + }, [effectiveSelection]); return (
= ({ > Alle -
{loading ? ( @@ -220,7 +246,7 @@ export const ProviderMultiSelect: React.FC = ({ > handleToggle(provider)} disabled={disabled} /> @@ -233,9 +259,9 @@ export const ProviderMultiSelect: React.FC = ({
)} - {selectedProviders.length === 0 && !loading && ( + {isAllSelected && !loading && (
- Alle Provider aktiv + Alle Provider aktiv (kein Filter)
)} diff --git a/src/hooks/playground/useDashboardInputForm.ts b/src/hooks/playground/useDashboardInputForm.ts index ec0267d..e39494a 100644 --- a/src/hooks/playground/useDashboardInputForm.ts +++ b/src/hooks/playground/useDashboardInputForm.ts @@ -10,7 +10,7 @@ import type { Workflow, WorkflowMessage } from '../../api/workflowApi'; import { useWorkflowLifecycle } from './useWorkflowLifecycle'; import { useWorkflows } from './useWorkflows'; import { useDashboardLogTree } from './useDashboardLogTree'; -import { extractFileIdsFromMessage, convertFilesToDocuments, sortMessages } from './playgroundUtils'; +import { convertFilesToDocuments, sortMessages } from './playgroundUtils'; import type { WorkflowLog as LogTypesWorkflowLog } from '../../components/UiComponents/Log/LogTypes'; export interface WorkflowFile { @@ -279,44 +279,21 @@ export function useDashboardInputForm(instanceId: string) { useEffect(() => { if (!messages || messages.length === 0) return; + if (!optimisticMessage) return; - const messageTexts = new Set(); - messages.forEach((message: WorkflowMessage) => { - if (message.message) { - messageTexts.add(message.message.trim()); - } - }); + // Clear optimistic message when backend's "first" user message arrives via polling. + // The backend message contains the normalizedRequest (which differs from the original prompt), + // so we match by status="first" instead of content comparison. + const hasFirstMessage = messages.some((msg: WorkflowMessage) => + (msg as any).status === 'first' && msg.role?.toLowerCase() === 'user' + ); - if (optimisticMessage && optimisticMessage.message) { - const optimisticText = optimisticMessage.message.trim(); - const optimisticFileIds = extractFileIdsFromMessage(optimisticMessage); - - const matchingMessage = Array.from(messages).find((msg: WorkflowMessage) => - msg.message && msg.message.trim() === optimisticText - ); - - if (matchingMessage) { - const matchingFileIds = extractFileIdsFromMessage(matchingMessage); - - if (optimisticFileIds.size > 0) { - const allFilesConfirmed = Array.from(optimisticFileIds).every(fileId => - matchingFileIds.has(fileId) - ); - if (allFilesConfirmed && matchingFileIds.size > 0) { - setOptimisticMessage(null); - } - } else { - if (messageTexts.has(optimisticText)) { - setOptimisticMessage(null); - } - } - } + if (hasFirstMessage) { + setOptimisticMessage(null); } }, [messages, optimisticMessage]); const displayMessages = useMemo(() => { - const optimisticText = optimisticMessage?.message?.trim(); - const processedMessages = (messages || []).map((message: WorkflowMessage) => { const files = (message as any).files as any[] | undefined; const documents = (message as any).documents as MessageDocument[] | undefined; @@ -331,37 +308,19 @@ export function useDashboardInputForm(instanceId: string) { return message; }); - let replacedMessageTimestamp: number | undefined; - const filteredMessages = processedMessages.filter((message: WorkflowMessage) => { - const isUserMessage = message.role?.toLowerCase() === 'user'; - const messageText = message.message?.trim(); - - if (optimisticMessage && optimisticText && isUserMessage && messageText === optimisticText) { - const documents = (message as any).documents as MessageDocument[] | undefined; - const files = (message as any).files as any[] | undefined; - const hasDocuments = documents && Array.isArray(documents) && documents.length > 0; - const hasFiles = files && Array.isArray(files) && files.length > 0; - - if (hasDocuments || hasFiles) { - return true; - } - - if (message.publishedAt !== undefined) { - replacedMessageTimestamp = message.publishedAt; - } - - return false; - } - - return true; - }); - - const allMessages = [...filteredMessages]; + // If optimistic message is still active (backend "first" message not yet polled), + // show the optimistic message instead of any backend user messages to avoid duplicates. + const allMessages = [...processedMessages]; if (optimisticMessage) { - const optimisticWithTimestamp = replacedMessageTimestamp !== undefined - ? { ...optimisticMessage, publishedAt: replacedMessageTimestamp } - : optimisticMessage; - allMessages.push(optimisticWithTimestamp); + // Find backend "first" user message to inherit its timestamp for correct ordering + const firstBackendMsg = processedMessages.find((msg: WorkflowMessage) => + (msg as any).status === 'first' && msg.role?.toLowerCase() === 'user' + ); + if (!firstBackendMsg) { + // Backend "first" message not yet arrived - show optimistic message + allMessages.push(optimisticMessage); + } + // If firstBackendMsg exists, the useEffect above will clear optimistic on next render } return allMessages.sort(sortMessages); diff --git a/src/hooks/useChatbot.ts b/src/hooks/useChatbot.ts index c4db9f8..6fb79c0 100644 --- a/src/hooks/useChatbot.ts +++ b/src/hooks/useChatbot.ts @@ -256,26 +256,12 @@ export function useChatbot(): ChatbotHookReturn { return prev; } - // For user messages, check if we already have a temporary one with same content - // Only replace if it's the temporary message we just created (by ID match) - if (message.role === 'user' && message.message === inputMessageContent) { - // Check if we have the exact temporary message we created - const hasTempMessage = prev.some(m => m.id === tempUserMessageId); - if (hasTempMessage) { - // Replace the temporary message with the real one from backend - return prev.map(m => - m.id === tempUserMessageId ? message : m - ); - } - // If no temp message found, check if this is a duplicate of an existing real message - const isDuplicate = prev.some(m => - m.role === 'user' && - m.message === inputMessageContent && - !m.id.startsWith('temp-') + // Backend sends the "first" message with the transformed/normalized user prompt + // Replace the temporary optimistic message with it + if (message.status === 'first') { + return prev.map(m => + m.id === tempUserMessageId ? message : m ); - if (isDuplicate) { - return prev; // Don't add duplicate - } } // For other messages, check for duplicates by role and content (more lenient check) diff --git a/src/hooks/useInvitations.ts b/src/hooks/useInvitations.ts index bd2514b..e8dbd79 100644 --- a/src/hooks/useInvitations.ts +++ b/src/hooks/useInvitations.ts @@ -51,6 +51,7 @@ export interface InvitationCreate { email?: string; roleIds: string[]; featureInstanceId?: string; + frontendUrl?: string; expiresInHours?: number; maxUses?: number; } @@ -117,6 +118,7 @@ export function useInvitations() { try { const params = new URLSearchParams(); + params.append('frontendUrl', window.location.origin); if (fetchOptions?.includeUsed) params.append('includeUsed', 'true'); if (fetchOptions?.includeExpired) params.append('includeExpired', 'true'); if (Object.keys(paginationParams).length > 0) { @@ -160,7 +162,11 @@ export function useInvitations() { setLoading(true); setError(null); try { - const response = await api.post('/api/invitations/', data, { + const payload = { + ...data, + frontendUrl: data.frontendUrl || window.location.origin, + }; + const response = await api.post('/api/invitations/', payload, { headers: { 'X-Mandate-Id': mandateId } }); return { success: true, data: response.data }; diff --git a/src/hooks/useUserMandates.ts b/src/hooks/useUserMandates.ts index 8c4ec4f..c7c128c 100644 --- a/src/hooks/useUserMandates.ts +++ b/src/hooks/useUserMandates.ts @@ -62,6 +62,7 @@ export interface Mandate { name: string | { [key: string]: string }; code?: string; language?: string; + isSystem?: boolean; } /** @@ -226,7 +227,10 @@ export function useUserMandates() { }, []); /** - * Fetch all available roles (global and mandate-specific, excluding feature-instance roles) + * Fetch available roles for a mandate (mandate-instance roles only). + * Each mandate has its own instances of system roles (admin, user, viewer) + * copied from templates during mandate creation. Only these mandate-bound + * roles should be offered for user assignment - NOT global templates. */ const fetchRoles = useCallback(async (mandateId?: string): Promise => { try { @@ -238,15 +242,15 @@ export function useUserMandates() { roles = response.data; } - // Filter to global roles and mandate-specific roles only - // Exclude feature-instance roles (they have featureInstanceId set) + // Only mandate-instance roles (mandateId matches, no featureInstanceId) + // Global templates (mandateId=null) are NOT assignable to users if (mandateId) { return roles.filter(r => - !r.featureInstanceId && (!r.mandateId || r.mandateId === mandateId) + !r.featureInstanceId && r.mandateId === mandateId ); } - // Without mandateId, return only global roles (no mandateId and no featureInstanceId) - return roles.filter(r => !r.mandateId && !r.featureInstanceId); + // Without mandateId, no roles available (roles are always mandate-specific) + return []; } catch (err: any) { console.error('Error fetching roles:', err); return []; diff --git a/src/pages/InvitePage.tsx b/src/pages/InvitePage.tsx index 1f9257a..f5cd0ab 100644 --- a/src/pages/InvitePage.tsx +++ b/src/pages/InvitePage.tsx @@ -44,6 +44,7 @@ export const InvitePage: React.FC = () => { const [accepting, setAccepting] = useState(false); const [success, setSuccess] = useState(false); const [error, setError] = useState(null); + const [userExists, setUserExists] = useState(null); // Validate token on mount useEffect(() => { @@ -56,7 +57,6 @@ export const InvitePage: React.FC = () => { const result = await validateInvitation(token); setValidation(result); - setValidating(false); // If invitation is valid but user is not authenticated, // store the token for later use after login/registration @@ -64,7 +64,24 @@ export const InvitePage: React.FC = () => { // (e.g., when user opens password reset email in a new tab) if (result.valid && !isAuthenticated) { localStorage.setItem(PENDING_INVITATION_KEY, token); + + // Check if the target username already has an account + if (result.targetUsername) { + try { + const resp = await fetch(`/api/local/available?username=${encodeURIComponent(result.targetUsername)}`); + if (resp.ok) { + const data = await resp.json(); + // available=true means username is free -> user does NOT exist + setUserExists(!data.available); + } + } catch { + // On error, default to showing both options + setUserExists(null); + } + } } + + setValidating(false); }; validate(); @@ -222,7 +239,7 @@ export const InvitePage: React.FC = () => { ); } - // Not authenticated - show login/register options (NO inline registration form) + // Not authenticated - show appropriate options based on whether user account exists return (
@@ -254,9 +271,11 @@ export const InvitePage: React.FC = () => {

- {validation.targetUsername + {userExists === true ? `Bitte melden Sie sich als "${validation.targetUsername}" an, um die Einladung anzunehmen.` - : 'Bitte melden Sie sich an, um die Einladung anzunehmen.'} + : userExists === false + ? 'Bitte erstellen Sie ein Konto, um die Einladung anzunehmen.' + : 'Bitte melden Sie sich an oder erstellen Sie ein Konto, um die Einladung anzunehmen.'}

@@ -267,29 +286,48 @@ export const InvitePage: React.FC = () => { )}
- - -
- oder -
- - + {userExists === true ? ( + + ) : userExists === false ? ( + + ) : ( + <> + +
+ oder +
+ + + )}

- Sie können sich mit Ihrem bestehenden Konto anmelden oder ein neues erstellen. - Die Einladung wird automatisch nach der Anmeldung akzeptiert. + {userExists === true + ? 'Melden Sie sich mit Ihrem bestehenden Konto an. Die Einladung wird automatisch nach der Anmeldung akzeptiert.' + : userExists === false + ? 'Erstellen Sie ein neues Konto. Die Einladung wird automatisch nach der Registrierung akzeptiert.' + : 'Die Einladung wird automatisch nach der Anmeldung akzeptiert.'}

diff --git a/src/pages/admin/AdminMandateRolePermissionsPage.tsx b/src/pages/admin/AdminMandateRolePermissionsPage.tsx index 619e39c..b54ebc1 100644 --- a/src/pages/admin/AdminMandateRolePermissionsPage.tsx +++ b/src/pages/admin/AdminMandateRolePermissionsPage.tsx @@ -69,7 +69,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { // State const [mandates, setMandates] = useState([]); const [selectedMandateId, setSelectedMandateId] = useState(''); - const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('all'); + const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate'); const [expandedRoleId, setExpandedRoleId] = useState(null); // Cleanup state @@ -117,19 +117,24 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { setExpandedRoleId(prev => prev === roleId ? null : roleId); }; + // Check if a role is a template (not bound to a specific mandate) + const _isTemplateRole = (role: Role): boolean => { + return !!role.isSystemRole || !role.mandateId; + }; + // Get scope badge const getScopeBadge = (role: Role) => { if (role.isSystemRole) { return ( - System + System-Template ); } if (!role.mandateId) { return ( - Global + Template ); } @@ -185,9 +190,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { // Filter options for scope const scopeOptions = useMemo(() => [ - { value: 'all', label: 'Alle Rollen' }, - { value: 'mandate', label: 'Nur Mandanten-Rollen' }, - { value: 'global', label: 'Nur globale Rollen' }, + { value: 'mandate', label: 'Mandanten-Rollen' }, + { value: 'all', label: 'Alle (inkl. Templates)' }, + { value: 'global', label: 'Nur Templates' }, ], []); if (error) { @@ -274,7 +279,8 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { Klicken Sie auf eine Rolle, um deren Berechtigungen (AccessRules) zu bearbeiten. - Alle Rollen-Berechtigungen sind bearbeitbar (System-Rollen-Namen sind geschützt). + Template-Rollen sind schreibgeschützt - Änderungen an Templates wirken sich nur auf neu erstellte Mandanten aus. + Mandanten-Rollen sind direkt bearbeitbar.
@@ -293,9 +299,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {

Keine Rollen gefunden

{scopeFilter === 'mandate' - ? 'Es gibt noch keine mandantenspezifischen Rollen.' + ? 'Es gibt noch keine Mandanten-Rollen. System-Rollen werden bei der Mandant-Erstellung automatisch kopiert.' : scopeFilter === 'global' - ? 'Es gibt noch keine globalen Rollen.' + ? 'Es gibt noch keine Rollen-Templates.' : 'Es gibt noch keine Rollen für diesen Mandanten.'}

@@ -328,11 +334,20 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { {/* Expanded Content - AccessRulesEditor */} {expandedRoleId === role.id && (
+ {_isTemplateRole(role) && ( +
+ + + Dies ist eine Template-Rolle. Änderungen an den Berechtigungen wirken sich nur auf neu erstellte Mandanten aus. + Bestehende Mandanten-Instanzen werden nicht aktualisiert. + +
+ )} diff --git a/src/pages/admin/AdminMandateRolesPage.tsx b/src/pages/admin/AdminMandateRolesPage.tsx index a6fc149..67bb828 100644 --- a/src/pages/admin/AdminMandateRolesPage.tsx +++ b/src/pages/admin/AdminMandateRolesPage.tsx @@ -46,7 +46,7 @@ export const AdminMandateRolesPage: React.FC = () => { const [showCreateModal, setShowCreateModal] = useState(false); const [editingRole, setEditingRole] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('all'); + const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate'); const [backendAttributes, setBackendAttributes] = useState([]); // Store current filter state for refetch @@ -126,14 +126,14 @@ export const AdminMandateRolesPage: React.FC = () => { if (value === 'system') { return ( - System + System-Template ); } if (value === 'global') { return ( - Global + Template ); } @@ -164,7 +164,7 @@ export const AdminMandateRolesPage: React.FC = () => { default: 'mandate', options: [ { value: 'mandate', label: 'Nur dieser Mandant' }, - { value: 'global', label: 'Global (alle Mandanten)' }, + { value: 'global', label: 'Template (wird bei neuen Mandanten kopiert)' }, ] }); } @@ -359,9 +359,9 @@ export const AdminMandateRolesPage: React.FC = () => { onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')} style={{ minWidth: 150 }} > - - - + + +
@@ -389,9 +389,9 @@ export const AdminMandateRolesPage: React.FC = () => {
- System-Rollen (admin, user, viewer) können nicht bearbeitet oder gelöscht werden. - Globale Rollen gelten für alle Mandanten. - Mandanten-Rollen gelten nur für den ausgewählten Mandanten. + System-Templates (admin, user, viewer) werden bei der Mandant-Erstellung automatisch als Mandanten-Instanz-Rollen kopiert. + Templates selbst können nicht gelöscht werden. + Mandanten-Rollen gelten nur für den ausgewählten Mandanten und sind den Benutzern zuweisbar.
)} @@ -416,9 +416,9 @@ export const AdminMandateRolesPage: React.FC = () => {

Keine Rollen

{scopeFilter === 'mandate' - ? 'Es gibt noch keine mandantenspezifischen Rollen.' + ? 'Es gibt noch keine Mandanten-Rollen. System-Rollen werden bei der Mandant-Erstellung automatisch kopiert.' : scopeFilter === 'global' - ? 'Es gibt noch keine globalen Rollen.' + ? 'Es gibt noch keine Rollen-Templates.' : 'Es gibt noch keine Rollen für diesen Mandanten.'}

- {formAttributes.length === 0 ? ( + {createFormAttributes.length === 0 ? (
Lade Formular...
) : ( setShowCreateModal(false)} @@ -233,6 +245,14 @@ export const AdminMandatesPage: React.FC = () => {
+ {editingMandate.isSystem && ( +
+ + + Dies ist ein System-Mandant. Er kann nicht gelöscht werden und der Name sollte nicht geändert werden. + +
+ )} {formAttributes.length === 0 ? (
diff --git a/src/pages/workflows/PlaygroundPage.tsx b/src/pages/workflows/PlaygroundPage.tsx index 9dc6af7..abd0569 100644 --- a/src/pages/workflows/PlaygroundPage.tsx +++ b/src/pages/workflows/PlaygroundPage.tsx @@ -774,6 +774,7 @@ export const PlaygroundPage: React.FC = () => { selectedProviders={selectedProviders} onChange={onProvidersChange} showLabel={false} + excludeByDefault={['privatellm']} />