880 lines
27 KiB
Markdown
880 lines
27 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. Feature-Objekt-Struktur
|
|
|
|
### 2.1 Feature als UI-Gruppe
|
|
|
|
Jedes Feature ist ein **Objekt**, das im UI als Gruppe organisiert ist:
|
|
|
|
```typescript
|
|
interface Feature {
|
|
code: string; // "trustee", "chatbot", "crm"
|
|
label: I18nLabel; // { en: "Trustee", de: "Treuhand" }
|
|
icon: string; // Material Icon Name
|
|
instances: FeatureInstance[];
|
|
}
|
|
|
|
interface FeatureInstance {
|
|
id: string; // UUID der Instanz
|
|
featureCode: string; // "trustee"
|
|
mandateId: string; // Referenz (für Backend)
|
|
mandateName: string; // "Soha Treuhand" (für Anzeige)
|
|
instanceLabel: string; // "PamoCreate AG" (spezifischer Name)
|
|
userRole: string; // Rolle des Users in dieser Instanz
|
|
permissions: InstancePermissions; // Summarische Berechtigungen
|
|
}
|
|
```
|
|
|
|
### 2.2 Generisches Instanz-Handling
|
|
|
|
Das Handling für Feature-Instanzen ist **generisch**. Die aktuelle Instanz ergibt sich aus der **URL** (Route-Parameter):
|
|
|
|
```typescript
|
|
// stores/featureStore.ts
|
|
|
|
interface FeatureState {
|
|
features: Feature[];
|
|
|
|
// Actions
|
|
loadFeatures: () => Promise<void>;
|
|
getInstanceById: (instanceId: string) => FeatureInstance | undefined;
|
|
getFeatureByCode: (featureCode: string) => Feature | undefined;
|
|
}
|
|
|
|
const useFeatureStore = create<FeatureState>((set, get) => ({
|
|
features: [],
|
|
|
|
loadFeatures: async () => {
|
|
// Ein API-Call lädt alle Features + Instanzen + Permissions
|
|
const response = await api.get('/features/my');
|
|
set({ features: response.data });
|
|
},
|
|
|
|
getInstanceById: (instanceId) => {
|
|
return get().features
|
|
.flatMap(f => f.instances)
|
|
.find(i => i.id === instanceId);
|
|
},
|
|
|
|
getFeatureByCode: (featureCode) => {
|
|
return get().features.find(f => f.code === featureCode);
|
|
}
|
|
}));
|
|
```
|
|
|
|
### 2.3 Instanz aus URL-Parameter
|
|
|
|
Die aktuelle Instanz wird **nicht** im Store gespeichert, sondern aus der URL gelesen:
|
|
|
|
```typescript
|
|
// hooks/useCurrentInstance.ts
|
|
|
|
export function useCurrentInstance(): FeatureInstance | undefined {
|
|
const { instanceId } = useParams<{ instanceId: string }>();
|
|
const getInstanceById = useFeatureStore(s => s.getInstanceById);
|
|
|
|
return instanceId ? getInstanceById(instanceId) : undefined;
|
|
}
|
|
|
|
// Verwendung in Komponenten:
|
|
function ContractList() {
|
|
const instance = useCurrentInstance();
|
|
|
|
if (!instance) {
|
|
return <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
|
|
|
|
```
|
|
┌────────────────────────────────────────────────────────────┐
|
|
│ [Logo] PowerOn [User] [Logout] │
|
|
├────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ SYSTEM │
|
|
│ ○ Dashboard │
|
|
│ ○ Profil │
|
|
│ ○ Einstellungen │
|
|
│ │
|
|
│ ▼ TRUSTEE [Feature] │
|
|
│ │ │
|
|
│ ├─▼ Soha Treuhand / PamoCreate AG [Instanz] │
|
|
│ │ ○ Übersicht │
|
|
│ │ ○ Verträge │
|
|
│ │ ○ Dokumente │
|
|
│ │ ○ Positionen │
|
|
│ │ │
|
|
│ ├─▼ Soha Treuhand / ValueOn AG [Instanz] │
|
|
│ │ ○ Übersicht │
|
|
│ │ ○ Verträge │
|
|
│ │ ○ Dokumente │
|
|
│ │ │
|
|
│ └─▶ SwissTreu / Firma X [Instanz] │
|
|
│ (collapsed) │
|
|
│ │
|
|
│ ▼ CHATBOT [Feature] │
|
|
│ │ │
|
|
│ └─▼ Althaus / Management-Tool [Instanz] │
|
|
│ ○ Conversations │
|
|
│ ○ Settings │
|
|
│ │
|
|
│ ───────────────────────────────────── │
|
|
│ ADMIN (nur wenn sysadmin) │
|
|
│ ○ Mandanten │
|
|
│ ○ Users │
|
|
│ ○ RBAC │
|
|
│ │
|
|
└────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
**Hierarchie:**
|
|
```
|
|
Feature (Gruppe)
|
|
└─ Instanz (Subgruppe)
|
|
└─ Objekte/Views (Navigation Items)
|
|
```
|
|
```
|
|
|
|
### 4.2 Feature-Gruppe mit Instanz-Subgruppen
|
|
|
|
Jedes Feature zeigt **alle Instanzen als Subgruppen** an:
|
|
|
|
```tsx
|
|
// components/FeatureNavGroup.tsx
|
|
|
|
function FeatureNavGroup({ featureCode }: { featureCode: string }) {
|
|
const { features } = useFeatureStore();
|
|
const feature = features.find(f => f.code === featureCode);
|
|
|
|
if (!feature || feature.instances.length === 0) {
|
|
return null; // Feature nicht anzeigen wenn keine Instanzen
|
|
}
|
|
|
|
return (
|
|
<NavGroup>
|
|
{/* Feature als Hauptgruppe */}
|
|
<NavGroupHeader collapsible>
|
|
<Icon name={feature.icon} />
|
|
<span>{feature.label.de}</span>
|
|
</NavGroupHeader>
|
|
|
|
{/* Jede Instanz als Subgruppe */}
|
|
{feature.instances.map(instance => (
|
|
<InstanceNavSubgroup
|
|
key={instance.id}
|
|
instance={instance}
|
|
featureCode={featureCode}
|
|
/>
|
|
))}
|
|
</NavGroup>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 4.3 Instanz-Subgruppe Komponente
|
|
|
|
```tsx
|
|
// components/InstanceNavSubgroup.tsx
|
|
|
|
function InstanceNavSubgroup({ instance, featureCode }: {
|
|
instance: FeatureInstance;
|
|
featureCode: string;
|
|
}) {
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
|
|
return (
|
|
<NavSubgroup>
|
|
{/* Instanz als Subgruppen-Header */}
|
|
<NavSubgroupHeader
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
collapsible
|
|
>
|
|
<Icon name={isExpanded ? 'expand_more' : 'chevron_right'} />
|
|
<span>{instance.mandateName} / {instance.instanceLabel}</span>
|
|
<RoleBadge role={instance.userRole} />
|
|
</NavSubgroupHeader>
|
|
|
|
{/* Objekte/Views der Instanz */}
|
|
{isExpanded && (
|
|
<NavSubgroupItems>
|
|
{instance.permissions.views[`${featureCode}-dashboard`] && (
|
|
<NavItem to={`/${featureCode}/${instance.id}/dashboard`}>
|
|
Übersicht
|
|
</NavItem>
|
|
)}
|
|
{instance.permissions.views[`${featureCode}-contracts`] && (
|
|
<NavItem to={`/${featureCode}/${instance.id}/contracts`}>
|
|
Verträge
|
|
</NavItem>
|
|
)}
|
|
{instance.permissions.views[`${featureCode}-documents`] && (
|
|
<NavItem to={`/${featureCode}/${instance.id}/documents`}>
|
|
Dokumente
|
|
</NavItem>
|
|
)}
|
|
{instance.permissions.views[`${featureCode}-positions`] && (
|
|
<NavItem to={`/${featureCode}/${instance.id}/positions`}>
|
|
Positionen
|
|
</NavItem>
|
|
)}
|
|
</NavSubgroupItems>
|
|
)}
|
|
</NavSubgroup>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 4.4 URL-Struktur mit Instanz-ID
|
|
|
|
Die URL enthält immer die **Instanz-ID**, damit klar ist, in welcher Instanz gearbeitet wird:
|
|
|
|
```
|
|
/trustee/{instanceId}/dashboard
|
|
/trustee/{instanceId}/contracts
|
|
/trustee/{instanceId}/contracts/{contractId}
|
|
/trustee/{instanceId}/documents
|
|
|
|
/chatbot/{instanceId}/conversations
|
|
/chatbot/{instanceId}/settings
|
|
```
|
|
|
|
**Router-Setup:**
|
|
```tsx
|
|
// routes.tsx
|
|
|
|
<Route path="/: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. Zusammenfassung
|
|
|
|
### 10.1 Wichtige Prinzipien
|
|
|
|
| Prinzip | Umsetzung |
|
|
|---------|-----------|
|
|
| **Kein mandateId** | User gehört keinem Mandanten, arbeitet in Feature-Instanzen |
|
|
| **Summarische Permissions** | Ein Request lädt alle Berechtigungen pro Instanz |
|
|
| **Generisches Feature-Handling** | Alle Features haben gleiche Struktur |
|
|
| **Instanz aus URL** | `/feature/{instanceId}/...` - Instanz-ID immer in URL |
|
|
| **Navigation = Hierarchie** | Feature → Instanz (Subgruppe) → Objekte |
|
|
| **System-Features immer verfügbar** | Profil, Settings, Logout ohne Instanz |
|
|
|
|
### 10.2 State-Architektur
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ Frontend State │
|
|
├─────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ AuthStore │
|
|
│ ├─ user: User │
|
|
│ ├─ token: string │
|
|
│ └─ isAuthenticated: boolean │
|
|
│ │
|
|
│ FeatureStore │
|
|
│ ├─ features: Feature[] │
|
|
│ │ └─ instances: FeatureInstance[] │
|
|
│ │ └─ permissions: InstancePermissions │
|
|
│ └─ getInstanceById(id): FeatureInstance │
|
|
│ │
|
|
│ Router (URL) │
|
|
│ └─ /:featureCode/:instanceId/* → aktuelle Instanz │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### 10.3 API-Endpunkte (Frontend-relevant)
|
|
|
|
| Endpunkt | Beschreibung |
|
|
|----------|--------------|
|
|
| `GET /features/my` | Alle Features + Instanzen + Permissions |
|
|
| `GET /invite/validate?token=X` | Einladung validieren |
|
|
| `POST /invite/accept` | Einladung annehmen |
|
|
| `GET /admin/invitations` | Alle Einladungen (Admin) |
|
|
| `DELETE /admin/invitations/{token}` | Einladung widerrufen |
|
|
|
|
---
|
|
|
|
## 11. Migration (Frontend)
|
|
|
|
### 11.1 Zu entfernen
|
|
|
|
- `currentMandateId` aus globalem State
|
|
- `mandateId` aus API-Requests (Query-Params)
|
|
- Mandanten-Switcher
|
|
|
|
### 11.2 Zu ändern
|
|
|
|
- **Navigation:** Feature → Instanz-Subgruppen → Objekte (hierarchisch)
|
|
- **URLs:** Müssen `/:featureCode/:instanceId/...` enthalten
|
|
- **Permission-Checks:** Auf `useTablePermission` + `useCurrentInstance` umstellen
|
|
- **API-Calls:** Instanz-ID aus URL-Parameter verwenden
|
|
|
|
### 11.3 Neu
|
|
|
|
- `FeatureStore` mit allen Instanzen und Permissions
|
|
- `useCurrentInstance()` Hook (liest aus URL)
|
|
- `FeatureNavGroup` + `InstanceNavSubgroup` Komponenten
|
|
- `PermissionGate` Wrapper
|
|
- Einladungs-Flow UI
|