streamlined bootstrap and initial config

This commit is contained in:
ValueOn AG 2026-02-09 12:49:39 +01:00
parent 15c93b3bf0
commit 75125e3f58
12 changed files with 231 additions and 173 deletions

View file

@ -143,6 +143,9 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin: 0 0 0.75rem 0; margin: 0 0 0.75rem 0;
position: relative;
z-index: 1;
flex-shrink: 0;
} }
.sectionDescription { .sectionDescription {
@ -199,8 +202,8 @@
.chartWrapperSmall { .chartWrapperSmall {
width: 100%; width: 100%;
height: 220px; height: 250px;
min-height: 220px; min-height: 250px;
min-width: 0; min-width: 0;
} }

View file

@ -129,7 +129,7 @@ const _renderBarChart = (section: ReportSectionBarChart, currencyCode: string):
return ( return (
<div className={styles.chartWrapper}> <div className={styles.chartWrapper}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}> <BarChart data={chartData} margin={{ top: 15, right: 10, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
<XAxis <XAxis
dataKey="name" dataKey="name"
@ -198,7 +198,7 @@ const _renderLineChart = (section: ReportSectionLineChart, currencyCode: string)
return ( return (
<div className={styles.chartWrapper}> <div className={styles.chartWrapper}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={section.data} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}> <LineChart data={section.data} margin={{ top: 15, right: 10, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
<XAxis <XAxis
dataKey="date" dataKey="date"
@ -244,7 +244,7 @@ const _renderAreaChart = (section: ReportSectionAreaChart, currencyCode: string)
return ( return (
<div className={styles.chartWrapper}> <div className={styles.chartWrapper}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<AreaChart data={section.data} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}> <AreaChart data={section.data} margin={{ top: 15, right: 10, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
<XAxis <XAxis
dataKey="date" dataKey="date"
@ -302,13 +302,13 @@ const _renderPieChart = (section: ReportSectionPieChart, currencyCode: string):
return ( return (
<div className={styles.chartWrapperSmall}> <div className={styles.chartWrapperSmall}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<PieChart> <PieChart margin={{ top: 20, right: 10, left: 10, bottom: 5 }}>
<Pie <Pie
data={chartData} data={chartData}
cx="50%" cx="50%"
cy="50%" cy="50%"
innerRadius={section.donut ? '55%' : 0} innerRadius={section.donut ? '45%' : 0}
outerRadius="80%" outerRadius="65%"
paddingAngle={2} paddingAngle={2}
dataKey="value" dataKey="value"
label={_renderLabel} label={_renderLabel}

View file

@ -102,6 +102,7 @@ interface ProviderMultiSelectProps {
label?: string; label?: string;
showLabel?: boolean; showLabel?: boolean;
defaultExpanded?: boolean; defaultExpanded?: boolean;
excludeByDefault?: string[];
} }
export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
@ -112,8 +113,10 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
label = 'AI-Provider', label = 'AI-Provider',
showLabel = true, showLabel = true,
defaultExpanded = false, defaultExpanded = false,
excludeByDefault = [],
}) => { }) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded); const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const [initialExcludeApplied, setInitialExcludeApplied] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const { allowedProviders, loadAllowedProviders, loading } = useBilling(); const { allowedProviders, loadAllowedProviders, loading } = useBilling();
@ -123,6 +126,25 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
} }
}, []); }, []);
// 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 // Click outside handler
const handleClickOutside = useCallback((event: MouseEvent) => { const handleClickOutside = useCallback((event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) { if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
@ -137,37 +159,49 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
} }
}, [isExpanded, handleClickOutside]); }, [isExpanded, handleClickOutside]);
// Check if all providers are explicitly selected // Effective selection: empty array = all providers active (no restriction)
const isAllSelected = allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length; const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders;
// Check if no providers are selected (= no restriction, all allowed by default) // "Alle" is active when no restriction is set (empty array) OR all explicitly selected
const isNoneSelected = selectedProviders.length === 0; const isAllSelected = selectedProviders.length === 0 ||
(allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length);
const handleToggle = (provider: string) => { 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 // 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 { } else {
// Activate: add to selection // 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 = () => { const handleSelectAll = () => {
onChange([...allowedProviders]); onChange([]); // Empty = all active, no restriction
};
const handleSelectNone = () => {
onChange([]);
}; };
// Summary icon for button // Summary icon for button
const summaryIcon = useMemo(() => { const summaryIcon = useMemo(() => {
if (selectedProviders.length === 1) { if (effectiveSelection.length === 1) {
return PROVIDER_ICONS[selectedProviders[0]] || '🔌'; return PROVIDER_ICONS[effectiveSelection[0]] || '🔌';
} }
return '🤖'; return '🤖';
}, [selectedProviders]); }, [effectiveSelection]);
return ( return (
<div <div
@ -199,14 +233,6 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
> >
Alle Alle
</button> </button>
<button
type="button"
onClick={handleSelectNone}
disabled={disabled}
className={`${styles.actionButton} ${isNoneSelected ? styles.active : ''}`}
>
Keine
</button>
</div> </div>
{loading ? ( {loading ? (
@ -220,7 +246,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
> >
<input <input
type="checkbox" type="checkbox"
checked={selectedProviders.includes(provider)} checked={effectiveSelection.includes(provider)}
onChange={() => handleToggle(provider)} onChange={() => handleToggle(provider)}
disabled={disabled} disabled={disabled}
/> />
@ -233,9 +259,9 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
</div> </div>
)} )}
{selectedProviders.length === 0 && !loading && ( {isAllSelected && !loading && (
<div className={styles.hint}> <div className={styles.hint}>
Alle Provider aktiv Alle Provider aktiv (kein Filter)
</div> </div>
)} )}
</div> </div>

View file

@ -10,7 +10,7 @@ import type { Workflow, WorkflowMessage } from '../../api/workflowApi';
import { useWorkflowLifecycle } from './useWorkflowLifecycle'; import { useWorkflowLifecycle } from './useWorkflowLifecycle';
import { useWorkflows } from './useWorkflows'; import { useWorkflows } from './useWorkflows';
import { useDashboardLogTree } from './useDashboardLogTree'; 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'; import type { WorkflowLog as LogTypesWorkflowLog } from '../../components/UiComponents/Log/LogTypes';
export interface WorkflowFile { export interface WorkflowFile {
@ -279,44 +279,21 @@ export function useDashboardInputForm(instanceId: string) {
useEffect(() => { useEffect(() => {
if (!messages || messages.length === 0) return; if (!messages || messages.length === 0) return;
if (!optimisticMessage) return;
const messageTexts = new Set<string>(); // Clear optimistic message when backend's "first" user message arrives via polling.
messages.forEach((message: WorkflowMessage) => { // The backend message contains the normalizedRequest (which differs from the original prompt),
if (message.message) { // so we match by status="first" instead of content comparison.
messageTexts.add(message.message.trim()); const hasFirstMessage = messages.some((msg: WorkflowMessage) =>
} (msg as any).status === 'first' && msg.role?.toLowerCase() === 'user'
}); );
if (optimisticMessage && optimisticMessage.message) { if (hasFirstMessage) {
const optimisticText = optimisticMessage.message.trim(); setOptimisticMessage(null);
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);
}
}
}
} }
}, [messages, optimisticMessage]); }, [messages, optimisticMessage]);
const displayMessages = useMemo(() => { const displayMessages = useMemo(() => {
const optimisticText = optimisticMessage?.message?.trim();
const processedMessages = (messages || []).map((message: WorkflowMessage) => { const processedMessages = (messages || []).map((message: WorkflowMessage) => {
const files = (message as any).files as any[] | undefined; const files = (message as any).files as any[] | undefined;
const documents = (message as any).documents as MessageDocument[] | undefined; const documents = (message as any).documents as MessageDocument[] | undefined;
@ -331,37 +308,19 @@ export function useDashboardInputForm(instanceId: string) {
return message; return message;
}); });
let replacedMessageTimestamp: number | undefined; // If optimistic message is still active (backend "first" message not yet polled),
const filteredMessages = processedMessages.filter((message: WorkflowMessage) => { // show the optimistic message instead of any backend user messages to avoid duplicates.
const isUserMessage = message.role?.toLowerCase() === 'user'; const allMessages = [...processedMessages];
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 (optimisticMessage) { if (optimisticMessage) {
const optimisticWithTimestamp = replacedMessageTimestamp !== undefined // Find backend "first" user message to inherit its timestamp for correct ordering
? { ...optimisticMessage, publishedAt: replacedMessageTimestamp } const firstBackendMsg = processedMessages.find((msg: WorkflowMessage) =>
: optimisticMessage; (msg as any).status === 'first' && msg.role?.toLowerCase() === 'user'
allMessages.push(optimisticWithTimestamp); );
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); return allMessages.sort(sortMessages);

View file

@ -256,26 +256,12 @@ export function useChatbot(): ChatbotHookReturn {
return prev; return prev;
} }
// For user messages, check if we already have a temporary one with same content // Backend sends the "first" message with the transformed/normalized user prompt
// Only replace if it's the temporary message we just created (by ID match) // Replace the temporary optimistic message with it
if (message.role === 'user' && message.message === inputMessageContent) { if (message.status === 'first') {
// Check if we have the exact temporary message we created return prev.map(m =>
const hasTempMessage = prev.some(m => m.id === tempUserMessageId); m.id === tempUserMessageId ? message : m
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-')
); );
if (isDuplicate) {
return prev; // Don't add duplicate
}
} }
// For other messages, check for duplicates by role and content (more lenient check) // For other messages, check for duplicates by role and content (more lenient check)

View file

@ -51,6 +51,7 @@ export interface InvitationCreate {
email?: string; email?: string;
roleIds: string[]; roleIds: string[];
featureInstanceId?: string; featureInstanceId?: string;
frontendUrl?: string;
expiresInHours?: number; expiresInHours?: number;
maxUses?: number; maxUses?: number;
} }
@ -117,6 +118,7 @@ export function useInvitations() {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('frontendUrl', window.location.origin);
if (fetchOptions?.includeUsed) params.append('includeUsed', 'true'); if (fetchOptions?.includeUsed) params.append('includeUsed', 'true');
if (fetchOptions?.includeExpired) params.append('includeExpired', 'true'); if (fetchOptions?.includeExpired) params.append('includeExpired', 'true');
if (Object.keys(paginationParams).length > 0) { if (Object.keys(paginationParams).length > 0) {
@ -160,7 +162,11 @@ export function useInvitations() {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { 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 } headers: { 'X-Mandate-Id': mandateId }
}); });
return { success: true, data: response.data }; return { success: true, data: response.data };

View file

@ -62,6 +62,7 @@ export interface Mandate {
name: string | { [key: string]: string }; name: string | { [key: string]: string };
code?: string; code?: string;
language?: 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<Role[]> => { const fetchRoles = useCallback(async (mandateId?: string): Promise<Role[]> => {
try { try {
@ -238,15 +242,15 @@ export function useUserMandates() {
roles = response.data; roles = response.data;
} }
// Filter to global roles and mandate-specific roles only // Only mandate-instance roles (mandateId matches, no featureInstanceId)
// Exclude feature-instance roles (they have featureInstanceId set) // Global templates (mandateId=null) are NOT assignable to users
if (mandateId) { if (mandateId) {
return roles.filter(r => 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) // Without mandateId, no roles available (roles are always mandate-specific)
return roles.filter(r => !r.mandateId && !r.featureInstanceId); return [];
} catch (err: any) { } catch (err: any) {
console.error('Error fetching roles:', err); console.error('Error fetching roles:', err);
return []; return [];

View file

@ -44,6 +44,7 @@ export const InvitePage: React.FC = () => {
const [accepting, setAccepting] = useState(false); const [accepting, setAccepting] = useState(false);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [userExists, setUserExists] = useState<boolean | null>(null);
// Validate token on mount // Validate token on mount
useEffect(() => { useEffect(() => {
@ -56,7 +57,6 @@ export const InvitePage: React.FC = () => {
const result = await validateInvitation(token); const result = await validateInvitation(token);
setValidation(result); setValidation(result);
setValidating(false);
// If invitation is valid but user is not authenticated, // If invitation is valid but user is not authenticated,
// store the token for later use after login/registration // 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) // (e.g., when user opens password reset email in a new tab)
if (result.valid && !isAuthenticated) { if (result.valid && !isAuthenticated) {
localStorage.setItem(PENDING_INVITATION_KEY, token); 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(); 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 ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.card}> <div className={styles.card}>
@ -254,9 +271,11 @@ export const InvitePage: React.FC = () => {
<div className={styles.authPrompt}> <div className={styles.authPrompt}>
<p> <p>
{validation.targetUsername {userExists === true
? `Bitte melden Sie sich als "${validation.targetUsername}" an, um die Einladung anzunehmen.` ? `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.'}
</p> </p>
</div> </div>
@ -267,29 +286,48 @@ export const InvitePage: React.FC = () => {
)} )}
<div className={styles.authActions}> <div className={styles.authActions}>
<button {userExists === true ? (
className={styles.primaryButton} <button
onClick={handleLoginRedirect} className={styles.primaryButton}
> onClick={handleLoginRedirect}
<FaSignInAlt /> Anmelden >
</button> <FaSignInAlt /> Anmelden
</button>
<div className={styles.divider}> ) : userExists === false ? (
<span>oder</span> <button
</div> className={styles.primaryButton}
onClick={handleRegisterRedirect}
<button >
className={styles.secondaryButton} <FaUserPlus /> Konto erstellen
onClick={handleRegisterRedirect} </button>
> ) : (
<FaUserPlus /> Neues Konto erstellen <>
</button> <button
className={styles.primaryButton}
onClick={handleLoginRedirect}
>
<FaSignInAlt /> Anmelden
</button>
<div className={styles.divider}>
<span>oder</span>
</div>
<button
className={styles.secondaryButton}
onClick={handleRegisterRedirect}
>
<FaUserPlus /> Neues Konto erstellen
</button>
</>
)}
</div> </div>
<div className={styles.authInfo}> <div className={styles.authInfo}>
<p> <p>
Sie können sich mit Ihrem bestehenden Konto anmelden oder ein neues erstellen. {userExists === true
Die Einladung wird automatisch nach der Anmeldung akzeptiert. ? '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.'}
</p> </p>
</div> </div>
</div> </div>

View file

@ -69,7 +69,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
// State // State
const [mandates, setMandates] = useState<Mandate[]>([]); const [mandates, setMandates] = useState<Mandate[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string>(''); const [selectedMandateId, setSelectedMandateId] = useState<string>('');
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('all'); const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate');
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null); const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
// Cleanup state // Cleanup state
@ -117,19 +117,24 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
setExpandedRoleId(prev => prev === roleId ? null : roleId); 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 // Get scope badge
const getScopeBadge = (role: Role) => { const getScopeBadge = (role: Role) => {
if (role.isSystemRole) { if (role.isSystemRole) {
return ( return (
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}> <span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
<FaUserShield style={{ marginRight: 4 }} /> System <FaUserShield style={{ marginRight: 4 }} /> System-Template
</span> </span>
); );
} }
if (!role.mandateId) { if (!role.mandateId) {
return ( return (
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}> <span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
<FaGlobe style={{ marginRight: 4 }} /> Global <FaGlobe style={{ marginRight: 4 }} /> Template
</span> </span>
); );
} }
@ -185,9 +190,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
// Filter options for scope // Filter options for scope
const scopeOptions = useMemo(() => [ const scopeOptions = useMemo(() => [
{ value: 'all', label: 'Alle Rollen' }, { value: 'mandate', label: 'Mandanten-Rollen' },
{ value: 'mandate', label: 'Nur Mandanten-Rollen' }, { value: 'all', label: 'Alle (inkl. Templates)' },
{ value: 'global', label: 'Nur globale Rollen' }, { value: 'global', label: 'Nur Templates' },
], []); ], []);
if (error) { if (error) {
@ -274,7 +279,8 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
<FaShieldAlt style={{ marginRight: '0.5rem' }} /> <FaShieldAlt style={{ marginRight: '0.5rem' }} />
<span> <span>
Klicken Sie auf eine Rolle, um deren Berechtigungen (AccessRules) zu bearbeiten. Klicken Sie auf eine Rolle, um deren Berechtigungen (AccessRules) zu bearbeiten.
Alle Rollen-Berechtigungen sind bearbeitbar (System-Rollen-Namen sind geschützt). <strong> Template-Rollen</strong> sind schreibgeschützt - Änderungen an Templates wirken sich nur auf neu erstellte Mandanten aus.
<strong> Mandanten-Rollen</strong> sind direkt bearbeitbar.
</span> </span>
</div> </div>
@ -293,9 +299,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
<p>Keine Rollen gefunden</p> <p>Keine Rollen gefunden</p>
<p className={styles.emptyHint}> <p className={styles.emptyHint}>
{scopeFilter === 'mandate' {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' : scopeFilter === 'global'
? 'Es gibt noch keine globalen Rollen.' ? 'Es gibt noch keine Rollen-Templates.'
: 'Es gibt noch keine Rollen für diesen Mandanten.'} : 'Es gibt noch keine Rollen für diesen Mandanten.'}
</p> </p>
</div> </div>
@ -328,11 +334,20 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
{/* Expanded Content - AccessRulesEditor */} {/* Expanded Content - AccessRulesEditor */}
{expandedRoleId === role.id && ( {expandedRoleId === role.id && (
<div className={styles.roleContent}> <div className={styles.roleContent}>
{_isTemplateRole(role) && (
<div className={styles.infoBox} style={{ marginBottom: '0.75rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<FaUserShield style={{ marginRight: '0.5rem', color: 'var(--warning-color, #d69e2e)' }} />
<span>
Dies ist eine <strong>Template-Rolle</strong>. Änderungen an den Berechtigungen wirken sich nur auf neu erstellte Mandanten aus.
Bestehende Mandanten-Instanzen werden nicht aktualisiert.
</span>
</div>
)}
<AccessRulesEditor <AccessRulesEditor
roleId={role.id} roleId={role.id}
roleName={role.roleLabel} roleName={role.roleLabel}
isTemplate={false} isTemplate={_isTemplateRole(role)}
readOnly={false} // All AccessRules are editable (access controlled via RBAC) readOnly={false}
apiBasePath="/api/rbac" apiBasePath="/api/rbac"
mandateId={selectedMandateId} mandateId={selectedMandateId}
/> />

View file

@ -46,7 +46,7 @@ export const AdminMandateRolesPage: React.FC = () => {
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null); const [editingRole, setEditingRole] = useState<Role | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); 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<AttributeDefinition[]>([]); const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
// Store current filter state for refetch // Store current filter state for refetch
@ -126,14 +126,14 @@ export const AdminMandateRolesPage: React.FC = () => {
if (value === 'system') { if (value === 'system') {
return ( return (
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}> <span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
<FaUserShield style={{ marginRight: 4 }} /> System <FaUserShield style={{ marginRight: 4 }} /> System-Template
</span> </span>
); );
} }
if (value === 'global') { if (value === 'global') {
return ( return (
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}> <span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
<FaGlobe style={{ marginRight: 4 }} /> Global <FaGlobe style={{ marginRight: 4 }} /> Template
</span> </span>
); );
} }
@ -164,7 +164,7 @@ export const AdminMandateRolesPage: React.FC = () => {
default: 'mandate', default: 'mandate',
options: [ options: [
{ value: 'mandate', label: 'Nur dieser Mandant' }, { 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')} onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')}
style={{ minWidth: 150 }} style={{ minWidth: 150 }}
> >
<option value="all">Alle Rollen</option> <option value="mandate">Mandanten-Rollen</option>
<option value="mandate">Nur Mandanten-Rollen</option> <option value="all">Alle (inkl. Templates)</option>
<option value="global">Nur globale Rollen</option> <option value="global">Nur Templates</option>
</select> </select>
</div> </div>
@ -389,9 +389,9 @@ export const AdminMandateRolesPage: React.FC = () => {
<div className={styles.infoBox}> <div className={styles.infoBox}>
<FaUserShield style={{ marginRight: 8 }} /> <FaUserShield style={{ marginRight: 8 }} />
<span> <span>
<strong>System-Rollen</strong> (admin, user, viewer) können nicht bearbeitet oder gelöscht werden. <strong>System-Templates</strong> (admin, user, viewer) werden bei der Mandant-Erstellung automatisch als Mandanten-Instanz-Rollen kopiert.
<strong> Globale Rollen</strong> gelten für alle Mandanten. Templates selbst können nicht gelöscht werden.
<strong> Mandanten-Rollen</strong> gelten nur für den ausgewählten Mandanten. <strong> Mandanten-Rollen</strong> gelten nur für den ausgewählten Mandanten und sind den Benutzern zuweisbar.
</span> </span>
</div> </div>
)} )}
@ -416,9 +416,9 @@ export const AdminMandateRolesPage: React.FC = () => {
<h3 className={styles.emptyTitle}>Keine Rollen</h3> <h3 className={styles.emptyTitle}>Keine Rollen</h3>
<p className={styles.emptyDescription}> <p className={styles.emptyDescription}>
{scopeFilter === 'mandate' {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' : scopeFilter === 'global'
? 'Es gibt noch keine globalen Rollen.' ? 'Es gibt noch keine Rollen-Templates.'
: 'Es gibt noch keine Rollen für diesen Mandanten.'} : 'Es gibt noch keine Rollen für diesen Mandanten.'}
</p> </p>
<button <button
@ -522,7 +522,7 @@ export const AdminMandateRolesPage: React.FC = () => {
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}> <div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<FaUserShield style={{ marginRight: 8 }} /> <FaUserShield style={{ marginRight: 8 }} />
<span> <span>
Geltungsbereich: <strong>{editingRole.mandateId ? 'Mandant-spezifisch' : 'Global'}</strong> Geltungsbereich: <strong>{editingRole.mandateId ? 'Mandanten-Instanz' : 'Template (global)'}</strong>
{' '}(kann nicht geändert werden) {' '}(kann nicht geändert werden)
</span> </span>
</div> </div>

View file

@ -9,7 +9,7 @@ import { useNavigate } from 'react-router-dom';
import { useAdminMandates, type Mandate } from '../../hooks/useMandates'; import { useAdminMandates, type Mandate } from '../../hooks/useMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaBuilding, FaUsers } from 'react-icons/fa'; import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
export const AdminMandatesPage: React.FC = () => { export const AdminMandatesPage: React.FC = () => {
@ -42,6 +42,11 @@ export const AdminMandatesPage: React.FC = () => {
})) as AttributeDefinition[]; })) as AttributeDefinition[];
}, [attributes]); }, [attributes]);
// Create form attributes - exclude isSystem (only set by system, not user)
const createFormAttributes: AttributeDefinition[] = useMemo(() => {
return formAttributes.filter(attr => attr.name !== 'isSystem');
}, [formAttributes]);
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [editingMandate, setEditingMandate] = useState<Mandate | null>(null); const [editingMandate, setEditingMandate] = useState<Mandate | null>(null);
@ -76,7 +81,11 @@ export const AdminMandatesPage: React.FC = () => {
}; };
// Handle delete (confirmation handled by DeleteActionButton) // Handle delete (confirmation handled by DeleteActionButton)
// System mandates (isSystem=true) are protected from deletion
const handleDeleteMandate = async (mandate: Mandate) => { const handleDeleteMandate = async (mandate: Mandate) => {
if (mandate.isSystem) {
return; // Safety guard - should not be reachable due to disabled button
}
await handleDelete(mandate.id); await handleDelete(mandate.id);
}; };
@ -169,6 +178,9 @@ export const AdminMandatesPage: React.FC = () => {
...(canDelete ? [{ ...(canDelete ? [{
type: 'delete' as const, type: 'delete' as const,
title: 'Löschen', title: 'Löschen',
disabled: (row: Mandate) => row.isSystem
? { disabled: true, message: 'System-Mandanten können nicht gelöscht werden' }
: false
}] : []), }] : []),
]} ]}
onDelete={handleDeleteMandate} onDelete={handleDeleteMandate}
@ -199,14 +211,14 @@ export const AdminMandatesPage: React.FC = () => {
</button> </button>
</div> </div>
<div className={styles.modalContent}> <div className={styles.modalContent}>
{formAttributes.length === 0 ? ( {createFormAttributes.length === 0 ? (
<div className={styles.loadingContainer}> <div className={styles.loadingContainer}>
<div className={styles.spinner} /> <div className={styles.spinner} />
<span>Lade Formular...</span> <span>Lade Formular...</span>
</div> </div>
) : ( ) : (
<FormGeneratorForm <FormGeneratorForm
attributes={formAttributes} attributes={createFormAttributes}
mode="create" mode="create"
onSubmit={handleCreateSubmit} onSubmit={handleCreateSubmit}
onCancel={() => setShowCreateModal(false)} onCancel={() => setShowCreateModal(false)}
@ -233,6 +245,14 @@ export const AdminMandatesPage: React.FC = () => {
</button> </button>
</div> </div>
<div className={styles.modalContent}> <div className={styles.modalContent}>
{editingMandate.isSystem && (
<div className={styles.infoBox} style={{ marginBottom: '1rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<FaLock style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
<span>
Dies ist ein <strong>System-Mandant</strong>. Er kann nicht gelöscht werden und der Name sollte nicht geändert werden.
</span>
</div>
)}
{formAttributes.length === 0 ? ( {formAttributes.length === 0 ? (
<div className={styles.loadingContainer}> <div className={styles.loadingContainer}>
<div className={styles.spinner} /> <div className={styles.spinner} />

View file

@ -774,6 +774,7 @@ export const PlaygroundPage: React.FC = () => {
selectedProviders={selectedProviders} selectedProviders={selectedProviders}
onChange={onProvidersChange} onChange={onProvidersChange}
showLabel={false} showLabel={false}
excludeByDefault={['privatellm']}
/> />
<VoiceLanguageSelect <VoiceLanguageSelect
value={voiceLanguage} value={voiceLanguage}