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

View file

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

View file

@ -102,6 +102,7 @@ interface ProviderMultiSelectProps {
label?: string;
showLabel?: boolean;
defaultExpanded?: boolean;
excludeByDefault?: string[];
}
export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
@ -112,8 +113,10 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
label = 'AI-Provider',
showLabel = true,
defaultExpanded = false,
excludeByDefault = [],
}) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const [initialExcludeApplied, setInitialExcludeApplied] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
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
const handleClickOutside = useCallback((event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
@ -137,37 +159,49 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
}
}, [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 (
<div
@ -199,14 +233,6 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
>
Alle
</button>
<button
type="button"
onClick={handleSelectNone}
disabled={disabled}
className={`${styles.actionButton} ${isNoneSelected ? styles.active : ''}`}
>
Keine
</button>
</div>
{loading ? (
@ -220,7 +246,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
>
<input
type="checkbox"
checked={selectedProviders.includes(provider)}
checked={effectiveSelection.includes(provider)}
onChange={() => handleToggle(provider)}
disabled={disabled}
/>
@ -233,9 +259,9 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
</div>
)}
{selectedProviders.length === 0 && !loading && (
{isAllSelected && !loading && (
<div className={styles.hint}>
Alle Provider aktiv
Alle Provider aktiv (kein Filter)
</div>
)}
</div>

View file

@ -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<string>();
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);

View file

@ -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)

View file

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

View file

@ -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<Role[]> => {
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 [];

View file

@ -44,6 +44,7 @@ export const InvitePage: React.FC = () => {
const [accepting, setAccepting] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const [userExists, setUserExists] = useState<boolean | null>(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 (
<div className={styles.container}>
<div className={styles.card}>
@ -254,9 +271,11 @@ export const InvitePage: React.FC = () => {
<div className={styles.authPrompt}>
<p>
{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.'}
</p>
</div>
@ -267,29 +286,48 @@ export const InvitePage: React.FC = () => {
)}
<div className={styles.authActions}>
<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>
{userExists === true ? (
<button
className={styles.primaryButton}
onClick={handleLoginRedirect}
>
<FaSignInAlt /> Anmelden
</button>
) : userExists === false ? (
<button
className={styles.primaryButton}
onClick={handleRegisterRedirect}
>
<FaUserPlus /> 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 className={styles.authInfo}>
<p>
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.'}
</p>
</div>
</div>

View file

@ -69,7 +69,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
// State
const [mandates, setMandates] = useState<Mandate[]>([]);
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);
// 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 (
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
<FaUserShield style={{ marginRight: 4 }} /> System
<FaUserShield style={{ marginRight: 4 }} /> System-Template
</span>
);
}
if (!role.mandateId) {
return (
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
<FaGlobe style={{ marginRight: 4 }} /> Global
<FaGlobe style={{ marginRight: 4 }} /> Template
</span>
);
}
@ -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 = () => {
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
<span>
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>
</div>
@ -293,9 +299,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
<p>Keine Rollen gefunden</p>
<p className={styles.emptyHint}>
{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.'}
</p>
</div>
@ -328,11 +334,20 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
{/* Expanded Content - AccessRulesEditor */}
{expandedRoleId === role.id && (
<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
roleId={role.id}
roleName={role.roleLabel}
isTemplate={false}
readOnly={false} // All AccessRules are editable (access controlled via RBAC)
isTemplate={_isTemplateRole(role)}
readOnly={false}
apiBasePath="/api/rbac"
mandateId={selectedMandateId}
/>

View file

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

View file

@ -9,7 +9,7 @@ import { useNavigate } from 'react-router-dom';
import { useAdminMandates, type Mandate } from '../../hooks/useMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
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';
export const AdminMandatesPage: React.FC = () => {
@ -42,6 +42,11 @@ export const AdminMandatesPage: React.FC = () => {
})) as AttributeDefinition[];
}, [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 [editingMandate, setEditingMandate] = useState<Mandate | null>(null);
@ -76,7 +81,11 @@ export const AdminMandatesPage: React.FC = () => {
};
// Handle delete (confirmation handled by DeleteActionButton)
// System mandates (isSystem=true) are protected from deletion
const handleDeleteMandate = async (mandate: Mandate) => {
if (mandate.isSystem) {
return; // Safety guard - should not be reachable due to disabled button
}
await handleDelete(mandate.id);
};
@ -169,6 +178,9 @@ export const AdminMandatesPage: React.FC = () => {
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
disabled: (row: Mandate) => row.isSystem
? { disabled: true, message: 'System-Mandanten können nicht gelöscht werden' }
: false
}] : []),
]}
onDelete={handleDeleteMandate}
@ -199,14 +211,14 @@ export const AdminMandatesPage: React.FC = () => {
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
{createFormAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
attributes={createFormAttributes}
mode="create"
onSubmit={handleCreateSubmit}
onCancel={() => setShowCreateModal(false)}
@ -233,6 +245,14 @@ export const AdminMandatesPage: React.FC = () => {
</button>
</div>
<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 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />

View file

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