# Multi-Tenant UI Konzept - Frontend Nyla ## Architektur für mandantenunabhängige Benutzer **Version:** 1.0 **Datum:** 14. Januar 2026 **Status:** Entwurf **Frontend:** Nyla (React) --- ## 1. Grundprinzip ### 1.1 User ohne Mandanten-Zugehörigkeit Ein User gehört **keinem Mandanten** an. Er sieht: ``` ┌─────────────────────────────────────────────────────────┐ │ User: patrick@example.com │ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ SYSTEM (immer verfügbar) │ │ │ │ • Profil bearbeiten │ │ │ │ • Einstellungen │ │ │ │ • Abmelden │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ FEATURE: Trustee │ │ │ │ ├─ Instanz: "Soha Treuhand / PamoCreate AG" │ │ │ │ ├─ Instanz: "Soha Treuhand / ValueOn AG" │ │ │ │ └─ Instanz: "SwissTreu / Firma X" │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ FEATURE: Chatbot │ │ │ │ └─ Instanz: "Althaus / Management-Tool" │ │ │ └─────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────┘ ``` ### 1.2 Keine mandateId im Frontend **Alt:** Frontend speicherte `mandateId` im State und sendete bei jedem Request. **Neu:** Frontend kennt nur **Feature-Instanzen**. Der Mandant ergibt sich aus der Instanz. ```typescript // ALT const currentMandateId = useStore(state => state.mandateId); await api.get('/trustee/contracts', { params: { mandateId: currentMandateId }}); // NEU const currentInstance = useStore(state => state.currentFeatureInstance); await api.get('/trustee/contracts', { params: { instanceId: currentInstance.id }}); // Backend ermittelt mandateId aus der Instanz ``` --- ## 2. Mandat- und Feature-Struktur ### 2.1 Mandat als oberste Ebene (Level 1) Die Navigation beginnt auf **Mandanten-Ebene**. Darunter liegen Features und deren Instanzen. ```typescript interface Mandate { id: string; // mandateId name: string; // Anzeige-Name features: MandateFeature[]; } interface MandateFeature { code: string; // "trustee", "chatbot", "workflow-dynamic", ... label: I18nLabel; // { en: "Trustee", de: "Treuhand" } icon: string; // Material Icon Name instances: FeatureInstance[]; } interface FeatureInstance { id: string; // UUID der Instanz featureCode: string; // "trustee" mandateId: string; // Zugehöriger Mandant mandateName: string; // Für Anzeige instanceLabel: string; // z.B. "PamoCreate AG" userRole: string; // Rolle des Users in dieser Instanz permissions: InstancePermissions; // Summarische Berechtigungen } ``` ### 2.2 Generisches Laden (Gateway → UI) Die UI rendert Mandanten/Features/Instanzen **generisch** aus dem Gateway-Connector. Ein Endpoint liefert alle sichtbaren Mandate mit deren Feature-Instanzen **inkl.** summarischen Berechtigungen: ```json GET /features/my { "mandates": [ { "id": "mand-1", "name": "Soha Treuhand", "features": [ { "code": "trustee", "label": { "de": "Treuhand" }, "instances": [ { "id": "inst-123", "instanceLabel": "PamoCreate AG", "permissions": { ... } } ] }, { "code": "workflow-dynamic", "label": { "de": "Workflow (Dynamic)" }, "instances": [ ... ] } ] }, { "id": "mand-2", "name": "SwissTreu", "features": [ ... ] } ] } ``` Die UI muss nur diese Struktur rendern; keine Feature-spezifische Navigation wird hartkodiert. ### 2.3 Generisches Instanz-Handling Das Handling für Feature-Instanzen bleibt **generisch**. Die aktuelle Instanz ergibt sich aus der **URL** (Route-Parameter): ```typescript // stores/featureStore.ts interface FeatureState { features: Feature[]; // Actions loadFeatures: () => Promise; getInstanceById: (instanceId: string) => FeatureInstance | undefined; getFeatureByCode: (featureCode: string) => Feature | undefined; } const useFeatureStore = create((set, get) => ({ mandates: [], loadFeatures: async () => { // Ein API-Call lädt alle Mandate + Features + Instanzen + Permissions const response = await api.get('/features/my'); set({ mandates: response.data.mandates }); }, getInstanceById: (instanceId) => { return get().mandates .flatMap(m => m.features) .flatMap(f => f.instances) .find(i => i.id === instanceId); }, getFeatureByCode: (featureCode) => { return get().mandates .flatMap(m => m.features) .find(f => f.code === featureCode); } })); ``` ### 2.3 Instanz aus URL-Parameter Die aktuelle Instanz wird **nicht** im Store gespeichert, sondern aus der URL gelesen: ```typescript // hooks/useCurrentInstance.ts export function useCurrentInstance(): FeatureInstance | undefined { const { instanceId } = useParams<{ instanceId: string }>(); const getInstanceById = useFeatureStore(s => s.getInstanceById); return instanceId ? getInstanceById(instanceId) : undefined; } // Verwendung in Komponenten: function ContractList() { const instance = useCurrentInstance(); if (!instance) { return ; // Keine Instanz in URL } // Arbeite mit instance.permissions, instance.mandateId, etc. } ``` --- ## 3. Berechtigungs-Abfrage (Summarisch) ### 3.1 Problem: Rate Limits **Vermeiden:** ```typescript // SCHLECHT: Ein Request pro Objekt/Feld for (const contract of contracts) { const canEdit = await api.get(`/rbac/check?table=Contract&id=${contract.id}&action=update`); const canDelete = await api.get(`/rbac/check?table=Contract&id=${contract.id}&action=delete`); } ``` ### 3.2 Lösung: Summarische Berechtigungen Berechtigungen werden **einmalig pro Feature-Instanz** geladen: ```typescript interface InstancePermissions { // Tabellen-Level (CRUD pro Tabelle) tables: { [tableName: string]: { view: boolean; read: AccessLevel; // "n" | "m" | "g" | "a" create: AccessLevel; update: AccessLevel; delete: AccessLevel; } }; // Feld-Level (nur wo eingeschränkt) fields?: { [tableName: string]: { [fieldName: string]: { read: boolean; write: boolean; } } }; // View-Level (Navigation) views: { [viewCode: string]: boolean; }; } ``` ### 3.3 API-Endpunkt für Permissions ```typescript // GET /features/my // Lädt alles in einem Request { "features": [ { "code": "trustee", "label": { "en": "Trustee", "de": "Treuhand" }, "instances": [ { "id": "inst-123", "mandateId": "mand-456", "mandateName": "Soha Treuhand", "instanceLabel": "PamoCreate AG", "userRole": "customer", "permissions": { "tables": { "TrusteeOrganisation": { "view": true, "read": "m", "create": "n", "update": "m", "delete": "n" }, "TrusteeContract": { "view": true, "read": "m", "create": "n", "update": "n", "delete": "n" }, "TrusteeDocument": { "view": true, "read": "m", "create": "m", "update": "m", "delete": "n" } }, "views": { "trustee-dashboard": true, "trustee-contracts": true, "trustee-admin": false } } } ] } ] } ``` ### 3.4 Permission-Hooks im Frontend ```typescript // hooks/usePermissions.ts export function useTablePermission(tableName: string) { const instance = useCurrentInstance(); // Aus URL-Parameter if (!instance) { return { view: false, read: 'n', create: 'n', update: 'n', delete: 'n' }; } return instance.permissions.tables[tableName] ?? { view: false, read: 'n', create: 'n', update: 'n', delete: 'n' }; } export function useCanView(viewCode: string): boolean { const instance = useCurrentInstance(); // Aus URL-Parameter return instance?.permissions.views[viewCode] ?? false; } export function useCanEditRecord(tableName: string, record: any): boolean { const permission = useTablePermission(tableName); const userId = useAuthStore(s => s.user?.id); switch (permission.update) { case 'n': return false; case 'm': return record._createdBy === userId; case 'g': return true; // Instanz-Scope case 'a': return true; // Alle default: return false; } } ``` --- ## 4. Navigation & UI-Struktur ### 4.1 Hauptnavigation (Level 1 = Mandant) ``` ┌────────────────────────────────────────────────────────────┐ │ [Logo] PowerOn [User] [Logout] │ ├────────────────────────────────────────────────────────────┤ │ │ │ SYSTEM │ │ ○ Dashboard │ │ ○ Profil │ │ ○ Einstellungen │ │ │ │ ▼ Mandant: Soha Treuhand │ │ │ │ │ ├─▼ Feature: Trustee │ │ │ ├─▼ Instanz: PamoCreate AG │ │ │ │ ○ Übersicht │ │ │ │ ○ Verträge │ │ │ │ ○ Dokumente │ │ │ │ ○ Positionen │ │ │ │ │ │ │ └─▼ Instanz: ValueOn AG │ │ │ ○ Übersicht │ │ │ ○ Verträge │ │ │ ○ Dokumente │ │ │ │ │ └─▼ Feature: Workflow (Dynamic) │ │ └─▼ Instanz: Beratung-Playground │ │ ○ Übersicht │ │ ○ Runs │ │ │ │ ▼ Mandant: SwissTreu │ │ └─▼ Feature: Trustee │ │ └─▶ Instanz: Firma X (collapsed) │ │ │ │ ───────────────────────────────────── │ │ ADMIN (nur wenn sysadmin) │ │ ○ Mandanten │ │ ○ Users │ │ ○ RBAC │ │ │ └────────────────────────────────────────────────────────────┘ ``` **Hierarchie:** ``` Mandant (Level 1) └─ Feature (Gruppe) └─ Instanz (Subgruppe) └─ Objekte/Views (Navigation Items) ``` ### 4.2 Generische Navigation (Mandat → Feature → Instanz) Die Navigation wird ausschließlich aus `mandates[].features[].instances[]` gerendert (siehe Store). Keine hartcodierten Items. ```tsx // components/MandateNav.tsx function MandateNav() { const mandates = useFeatureStore(s => s.mandates); return ( <> {mandates.map(mandate => ( ))} ); } ``` ### 4.3 Mandat- und Feature-Gruppen Jedes Mandat enthält Features, jedes Feature enthält Instanzen. ```tsx // components/MandateNavGroup.tsx function MandateNavGroup({ mandate }: { mandate: Mandate }) { if (!mandate.features.length) return null; return ( {mandate.name} {mandate.features.map(feature => ( ))} ); } ``` ```tsx // components/FeatureNavGroup.tsx function FeatureNavGroup({ feature, mandateId, }: { feature: MandateFeature; mandateId: string; }) { if (!feature.instances.length) return null; return ( {feature.label.de} {feature.instances.map(instance => ( ))} ); } ``` ### 4.4 Instanz-Subgruppe Komponente ```tsx // components/InstanceNavSubgroup.tsx function InstanceNavSubgroup({ instance, featureCode, mandateId }: { instance: FeatureInstance; featureCode: string; mandateId: string; }) { const [isExpanded, setIsExpanded] = useState(false); return ( setIsExpanded(!isExpanded)} collapsible > {instance.mandateName} / {instance.instanceLabel} {isExpanded && ( {instance.permissions.views[`${featureCode}-dashboard`] && ( Übersicht )} {instance.permissions.views[`${featureCode}-contracts`] && ( Verträge )} {instance.permissions.views[`${featureCode}-documents`] && ( Dokumente )} {instance.permissions.views[`${featureCode}-positions`] && ( Positionen )} )} ); } ``` ### 4.5 URL-Struktur mit Mandant und Instanz Die URL enthält **Mandant** und **Instanz**, damit Kontext eindeutig ist: ``` /mandates/{mandateId}/{featureCode}/{instanceId}/dashboard /mandates/{mandateId}/{featureCode}/{instanceId}/contracts /mandates/{mandateId}/{featureCode}/{instanceId}/contracts/{contractId} /mandates/{mandateId}/{featureCode}/{instanceId}/documents /mandates/{mandateId}/chatbot/{instanceId}/conversations /mandates/{mandateId}/chatbot/{instanceId}/settings ``` **Router-Setup:** ```tsx // routes.tsx }> } /> } /> } /> } /> {/* ... */} ``` --- ## 5. System-Bereich (Ohne Mandant) ### 5.1 Immer verfügbare Funktionen User ohne Mandant-Zugehörigkeit können: ```typescript const SYSTEM_FEATURES = { // Immer verfügbar profile: true, settings: true, logout: true, // Nur für sysadmin adminMandates: (user) => user.isSysAdmin, adminUsers: (user) => user.isSysAdmin, adminRbac: (user) => user.isSysAdmin, }; ``` ### 5.2 Dashboard für User ohne Instanzen ```tsx // pages/Dashboard.tsx function Dashboard() { const { features } = useFeatureStore(); const hasInstances = features.some(f => f.instances.length > 0); if (!hasInstances) { return (

Willkommen bei PowerOn

Du hast aktuell Zugriff auf keine Feature-Instanzen.

Kontaktiere einen Administrator, um Zugriff zu erhalten.

Was du jetzt tun kannst:

  • Profil bearbeiten
  • Einstellungen anpassen
); } return ; } ``` --- ## 6. Daten-Requests mit Instanz-Kontext ### 6.1 API-Client mit Instanz-Parameter Da die Instanz-ID in der URL steht, wird sie **explizit** an API-Calls übergeben: ```typescript // services/api.ts const api = axios.create({ baseURL: '/api' }); // Kein Interceptor nötig - Instanz-ID kommt aus URL und wird explizit übergeben ``` ### 6.2 Feature-spezifische Queries ```typescript // hooks/useTrusteeContracts.ts export function useTrusteeContracts() { const instance = useCurrentInstance(); // Aus URL: /trustee/:instanceId/contracts return useQuery({ queryKey: ['trustee', 'contracts', instance?.id], queryFn: async () => { if (!instance) throw new Error('No instance in URL'); // Instanz-ID explizit im Request const response = await api.get(`/trustee/${instance.id}/contracts`); return response.data; }, enabled: !!instance && instance.featureCode === 'trustee' }); } ``` --- ## 7. Berechtigungs-gesteuerte UI-Elemente ### 7.1 Conditional Rendering ```tsx // components/ContractList.tsx function ContractList() { const { data: contracts } = useTrusteeContracts(); const tablePermission = useTablePermission('TrusteeContract'); const userId = useAuthStore(s => s.user?.id); const canCreate = tablePermission.create !== 'n'; return (

Verträge

{canCreate && ( )}
{contracts?.map(contract => { const canEdit = canEditRecord(tablePermission.update, contract, userId); const canDelete = canEditRecord(tablePermission.delete, contract, userId); return ( {contract.name} {canEdit && editContract(contract)} />} {canDelete && deleteContract(contract)} />} ); })}
); } function canEditRecord(accessLevel: AccessLevel, record: any, userId: string): boolean { switch (accessLevel) { case 'n': return false; case 'm': return record._createdBy === userId; case 'g': case 'a': return true; default: return false; } } ``` ### 7.2 Permission-Wrapper Komponente ```tsx // components/PermissionGate.tsx interface PermissionGateProps { table: string; action: 'view' | 'read' | 'create' | 'update' | 'delete'; record?: any; // Für "m" (my) Prüfung children: React.ReactNode; fallback?: React.ReactNode; } function PermissionGate({ table, action, record, children, fallback = null }: PermissionGateProps) { const permission = useTablePermission(table); const userId = useAuthStore(s => s.user?.id); let hasPermission = false; if (action === 'view') { hasPermission = permission.view; } else { const level = permission[action]; if (level === 'n') { hasPermission = false; } else if (level === 'm' && record) { hasPermission = record._createdBy === userId; } else if (level === 'g' || level === 'a') { hasPermission = true; } } return hasPermission ? <>{children} : <>{fallback}; } // Verwendung: ``` --- ## 8. Login & Session ### 8.1 Login-Response (ohne mandateId) ```typescript // Nach erfolgreichem Login: { "token": "jwt...", "user": { "id": "user-123", "username": "patrick", "email": "patrick@example.com", "isSysAdmin": false }, "features": [ // Alle Features + Instanzen + Permissions (siehe Abschnitt 3.3) ] } ``` ### 8.2 Auth-Store ```typescript // stores/authStore.ts interface AuthState { user: User | null; token: string | null; isAuthenticated: boolean; login: (credentials: LoginCredentials) => Promise; logout: () => void; } const useAuthStore = create((set) => ({ user: null, token: null, isAuthenticated: false, login: async (credentials) => { const response = await api.post('/auth/login', credentials); // Token speichern set({ user: response.data.user, token: response.data.token, isAuthenticated: true }); // Features laden (separater Store) useFeatureStore.getState().setFeatures(response.data.features); }, logout: () => { set({ user: null, token: null, isAuthenticated: false }); useFeatureStore.getState().reset(); } })); ``` --- ## 9. Einladungs-Flow ### 9.1 Einladungs-Link ``` https://app.poweron.ch/invite?token=abc123xyz ``` ### 9.2 Einladungs-Seite ```tsx // pages/Invite.tsx function InvitePage() { const token = useSearchParam('token'); const [inviteData, setInviteData] = useState(null); const [error, setError] = useState(null); useEffect(() => { // Token validieren (ohne Login) api.get(`/invite/validate?token=${token}`) .then(res => setInviteData(res.data)) .catch(err => setError(err.response?.data?.detail || 'Ungültiger Link')); }, [token]); if (error) { return ; } if (!inviteData) { return ; } return (

Einladung zu {inviteData.mandateName}

Du wurdest eingeladen, als {inviteData.roleLabel} mitzuarbeiten.

{inviteData.existingUser ? ( // User existiert bereits - nur Login erforderlich acceptInvite(token)} message="Melde dich an, um die Einladung anzunehmen." /> ) : ( // Neuer User - Registrierung acceptInvite(token, userId)} /> )}
); } ``` ### 9.3 Einladungs-Verwaltung (Admin) ```tsx // pages/admin/Invitations.tsx function InvitationsAdmin() { const { data: invitations } = useQuery({ queryKey: ['admin', 'invitations'], queryFn: () => api.get('/admin/invitations').then(r => r.data) }); const revokeInvite = useMutation({ mutationFn: (token: string) => api.delete(`/admin/invitations/${token}`), onSuccess: () => queryClient.invalidateQueries(['admin', 'invitations']) }); return (

Ausstehende Einladungen

Email Mandant Rolle Erstellt Gültig bis Aktionen {invitations?.map(inv => ( {inv.email} {inv.mandateName} {inv.roleLabel} {formatDate(inv.createdAt)} {formatDate(inv.expiresAt)} revokeInvite.mutate(inv.token)} title="Einladung widerrufen" /> ))}
); } ``` --- ## 10. Administration (Gruppe "Administration") Die Administration gliedert sich in **zwei Bereiche**: 1. **SysAdmin** (isSysAdmin=true): System-weite Verwaltung 2. **Mandate-Admin**: Mandanten-spezifische Verwaltung ``` ┌────────────────────────────────────────────────────────────┐ │ Navigation │ ├────────────────────────────────────────────────────────────┤ │ │ │ SYSTEM │ │ ○ Dashboard │ │ ○ Profil │ │ │ │ ▼ Mandant: Soha Treuhand │ │ ├─▼ Feature: Trustee │ │ │ └─▼ Instanz: PamoCreate AG │ │ │ ○ ... │ │ │ │ │ └─▼ ADMINISTRATION (nur Mandate-Admin) │ │ ○ Benutzer │ │ ○ Rollen │ │ ○ Berechtigungen │ │ ○ Feature-Instanzen │ │ ○ Einladungen │ │ ○ RBAC Export/Import │ │ │ │ ───────────────────────────────────── │ │ ADMINISTRATION (nur sysAdmin=true) │ │ ○ Mandanten │ │ ○ Alle Benutzer │ │ ○ Globale Rollen (Templates) │ │ ○ RBAC Templates │ │ ○ System-Einstellungen │ │ │ └────────────────────────────────────────────────────────────┘ ``` ### 10.1 SysAdmin Pages #### 10.1.1 Mandanten-Verwaltung ```tsx // pages/admin/Mandates.tsx function MandatesAdmin() { const user = useAuthStore(s => s.user); if (!user?.isSysAdmin) return ; const { data: mandates } = useQuery({ queryKey: ['admin', 'mandates'], queryFn: () => api.get('/admin/mandates').then(r => r.data) }); return ( Name Code Features User Erstellt Aktionen {mandates?.map(mandate => ( {mandate.name} {mandate.code} {mandate.featureCount} Features {mandate.userCount} User {formatDate(mandate.createdAt)} editMandate(mandate)} /> viewMandateUsers(mandate)} /> deleteMandate(mandate)} /> ))}
); } ``` #### 10.1.2 Globale Rollen (Templates) ```tsx // pages/admin/GlobalRoles.tsx interface GlobalRole { id: string; roleLabel: string; description: I18nLabel; featureCode: string | null; // null = mandanten-weit, sonst feature-spezifisch isSystemRole: boolean; accessRulesCount: number; } function GlobalRolesAdmin() { const { data: roles } = useQuery({ queryKey: ['admin', 'roles', 'global'], queryFn: () => api.get('/admin/rbac/roles?scope=global').then(r => r.data) }); const [selectedRole, setSelectedRole] = useState(null); return ( Globale Rollen dienen als Templates. Sie werden beim Erstellen von Mandanten/Feature-Instanzen kopiert. {/* Linke Spalte: Rollen-Liste */} {roles?.filter(r => !r.featureCode).map(role => ( setSelectedRole(role)} /> ))} {/* Gruppiert nach featureCode */} {groupBy(roles?.filter(r => r.featureCode), 'featureCode') .map(([featureCode, featureRoles]) => ( {featureRoles.map(role => ( setSelectedRole(role)} /> ))} ))} {/* Rechte Spalte: AccessRules Editor */} {selectedRole && ( )} ); } ``` #### 10.1.3 AccessRules Editor (Wiederverwendbar) ```tsx // components/admin/AccessRulesEditor.tsx interface AccessRulesEditorProps { roleId: string; isTemplate?: boolean; // Template = global, sonst mandant/instanz-spezifisch readOnly?: boolean; onSave?: () => void; } function AccessRulesEditor({ roleId, isTemplate, readOnly, onSave }: AccessRulesEditorProps) { const { data: rules } = useQuery({ queryKey: ['rbac', 'rules', roleId], queryFn: () => api.get(`/rbac/roles/${roleId}/rules`).then(r => r.data) }); const [editedRules, setEditedRules] = useState([]); useEffect(() => { if (rules) setEditedRules(rules); }, [rules]); const saveRules = useMutation({ mutationFn: (rules: AccessRule[]) => api.put(`/rbac/roles/${roleId}/rules`, { rules }), onSuccess: onSave }); return ( {/* Tab: Daten (Tabellen) */} r.item.startsWith('table.'))} onChange={updateDataRules} readOnly={readOnly} /> {/* Tab: UI (Views, Komponenten) */} r.item.startsWith('ui.'))} onChange={updateUiRules} readOnly={readOnly} /> {/* Tab: Resources */} r.item.startsWith('resource.'))} onChange={updateResourceRules} readOnly={readOnly} /> {/* Tab: Raw JSON (für Experten) */} {!readOnly && ( )} ); } ``` #### 10.1.4 Daten-Regeln Sektion ```tsx // components/admin/DataRulesSection.tsx interface DataRulesSectionProps { rules: AccessRule[]; onChange: (rules: AccessRule[]) => void; readOnly?: boolean; } function DataRulesSection({ rules, onChange, readOnly }: DataRulesSectionProps) { // Gruppiere nach Tabelle const tableRules = groupRulesByTable(rules); return (
{Object.entries(tableRules).map(([tableName, tableRule]) => ( {tableName} {!readOnly && ( removeTableRule(tableName)} /> )} updateTableRule(tableName, 'view', v)} options={['true', 'false']} disabled={readOnly} /> updateTableRule(tableName, 'read', v)} options={ACCESS_LEVELS} disabled={readOnly} /> updateTableRule(tableName, 'create', v)} options={ACCESS_LEVELS} disabled={readOnly} /> updateTableRule(tableName, 'update', v)} options={ACCESS_LEVELS} disabled={readOnly} /> updateTableRule(tableName, 'delete', v)} options={ACCESS_LEVELS} disabled={readOnly} /> {/* Feld-Level Regeln (optional) */} updateTableFields(tableName, fields)} readOnly={readOnly} /> ))}
); } const ACCESS_LEVELS = [ { value: 'n', label: 'Keine', color: 'red' }, { value: 'm', label: 'Eigene', color: 'yellow' }, { value: 'g', label: 'Gruppe', color: 'blue' }, { value: 'a', label: 'Alle', color: 'green' }, ]; ``` ### 10.2 Mandate-Admin Pages #### 10.2.1 Mandanten-Benutzer ```tsx // pages/mandate/Users.tsx function MandateUsersPage() { const { mandateId } = useParams<{ mandateId: string }>(); const mandate = useMandateById(mandateId); // Prüfe Mandate-Admin Berechtigung const isMandateAdmin = useIsMandateAdmin(mandateId); if (!isMandateAdmin) return ; const { data: users } = useQuery({ queryKey: ['mandate', mandateId, 'users'], queryFn: () => api.get(`/mandates/${mandateId}/users`).then(r => r.data) }); return ( Name Email Mandanten-Rollen Feature-Zugriffe Aktionen {users?.map(user => ( {user.displayName} {user.email} {user.mandateRoles.map(role => ( ))} {user.featureAccessCount} Feature-Instanzen openUserRolesDialog(user, mandateId)} title="Rollen bearbeiten" /> removeUserFromMandate(user.id, mandateId)} title="Aus Mandant entfernen" /> ))}
); } ``` #### 10.2.2 Mandanten-Rollen ```tsx // pages/mandate/Roles.tsx function MandateRolesPage() { const { mandateId } = useParams<{ mandateId: string }>(); const mandate = useMandateById(mandateId); const { data: roles } = useQuery({ queryKey: ['mandate', mandateId, 'roles'], queryFn: () => api.get(`/mandates/${mandateId}/rbac/roles`).then(r => r.data) }); const [selectedRole, setSelectedRole] = useState(null); return ( Diese Rollen gelten nur für den Mandanten "{mandate?.name}". Für globale Template-Änderungen kontaktiere einen System-Administrator. {/* Linke Spalte: Rollen-Liste */} {roles?.filter(r => !r.featureInstanceId).map(role => ( setSelectedRole(role)} showOrigin // Zeigt "Kopiert von Template" Badge /> ))} {groupBy(roles?.filter(r => r.featureInstanceId), 'featureInstanceId') .map(([instanceId, instanceRoles]) => ( {instanceRoles.map(role => ( setSelectedRole(role)} /> ))} ))} {/* Rechte Spalte: AccessRules Editor */} {selectedRole && ( )} ); } ``` #### 10.2.3 RBAC Export/Import ```tsx // pages/mandate/RbacExportImport.tsx function RbacExportImportPage() { const { mandateId } = useParams<{ mandateId: string }>(); const mandate = useMandateById(mandateId); const [importMode, setImportMode] = useState<'merge' | 'replace' | 'add_only'>('merge'); const [importFile, setImportFile] = useState(null); const exportRbac = async () => { const response = await api.get(`/mandates/${mandateId}/rbac/export`, { responseType: 'blob' }); downloadBlob(response.data, `rbac-${mandate?.code}-${formatDate(new Date())}.json`); }; const importRbac = useMutation({ mutationFn: async () => { const formData = new FormData(); formData.append('file', importFile!); formData.append('mode', importMode); return api.post(`/mandates/${mandateId}/rbac/import`, formData); }, onSuccess: () => { toast.success('RBAC erfolgreich importiert'); setImportFile(null); } }); return ( {/* Export Panel */} Export

Exportiert alle Rollen und Berechtigungen dieses Mandanten als JSON-Datei. Feature-Instanz-spezifische Regeln werden ebenfalls exportiert.

{/* Import Panel */} Import setImportFile(files[0])} > {importFile ? (
{importFile.name} setImportFile(null)} />
) : (

JSON-Datei hier ablegen oder klicken

)}
Zusammenführen Bestehende Regeln aktualisieren, neue hinzufügen Nur hinzufügen Nur neue Regeln hinzufügen, bestehende nicht ändern Ersetzen Alle bestehenden Regeln löschen und ersetzen
); } ``` #### 10.2.4 Feature-Instanzen Verwaltung ```tsx // pages/mandate/FeatureInstances.tsx function FeatureInstancesPage() { const { mandateId } = useParams<{ mandateId: string }>(); const mandate = useMandateById(mandateId); const { data: instances } = useQuery({ queryKey: ['mandate', mandateId, 'instances'], queryFn: () => api.get(`/mandates/${mandateId}/feature-instances`).then(r => r.data) }); const { data: availableFeatures } = useQuery({ queryKey: ['features', 'available'], queryFn: () => api.get('/features/available').then(r => r.data) }); return ( {/* Gruppiert nach Feature */} {groupBy(instances, 'featureCode').map(([featureCode, featureInstances]) => { const feature = availableFeatures?.find(f => f.code === featureCode); return ( {feature?.label.de || featureCode} {featureInstances.length} Instanzen Label Benutzer Rollen Erstellt Aktionen {featureInstances.map(instance => ( {instance.instanceLabel} {instance.userCount} User {instance.roleCount} Rollen {formatDate(instance.createdAt)} openInstanceUsersDialog(instance)} title="Benutzer verwalten" /> navigate(`/mandates/${mandateId}/admin/instances/${instance.id}/roles`)} title="Rollen & Berechtigungen" /> syncInstanceFromTemplate(instance.id)} title="Von Template synchronisieren" /> deleteInstance(instance)} title="Instanz löschen" /> ))}
); })}
); } ``` ### 10.3 User-Rollen Dialog ```tsx // components/dialogs/UserRolesDialog.tsx interface UserRolesDialogProps { user: User; mandateId: string; onClose: () => void; } function UserRolesDialog({ user, mandateId, onClose }: UserRolesDialogProps) { const { data: userMembership } = useQuery({ queryKey: ['user', user.id, 'mandate', mandateId, 'membership'], queryFn: () => api.get(`/mandates/${mandateId}/users/${user.id}/membership`).then(r => r.data) }); const { data: availableRoles } = useQuery({ queryKey: ['mandate', mandateId, 'roles', 'available'], queryFn: () => api.get(`/mandates/${mandateId}/rbac/roles`).then(r => r.data) }); const [selectedMandateRoles, setSelectedMandateRoles] = useState([]); const [featureAccess, setFeatureAccess] = useState([]); useEffect(() => { if (userMembership) { setSelectedMandateRoles(userMembership.mandateRoleIds); setFeatureAccess(userMembership.featureAccess); } }, [userMembership]); const saveMembership = useMutation({ mutationFn: () => api.put(`/mandates/${mandateId}/users/${user.id}/membership`, { mandateRoleIds: selectedMandateRoles, featureAccess }), onSuccess: () => { toast.success('Rollen gespeichert'); onClose(); } }); return (

{user.displayName}

{user.email}
{/* Mandanten-Rollen */}
{availableRoles ?.filter(r => !r.featureInstanceId) .map(role => ( {role.roleLabel} {role.description?.de} ))}
{/* Feature-Instanz Zugriffe */}
); } // Feature-Zugriff Editor function FeatureAccessEditor({ mandateId, value, onChange }) { const { data: instances } = useQuery({ queryKey: ['mandate', mandateId, 'instances'], queryFn: () => api.get(`/mandates/${mandateId}/feature-instances`).then(r => r.data) }); return (
{groupBy(instances, 'featureCode').map(([featureCode, featureInstances]) => ( {featureInstances.map(instance => { const access = value.find(a => a.instanceId === instance.id); return ( toggleInstanceAccess(instance.id, checked)} /> {instance.instanceLabel} {access && ( updateInstanceRoles(instance.id, roleIds)} > {instance.availableRoles.map(role => ( {role.roleLabel} ))} )} ); })} ))}
); } ``` ### 10.4 Navigation für Administration ```tsx // components/AdminNavSection.tsx function AdminNavSection() { const user = useAuthStore(s => s.user); const mandates = useFeatureStore(s => s.mandates); return ( <> {/* Mandate-Admin Sektionen (pro Mandant) */} {mandates .filter(m => userIsMandateAdmin(user, m.id)) .map(mandate => ( Administration: {mandate.name} Benutzer Rollen Feature-Instanzen Einladungen RBAC Export/Import ))} {/* SysAdmin Sektion */} {user?.isSysAdmin && ( System-Administration Mandanten Alle Benutzer Globale Rollen RBAC Templates System-Einstellungen )} ); } ``` ### 10.5 API-Endpunkte (Admin) | Endpunkt | Berechtigung | Beschreibung | |----------|--------------|--------------| | **SysAdmin** | | | | `GET /admin/mandates` | isSysAdmin | Alle Mandanten | | `POST /admin/mandates` | isSysAdmin | Mandant erstellen | | `PUT /admin/mandates/{id}` | isSysAdmin | Mandant bearbeiten | | `DELETE /admin/mandates/{id}` | isSysAdmin | Mandant löschen (CASCADE!) | | `GET /admin/users` | isSysAdmin | Alle User im System | | `GET /admin/rbac/roles?scope=global` | isSysAdmin | Globale Template-Rollen | | `POST /admin/rbac/roles` | isSysAdmin | Template-Rolle erstellen | | `PUT /admin/rbac/roles/{id}` | isSysAdmin | Template-Rolle bearbeiten | | `GET /admin/rbac/roles/{id}/rules` | isSysAdmin | AccessRules einer Rolle | | `PUT /admin/rbac/roles/{id}/rules` | isSysAdmin | AccessRules speichern | | **Mandate-Admin** | | | | `GET /mandates/{mid}/users` | Mandate-Admin | User im Mandant | | `POST /mandates/{mid}/users` | Mandate-Admin | User hinzufügen | | `DELETE /mandates/{mid}/users/{uid}` | Mandate-Admin | User entfernen | | `GET /mandates/{mid}/users/{uid}/membership` | Mandate-Admin | User-Mitgliedschaft | | `PUT /mandates/{mid}/users/{uid}/membership` | Mandate-Admin | Mitgliedschaft ändern | | `GET /mandates/{mid}/rbac/roles` | Mandate-Admin | Mandanten-Rollen | | `POST /mandates/{mid}/rbac/roles` | Mandate-Admin | Rolle erstellen | | `GET /mandates/{mid}/rbac/export` | Mandate-Admin | RBAC exportieren | | `POST /mandates/{mid}/rbac/import` | Mandate-Admin | RBAC importieren | | `GET /mandates/{mid}/feature-instances` | Mandate-Admin | Feature-Instanzen | | `POST /mandates/{mid}/feature-instances` | Mandate-Admin | Instanz erstellen | | `DELETE /mandates/{mid}/feature-instances/{iid}` | Mandate-Admin | Instanz löschen | | `POST /mandates/{mid}/feature-instances/{iid}/sync-roles` | Mandate-Admin | Rollen synchronisieren | --- ## 11. Zusammenfassung ### 11.1 Wichtige Prinzipien | Prinzip | Umsetzung | |---------|-----------| | **Kein mandateId** | User gehört keinem Mandanten, arbeitet in Feature-Instanzen | | **Summarische Permissions** | Ein Request lädt alle Berechtigungen pro Instanz | | **Generisches Feature-Handling** | Alle Features haben gleiche Struktur | | **Instanz aus URL** | `/feature/{instanceId}/...` - Instanz-ID immer in URL | | **Navigation = Hierarchie** | Mandant → Feature → Instanz → Views | | **System-Features immer verfügbar** | Profil, Settings, Logout ohne Instanz | ### 11.2 State-Architektur ``` ┌─────────────────────────────────────────────────────────┐ │ Frontend State │ ├─────────────────────────────────────────────────────────┤ │ │ │ AuthStore │ │ ├─ user: User │ │ ├─ token: string │ │ └─ isAuthenticated: boolean │ │ │ │ FeatureStore │ │ ├─ mandates: Mandate[] │ │ │ └─ features: MandateFeature[] │ │ │ └─ instances: FeatureInstance[] │ │ │ └─ permissions: InstancePermissions │ │ └─ getInstanceById(id): FeatureInstance │ │ │ │ Router (URL) │ │ └─ /mandates/:mandateId/:featureCode/:instanceId/* │ │ │ └─────────────────────────────────────────────────────────┘ ``` ### 11.3 API-Endpunkte (Frontend-relevant) | Endpunkt | Beschreibung | |----------|--------------| | `GET /features/my` | Alle Mandate + Features + Instanzen + Permissions (für User sichtbare) | | `GET /invite/validate?token=X` | Einladung validieren | | `POST /invite/accept` | Einladung annehmen | | `GET /admin/invitations` | Alle Einladungen (Admin) | | `DELETE /admin/invitations/{token}` | Einladung widerrufen | --- ## 12. Migration (Frontend) ### 12.1 Zu entfernen - `currentMandateId` aus globalem State - `mandateId` aus API-Requests (Query-Params) - Mandanten-Switcher ### 12.2 Zu ändern - **Navigation:** Feature → Instanz-Subgruppen → Objekte (hierarchisch) - **URLs:** Müssen `/:featureCode/:instanceId/...` enthalten - **Permission-Checks:** Auf `useTablePermission` + `useCurrentInstance` umstellen - **API-Calls:** Instanz-ID aus URL-Parameter verwenden ### 12.3 Neu - `FeatureStore` mit allen Instanzen und Permissions - `useCurrentInstance()` Hook (liest aus URL) - `FeatureNavGroup` + `InstanceNavSubgroup` Komponenten - `PermissionGate` Wrapper - Einladungs-Flow UI