wiki/implementation/Saas Multi Tenant Mandate/mandate_implementation_ui_myla.md
2026-01-16 22:14:01 +01:00

1925 lines
61 KiB
Markdown

# 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<void>;
getInstanceById: (instanceId: string) => FeatureInstance | undefined;
getFeatureByCode: (featureCode: string) => Feature | undefined;
}
const useFeatureStore = create<FeatureState>((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 <Navigate to="/" />; // 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 => (
<MandateNavGroup key={mandate.id} mandate={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 (
<NavGroup>
<NavGroupHeader collapsible>
<Icon name="corporate_fare" />
<span>{mandate.name}</span>
</NavGroupHeader>
{mandate.features.map(feature => (
<FeatureNavGroup
key={`${mandate.id}-${feature.code}`}
mandateId={mandate.id}
feature={feature}
/>
))}
</NavGroup>
);
}
```
```tsx
// components/FeatureNavGroup.tsx
function FeatureNavGroup({
feature,
mandateId,
}: {
feature: MandateFeature;
mandateId: string;
}) {
if (!feature.instances.length) return null;
return (
<NavGroup>
<NavGroupHeader collapsible>
<Icon name={feature.icon} />
<span>{feature.label.de}</span>
</NavGroupHeader>
{feature.instances.map(instance => (
<InstanceNavSubgroup
key={instance.id}
instance={instance}
featureCode={feature.code}
mandateId={mandateId}
/>
))}
</NavGroup>
);
}
```
### 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 (
<NavSubgroup>
<NavSubgroupHeader
onClick={() => setIsExpanded(!isExpanded)}
collapsible
>
<Icon name={isExpanded ? 'expand_more' : 'chevron_right'} />
<span>{instance.mandateName} / {instance.instanceLabel}</span>
<RoleBadge role={instance.userRole} />
</NavSubgroupHeader>
{isExpanded && (
<NavSubgroupItems>
{instance.permissions.views[`${featureCode}-dashboard`] && (
<NavItem to={`/mandates/${mandateId}/${featureCode}/${instance.id}/dashboard`}>
Übersicht
</NavItem>
)}
{instance.permissions.views[`${featureCode}-contracts`] && (
<NavItem to={`/mandates/${mandateId}/${featureCode}/${instance.id}/contracts`}>
Verträge
</NavItem>
)}
{instance.permissions.views[`${featureCode}-documents`] && (
<NavItem to={`/mandates/${mandateId}/${featureCode}/${instance.id}/documents`}>
Dokumente
</NavItem>
)}
{instance.permissions.views[`${featureCode}-positions`] && (
<NavItem to={`/mandates/${mandateId}/${featureCode}/${instance.id}/positions`}>
Positionen
</NavItem>
)}
</NavSubgroupItems>
)}
</NavSubgroup>
);
}
```
### 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
<Route path="/mandates/:mandateId/:featureCode/:instanceId/*" element={<FeatureLayout />}>
<Route path="dashboard" element={<Dashboard />} />
<Route path="contracts" element={<ContractList />} />
<Route path="contracts/:contractId" element={<ContractDetail />} />
<Route path="documents" element={<DocumentList />} />
{/* ... */}
</Route>
```
---
## 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 (
<WelcomeScreen>
<h1>Willkommen bei PowerOn</h1>
<p>Du hast aktuell Zugriff auf keine Feature-Instanzen.</p>
<p>Kontaktiere einen Administrator, um Zugriff zu erhalten.</p>
<Card>
<h3>Was du jetzt tun kannst:</h3>
<ul>
<li><Link to="/profile">Profil bearbeiten</Link></li>
<li><Link to="/settings">Einstellungen anpassen</Link></li>
</ul>
</Card>
</WelcomeScreen>
);
}
return <FeatureOverviewDashboard features={features} />;
}
```
---
## 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 (
<div>
<Header>
<h2>Verträge</h2>
{canCreate && (
<Button onClick={openCreateDialog}>Neuer Vertrag</Button>
)}
</Header>
<Table>
{contracts?.map(contract => {
const canEdit = canEditRecord(tablePermission.update, contract, userId);
const canDelete = canEditRecord(tablePermission.delete, contract, userId);
return (
<TableRow key={contract.id}>
<TableCell>{contract.name}</TableCell>
<TableCell>
{canEdit && <IconButton icon="edit" onClick={() => editContract(contract)} />}
{canDelete && <IconButton icon="delete" onClick={() => deleteContract(contract)} />}
</TableCell>
</TableRow>
);
})}
</Table>
</div>
);
}
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:
<PermissionGate table="TrusteeContract" action="create">
<Button>Neuer Vertrag</Button>
</PermissionGate>
<PermissionGate table="TrusteeContract" action="update" record={contract}>
<IconButton icon="edit" />
</PermissionGate>
```
---
## 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<void>;
logout: () => void;
}
const useAuthStore = create<AuthState>((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<InviteData | null>(null);
const [error, setError] = useState<string | null>(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 <ErrorScreen message={error} />;
}
if (!inviteData) {
return <Loading />;
}
return (
<InviteForm>
<h1>Einladung zu {inviteData.mandateName}</h1>
<p>Du wurdest eingeladen, als <strong>{inviteData.roleLabel}</strong> mitzuarbeiten.</p>
{inviteData.existingUser ? (
// User existiert bereits - nur Login erforderlich
<LoginForm
onSuccess={() => acceptInvite(token)}
message="Melde dich an, um die Einladung anzunehmen."
/>
) : (
// Neuer User - Registrierung
<RegisterForm
prefillEmail={inviteData.email}
onSuccess={(userId) => acceptInvite(token, userId)}
/>
)}
</InviteForm>
);
}
```
### 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 (
<div>
<h2>Ausstehende Einladungen</h2>
<Table>
<TableHeader>
<TableColumn>Email</TableColumn>
<TableColumn>Mandant</TableColumn>
<TableColumn>Rolle</TableColumn>
<TableColumn>Erstellt</TableColumn>
<TableColumn>Gültig bis</TableColumn>
<TableColumn>Aktionen</TableColumn>
</TableHeader>
<TableBody>
{invitations?.map(inv => (
<TableRow key={inv.token}>
<TableCell>{inv.email}</TableCell>
<TableCell>{inv.mandateName}</TableCell>
<TableCell>{inv.roleLabel}</TableCell>
<TableCell>{formatDate(inv.createdAt)}</TableCell>
<TableCell>{formatDate(inv.expiresAt)}</TableCell>
<TableCell>
<IconButton
icon="delete"
onClick={() => revokeInvite.mutate(inv.token)}
title="Einladung widerrufen"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
```
---
## 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 <Navigate to="/" />;
const { data: mandates } = useQuery({
queryKey: ['admin', 'mandates'],
queryFn: () => api.get('/admin/mandates').then(r => r.data)
});
return (
<AdminLayout title="Mandanten">
<Toolbar>
<Button onClick={openCreateMandateDialog}>
<Icon name="add" /> Neuer Mandant
</Button>
</Toolbar>
<Table>
<TableHeader>
<TableColumn>Name</TableColumn>
<TableColumn>Code</TableColumn>
<TableColumn>Features</TableColumn>
<TableColumn>User</TableColumn>
<TableColumn>Erstellt</TableColumn>
<TableColumn>Aktionen</TableColumn>
</TableHeader>
<TableBody>
{mandates?.map(mandate => (
<TableRow key={mandate.id}>
<TableCell>{mandate.name}</TableCell>
<TableCell><Code>{mandate.code}</Code></TableCell>
<TableCell>
<Badge>{mandate.featureCount} Features</Badge>
</TableCell>
<TableCell>
<Badge>{mandate.userCount} User</Badge>
</TableCell>
<TableCell>{formatDate(mandate.createdAt)}</TableCell>
<TableCell>
<IconButton icon="edit" onClick={() => editMandate(mandate)} />
<IconButton icon="people" onClick={() => viewMandateUsers(mandate)} />
<IconButton icon="delete" onClick={() => deleteMandate(mandate)} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</AdminLayout>
);
}
```
#### 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<GlobalRole | null>(null);
return (
<AdminLayout title="Globale Rollen (Templates)">
<InfoBanner type="info">
Globale Rollen dienen als Templates. Sie werden beim Erstellen von
Mandanten/Feature-Instanzen kopiert.
</InfoBanner>
<Toolbar>
<Button onClick={openCreateRoleDialog}>
<Icon name="add" /> Neue Template-Rolle
</Button>
</Toolbar>
<TwoColumnLayout>
{/* Linke Spalte: Rollen-Liste */}
<RoleList>
<Tabs>
<Tab label="Mandanten-Rollen">
{roles?.filter(r => !r.featureCode).map(role => (
<RoleListItem
key={role.id}
role={role}
selected={selectedRole?.id === role.id}
onClick={() => setSelectedRole(role)}
/>
))}
</Tab>
<Tab label="Feature-Rollen">
{/* Gruppiert nach featureCode */}
{groupBy(roles?.filter(r => r.featureCode), 'featureCode')
.map(([featureCode, featureRoles]) => (
<Accordion key={featureCode} title={featureCode}>
{featureRoles.map(role => (
<RoleListItem
key={role.id}
role={role}
selected={selectedRole?.id === role.id}
onClick={() => setSelectedRole(role)}
/>
))}
</Accordion>
))}
</Tab>
</Tabs>
</RoleList>
{/* Rechte Spalte: AccessRules Editor */}
{selectedRole && (
<AccessRulesEditor
roleId={selectedRole.id}
isTemplate={true}
onSave={refetchRoles}
/>
)}
</TwoColumnLayout>
</AdminLayout>
);
}
```
#### 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<AccessRule[]>([]);
useEffect(() => {
if (rules) setEditedRules(rules);
}, [rules]);
const saveRules = useMutation({
mutationFn: (rules: AccessRule[]) =>
api.put(`/rbac/roles/${roleId}/rules`, { rules }),
onSuccess: onSave
});
return (
<Panel title="Berechtigungen">
<Tabs>
{/* Tab: Daten (Tabellen) */}
<Tab label="Daten" icon="table_chart">
<DataRulesSection
rules={editedRules.filter(r => r.item.startsWith('table.'))}
onChange={updateDataRules}
readOnly={readOnly}
/>
</Tab>
{/* Tab: UI (Views, Komponenten) */}
<Tab label="UI" icon="view_quilt">
<UiRulesSection
rules={editedRules.filter(r => r.item.startsWith('ui.'))}
onChange={updateUiRules}
readOnly={readOnly}
/>
</Tab>
{/* Tab: Resources */}
<Tab label="Ressourcen" icon="folder">
<ResourceRulesSection
rules={editedRules.filter(r => r.item.startsWith('resource.'))}
onChange={updateResourceRules}
readOnly={readOnly}
/>
</Tab>
{/* Tab: Raw JSON (für Experten) */}
<Tab label="JSON" icon="code">
<JsonEditor
value={editedRules}
onChange={setEditedRules}
readOnly={readOnly}
/>
</Tab>
</Tabs>
{!readOnly && (
<ActionBar>
<Button variant="secondary" onClick={() => setEditedRules(rules)}>
Zurücksetzen
</Button>
<Button onClick={() => saveRules.mutate(editedRules)}>
Speichern
</Button>
</ActionBar>
)}
</Panel>
);
}
```
#### 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 (
<div>
<Toolbar>
<Button onClick={addTableRule} disabled={readOnly}>
<Icon name="add" /> Tabellen-Regel
</Button>
</Toolbar>
{Object.entries(tableRules).map(([tableName, tableRule]) => (
<Card key={tableName}>
<CardHeader>
<Icon name="table_chart" />
<span>{tableName}</span>
{!readOnly && (
<IconButton icon="delete" onClick={() => removeTableRule(tableName)} />
)}
</CardHeader>
<CardContent>
<Grid columns={5}>
<AccessLevelSelect
label="View"
value={tableRule.view}
onChange={v => updateTableRule(tableName, 'view', v)}
options={['true', 'false']}
disabled={readOnly}
/>
<AccessLevelSelect
label="Read"
value={tableRule.read}
onChange={v => updateTableRule(tableName, 'read', v)}
options={ACCESS_LEVELS}
disabled={readOnly}
/>
<AccessLevelSelect
label="Create"
value={tableRule.create}
onChange={v => updateTableRule(tableName, 'create', v)}
options={ACCESS_LEVELS}
disabled={readOnly}
/>
<AccessLevelSelect
label="Update"
value={tableRule.update}
onChange={v => updateTableRule(tableName, 'update', v)}
options={ACCESS_LEVELS}
disabled={readOnly}
/>
<AccessLevelSelect
label="Delete"
value={tableRule.delete}
onChange={v => updateTableRule(tableName, 'delete', v)}
options={ACCESS_LEVELS}
disabled={readOnly}
/>
</Grid>
{/* Feld-Level Regeln (optional) */}
<Accordion title="Feld-Berechtigungen">
<FieldRulesEditor
tableName={tableName}
rules={tableRule.fields}
onChange={fields => updateTableFields(tableName, fields)}
readOnly={readOnly}
/>
</Accordion>
</CardContent>
</Card>
))}
</div>
);
}
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 <AccessDenied />;
const { data: users } = useQuery({
queryKey: ['mandate', mandateId, 'users'],
queryFn: () => api.get(`/mandates/${mandateId}/users`).then(r => r.data)
});
return (
<AdminLayout title={`Benutzer - ${mandate?.name}`}>
<Toolbar>
<Button onClick={() => openInviteDialog(mandateId)}>
<Icon name="person_add" /> Benutzer einladen
</Button>
<Button variant="secondary" onClick={() => openAddExistingUserDialog(mandateId)}>
<Icon name="group_add" /> Bestehenden User hinzufügen
</Button>
</Toolbar>
<Table>
<TableHeader>
<TableColumn>Name</TableColumn>
<TableColumn>Email</TableColumn>
<TableColumn>Mandanten-Rollen</TableColumn>
<TableColumn>Feature-Zugriffe</TableColumn>
<TableColumn>Aktionen</TableColumn>
</TableHeader>
<TableBody>
{users?.map(user => (
<TableRow key={user.id}>
<TableCell>
<Avatar src={user.avatar} />
{user.displayName}
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
{user.mandateRoles.map(role => (
<RoleBadge key={role.id} role={role} />
))}
</TableCell>
<TableCell>
<Badge>{user.featureAccessCount} Feature-Instanzen</Badge>
</TableCell>
<TableCell>
<IconButton
icon="edit"
onClick={() => openUserRolesDialog(user, mandateId)}
title="Rollen bearbeiten"
/>
<IconButton
icon="remove_circle"
onClick={() => removeUserFromMandate(user.id, mandateId)}
title="Aus Mandant entfernen"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</AdminLayout>
);
}
```
#### 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<Role | null>(null);
return (
<AdminLayout title={`Rollen - ${mandate?.name}`}>
<InfoBanner type="info">
Diese Rollen gelten nur für den Mandanten "{mandate?.name}".
Für globale Template-Änderungen kontaktiere einen System-Administrator.
</InfoBanner>
<TwoColumnLayout>
{/* Linke Spalte: Rollen-Liste */}
<RoleList>
<Toolbar>
<Button onClick={openCreateRoleDialog}>
<Icon name="add" /> Neue Rolle
</Button>
<Button variant="secondary" onClick={syncFromTemplate}>
<Icon name="sync" /> Von Template synchronisieren
</Button>
</Toolbar>
<Tabs>
<Tab label="Mandanten-Rollen">
{roles?.filter(r => !r.featureInstanceId).map(role => (
<RoleListItem
key={role.id}
role={role}
selected={selectedRole?.id === role.id}
onClick={() => setSelectedRole(role)}
showOrigin // Zeigt "Kopiert von Template" Badge
/>
))}
</Tab>
<Tab label="Feature-Instanz-Rollen">
{groupBy(roles?.filter(r => r.featureInstanceId), 'featureInstanceId')
.map(([instanceId, instanceRoles]) => (
<Accordion key={instanceId} title={getInstanceLabel(instanceId)}>
{instanceRoles.map(role => (
<RoleListItem
key={role.id}
role={role}
selected={selectedRole?.id === role.id}
onClick={() => setSelectedRole(role)}
/>
))}
</Accordion>
))}
</Tab>
</Tabs>
</RoleList>
{/* Rechte Spalte: AccessRules Editor */}
{selectedRole && (
<AccessRulesEditor
roleId={selectedRole.id}
isTemplate={false}
onSave={refetchRoles}
/>
)}
</TwoColumnLayout>
</AdminLayout>
);
}
```
#### 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<File | null>(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 (
<AdminLayout title={`RBAC Export/Import - ${mandate?.name}`}>
<Grid columns={2} gap="lg">
{/* Export Panel */}
<Card>
<CardHeader>
<Icon name="file_download" />
Export
</CardHeader>
<CardContent>
<p>
Exportiert alle Rollen und Berechtigungen dieses Mandanten als JSON-Datei.
Feature-Instanz-spezifische Regeln werden ebenfalls exportiert.
</p>
<Button onClick={exportRbac}>
<Icon name="download" /> RBAC exportieren
</Button>
</CardContent>
</Card>
{/* Import Panel */}
<Card>
<CardHeader>
<Icon name="file_upload" />
Import
</CardHeader>
<CardContent>
<FileDropzone
accept=".json"
onDrop={files => setImportFile(files[0])}
>
{importFile ? (
<div>
<Icon name="description" />
{importFile.name}
<IconButton icon="close" onClick={() => setImportFile(null)} />
</div>
) : (
<p>JSON-Datei hier ablegen oder klicken</p>
)}
</FileDropzone>
<RadioGroup
label="Import-Modus"
value={importMode}
onChange={setImportMode}
>
<Radio value="merge">
<strong>Zusammenführen</strong>
<span>Bestehende Regeln aktualisieren, neue hinzufügen</span>
</Radio>
<Radio value="add_only">
<strong>Nur hinzufügen</strong>
<span>Nur neue Regeln hinzufügen, bestehende nicht ändern</span>
</Radio>
<Radio value="replace">
<strong>Ersetzen</strong>
<span className="text-warning">Alle bestehenden Regeln löschen und ersetzen</span>
</Radio>
</RadioGroup>
<Button
onClick={() => importRbac.mutate()}
disabled={!importFile}
loading={importRbac.isLoading}
>
<Icon name="upload" /> RBAC importieren
</Button>
</CardContent>
</Card>
</Grid>
</AdminLayout>
);
}
```
#### 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 (
<AdminLayout title={`Feature-Instanzen - ${mandate?.name}`}>
<Toolbar>
<Button onClick={openCreateInstanceDialog}>
<Icon name="add" /> Neue Feature-Instanz
</Button>
</Toolbar>
{/* Gruppiert nach Feature */}
{groupBy(instances, 'featureCode').map(([featureCode, featureInstances]) => {
const feature = availableFeatures?.find(f => f.code === featureCode);
return (
<Card key={featureCode}>
<CardHeader>
<Icon name={feature?.icon || 'extension'} />
<span>{feature?.label.de || featureCode}</span>
<Badge>{featureInstances.length} Instanzen</Badge>
</CardHeader>
<CardContent>
<Table compact>
<TableHeader>
<TableColumn>Label</TableColumn>
<TableColumn>Benutzer</TableColumn>
<TableColumn>Rollen</TableColumn>
<TableColumn>Erstellt</TableColumn>
<TableColumn>Aktionen</TableColumn>
</TableHeader>
<TableBody>
{featureInstances.map(instance => (
<TableRow key={instance.id}>
<TableCell>{instance.instanceLabel}</TableCell>
<TableCell>
<Badge>{instance.userCount} User</Badge>
</TableCell>
<TableCell>
<Badge>{instance.roleCount} Rollen</Badge>
</TableCell>
<TableCell>{formatDate(instance.createdAt)}</TableCell>
<TableCell>
<IconButton
icon="people"
onClick={() => openInstanceUsersDialog(instance)}
title="Benutzer verwalten"
/>
<IconButton
icon="security"
onClick={() => navigate(`/mandates/${mandateId}/admin/instances/${instance.id}/roles`)}
title="Rollen & Berechtigungen"
/>
<IconButton
icon="sync"
onClick={() => syncInstanceFromTemplate(instance.id)}
title="Von Template synchronisieren"
/>
<IconButton
icon="delete"
onClick={() => deleteInstance(instance)}
title="Instanz löschen"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
})}
</AdminLayout>
);
}
```
### 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<string[]>([]);
const [featureAccess, setFeatureAccess] = useState<FeatureAccessConfig[]>([]);
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 (
<Dialog open onClose={onClose} size="lg">
<DialogHeader>
<Avatar src={user.avatar} />
<div>
<h3>{user.displayName}</h3>
<span>{user.email}</span>
</div>
</DialogHeader>
<DialogContent>
{/* Mandanten-Rollen */}
<Section title="Mandanten-Rollen">
<CheckboxGroup
value={selectedMandateRoles}
onChange={setSelectedMandateRoles}
>
{availableRoles
?.filter(r => !r.featureInstanceId)
.map(role => (
<Checkbox key={role.id} value={role.id}>
<strong>{role.roleLabel}</strong>
<span>{role.description?.de}</span>
</Checkbox>
))}
</CheckboxGroup>
</Section>
{/* Feature-Instanz Zugriffe */}
<Section title="Feature-Instanz Zugriffe">
<FeatureAccessEditor
mandateId={mandateId}
value={featureAccess}
onChange={setFeatureAccess}
/>
</Section>
</DialogContent>
<DialogActions>
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
<Button onClick={() => saveMembership.mutate()}>Speichern</Button>
</DialogActions>
</Dialog>
);
}
// 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 (
<div>
{groupBy(instances, 'featureCode').map(([featureCode, featureInstances]) => (
<Accordion key={featureCode} title={featureCode}>
{featureInstances.map(instance => {
const access = value.find(a => a.instanceId === instance.id);
return (
<Card key={instance.id} variant="outlined">
<CardHeader>
<Checkbox
checked={!!access}
onChange={checked => toggleInstanceAccess(instance.id, checked)}
/>
<span>{instance.instanceLabel}</span>
</CardHeader>
{access && (
<CardContent>
<CheckboxGroup
value={access.roleIds}
onChange={roleIds => updateInstanceRoles(instance.id, roleIds)}
>
{instance.availableRoles.map(role => (
<Checkbox key={role.id} value={role.id}>
{role.roleLabel}
</Checkbox>
))}
</CheckboxGroup>
</CardContent>
)}
</Card>
);
})}
</Accordion>
))}
</div>
);
}
```
### 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 => (
<NavGroup key={`admin-${mandate.id}`}>
<NavGroupHeader>
<Icon name="admin_panel_settings" />
Administration: {mandate.name}
</NavGroupHeader>
<NavItem to={`/mandates/${mandate.id}/admin/users`}>
<Icon name="people" /> Benutzer
</NavItem>
<NavItem to={`/mandates/${mandate.id}/admin/roles`}>
<Icon name="security" /> Rollen
</NavItem>
<NavItem to={`/mandates/${mandate.id}/admin/instances`}>
<Icon name="extension" /> Feature-Instanzen
</NavItem>
<NavItem to={`/mandates/${mandate.id}/admin/invitations`}>
<Icon name="mail" /> Einladungen
</NavItem>
<NavItem to={`/mandates/${mandate.id}/admin/rbac-export`}>
<Icon name="import_export" /> RBAC Export/Import
</NavItem>
</NavGroup>
))}
{/* SysAdmin Sektion */}
{user?.isSysAdmin && (
<NavGroup>
<NavGroupHeader>
<Icon name="settings" />
System-Administration
</NavGroupHeader>
<NavItem to="/admin/mandates">
<Icon name="corporate_fare" /> Mandanten
</NavItem>
<NavItem to="/admin/users">
<Icon name="people" /> Alle Benutzer
</NavItem>
<NavItem to="/admin/roles">
<Icon name="security" /> Globale Rollen
</NavItem>
<NavItem to="/admin/rbac-templates">
<Icon name="copy_all" /> RBAC Templates
</NavItem>
<NavItem to="/admin/settings">
<Icon name="tune" /> System-Einstellungen
</NavItem>
</NavGroup>
)}
</>
);
}
```
### 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