wiki/implementation/Saas Multi Tenant Mandate/ui_concept_nyla.md
2026-01-14 22:38:46 +01:00

27 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. Feature-Objekt-Struktur

2.1 Feature als UI-Gruppe

Jedes Feature ist ein Objekt, das im UI als Gruppe organisiert ist:

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

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

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

┌────────────────────────────────────────────────────────────┐
│  [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

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

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

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