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

61 KiB

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.

// 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.

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:

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):

// 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:

// 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:

// 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:

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

// 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

// 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.

// 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.

// 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>
  );
}
// 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

// 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:

// 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:

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

// 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:

// 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

// 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

// 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

// 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)

// 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

// 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

https://app.poweron.ch/invite?token=abc123xyz

9.2 Einladungs-Seite

// 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)

// 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

// 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)

// 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)

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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