1925 lines
61 KiB
Markdown
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
|