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
9.1 Einladungs-Link
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
currentMandateIdaus globalem StatemandateIdaus 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+useCurrentInstanceumstellen - API-Calls: Instanz-ID aus URL-Parameter verwenden
11.3 Neu
FeatureStoremit allen Instanzen und PermissionsuseCurrentInstance()Hook (liest aus URL)FeatureNavGroup+InstanceNavSubgroupKomponentenPermissionGateWrapper- Einladungs-Flow UI