# 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. Feature-Objekt-Struktur ### 2.1 Feature als UI-Gruppe Jedes Feature ist ein **Objekt**, das im UI als Gruppe organisiert ist: ```typescript interface Feature { code: string; // "trustee", "chatbot", "crm" 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; // Referenz (für Backend) mandateName: string; // "Soha Treuhand" (für Anzeige) instanceLabel: string; // "PamoCreate AG" (spezifischer Name) userRole: string; // Rolle des Users in dieser Instanz permissions: InstancePermissions; // Summarische Berechtigungen } ``` ### 2.2 Generisches Instanz-Handling Das Handling für Feature-Instanzen ist **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) => ({ features: [], loadFeatures: async () => { // Ein API-Call lädt alle Features + Instanzen + Permissions const response = await api.get('/features/my'); set({ features: response.data }); }, getInstanceById: (instanceId) => { return get().features .flatMap(f => f.instances) .find(i => i.id === instanceId); }, getFeatureByCode: (featureCode) => { return get().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 ``` ┌────────────────────────────────────────────────────────────┐ │ [Logo] PowerOn [User] [Logout] │ ├────────────────────────────────────────────────────────────┤ │ │ │ SYSTEM │ │ ○ Dashboard │ │ ○ Profil │ │ ○ Einstellungen │ │ │ │ ▼ TRUSTEE [Feature] │ │ │ │ │ ├─▼ Soha Treuhand / PamoCreate AG [Instanz] │ │ │ ○ Übersicht │ │ │ ○ Verträge │ │ │ ○ Dokumente │ │ │ ○ Positionen │ │ │ │ │ ├─▼ Soha Treuhand / ValueOn AG [Instanz] │ │ │ ○ Übersicht │ │ │ ○ Verträge │ │ │ ○ Dokumente │ │ │ │ │ └─▶ SwissTreu / Firma X [Instanz] │ │ (collapsed) │ │ │ │ ▼ CHATBOT [Feature] │ │ │ │ │ └─▼ Althaus / Management-Tool [Instanz] │ │ ○ Conversations │ │ ○ Settings │ │ │ │ ───────────────────────────────────── │ │ ADMIN (nur wenn sysadmin) │ │ ○ Mandanten │ │ ○ Users │ │ ○ RBAC │ │ │ └────────────────────────────────────────────────────────────┘ ``` **Hierarchie:** ``` Feature (Gruppe) └─ Instanz (Subgruppe) └─ Objekte/Views (Navigation Items) ``` ``` ### 4.2 Feature-Gruppe mit Instanz-Subgruppen Jedes Feature zeigt **alle Instanzen als Subgruppen** an: ```tsx // components/FeatureNavGroup.tsx function FeatureNavGroup({ featureCode }: { featureCode: string }) { const { features } = useFeatureStore(); const feature = features.find(f => f.code === featureCode); if (!feature || feature.instances.length === 0) { return null; // Feature nicht anzeigen wenn keine Instanzen } return ( {/* Feature als Hauptgruppe */} {feature.label.de} {/* Jede Instanz als Subgruppe */} {feature.instances.map(instance => ( ))} ); } ``` ### 4.3 Instanz-Subgruppe Komponente ```tsx // components/InstanceNavSubgroup.tsx function InstanceNavSubgroup({ instance, featureCode }: { instance: FeatureInstance; featureCode: string; }) { const [isExpanded, setIsExpanded] = useState(false); return ( {/* Instanz als Subgruppen-Header */} setIsExpanded(!isExpanded)} collapsible > {instance.mandateName} / {instance.instanceLabel} {/* Objekte/Views der Instanz */} {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.4 URL-Struktur mit Instanz-ID Die URL enthält immer die **Instanz-ID**, damit klar ist, in welcher Instanz gearbeitet wird: ``` /trustee/{instanceId}/dashboard /trustee/{instanceId}/contracts /trustee/{instanceId}/contracts/{contractId} /trustee/{instanceId}/documents /chatbot/{instanceId}/conversations /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. Zusammenfassung ### 10.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** | Feature → Instanz (Subgruppe) → Objekte | | **System-Features immer verfügbar** | Profil, Settings, Logout ohne Instanz | ### 10.2 State-Architektur ``` ┌─────────────────────────────────────────────────────────┐ │ Frontend State │ ├─────────────────────────────────────────────────────────┤ │ │ │ AuthStore │ │ ├─ user: User │ │ ├─ token: string │ │ └─ isAuthenticated: boolean │ │ │ │ FeatureStore │ │ ├─ features: Feature[] │ │ │ └─ instances: FeatureInstance[] │ │ │ └─ permissions: InstancePermissions │ │ └─ getInstanceById(id): FeatureInstance │ │ │ │ Router (URL) │ │ └─ /:featureCode/:instanceId/* → aktuelle Instanz │ │ │ └─────────────────────────────────────────────────────────┘ ``` ### 10.3 API-Endpunkte (Frontend-relevant) | Endpunkt | Beschreibung | |----------|--------------| | `GET /features/my` | Alle Features + Instanzen + Permissions | | `GET /invite/validate?token=X` | Einladung validieren | | `POST /invite/accept` | Einladung annehmen | | `GET /admin/invitations` | Alle Einladungen (Admin) | | `DELETE /admin/invitations/{token}` | Einladung widerrufen | --- ## 11. Migration (Frontend) ### 11.1 Zu entfernen - `currentMandateId` aus globalem State - `mandateId` aus API-Requests (Query-Params) - Mandanten-Switcher ### 11.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 ### 11.3 Neu - `FeatureStore` mit allen Instanzen und Permissions - `useCurrentInstance()` Hook (liest aus URL) - `FeatureNavGroup` + `InstanceNavSubgroup` Komponenten - `PermissionGate` Wrapper - Einladungs-Flow UI