tenant concepts
This commit is contained in:
parent
cc4953da50
commit
d8d692200e
3 changed files with 2316 additions and 0 deletions
|
|
@ -0,0 +1,827 @@
|
|||
# Multi-Tenant SaaS Plattform - Implementierungskonzept
|
||||
|
||||
## Technische Umsetzung für PowerOn Gateway
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 14. Januar 2026
|
||||
**Status:** Entwurf
|
||||
**Basis:** [mandate_concept.md](./mandate_concept.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Übersicht
|
||||
|
||||
### 1.1 Grundprinzip
|
||||
|
||||
**Kernänderung:** User gehören grundsätzlich **keinem** Mandanten an. Die Zugehörigkeit wird über eine separate Membership-Tabelle (`UserMandate`) geregelt.
|
||||
|
||||
```
|
||||
AKTUELL (IST):
|
||||
User ──── mandateId ──→ Mandate (1:1)
|
||||
|
||||
NEU (SOLL):
|
||||
User ◄──── UserMandate ────► Mandate (m:n)
|
||||
└─ roleLabels (mandantsspezifisch)
|
||||
```
|
||||
|
||||
### 1.2 Konsequenzen
|
||||
|
||||
| Aspekt | Aktuell | Neu |
|
||||
|--------|---------|-----|
|
||||
| User.mandateId | Pflichtfeld | Optional/Entfällt |
|
||||
| Rollen | Global pro User | Pro Membership |
|
||||
| Auth-Token | Enthält mandateId | Enthält nur userId |
|
||||
| API-Calls | Implizit aus Token | Explizit pro Request |
|
||||
| RBAC GROUP | User.mandateId | Request-Parameter |
|
||||
|
||||
---
|
||||
|
||||
## 2. Feature-Analyse (Code-Review)
|
||||
|
||||
### 2.1 Identifizierte Business-Features
|
||||
|
||||
Basierend auf Code-Analyse (`gateway/modules/`) wurden folgende Features identifiziert, die in das neue Multi-Tenant-Modell überführt werden müssen:
|
||||
|
||||
| Feature-Code | Name | Datenmodelle | Routes | Feature-Code |
|
||||
|--------------|------|--------------|--------|--------------|
|
||||
| `trustee` | **Treuhand** | `TrusteeOrganisation`, `TrusteeRole`, `TrusteeAccess`, `TrusteeContract`, `TrusteeDocument`, `TrusteePosition` | `routeDataTrustee.py` | `interfaceDbTrusteeObjects.py` |
|
||||
| `chatbot` | **Chatbot** | `ChatWorkflow` (mode=Chatbot), `ChatMessage`, `ChatDocument` | `routeChatbot.py` | `features/chatbot/` |
|
||||
| `workflow-dynamic` | **Dynamic Workflow** | `ChatWorkflow` (mode=Dynamic), `ChatMessage`, `ChatDocument`, `ChatLog`, `ChatStat` | `routeChatPlayground.py`, `routeWorkflows.py` | `features/workflow/` + `WorkflowManager` |
|
||||
| `workflow-automation` | **Automation** | `AutomationDefinition`, `ChatWorkflow` (mode=Automation) | `routeDataAutomation.py`, `routeAdminAutomationEvents.py` | `features/workflow/` + `WorkflowManager` |
|
||||
| `voice-center` | **Voice Center** | `VoiceSettings` | `routeVoiceGoogle.py` | `interfaceVoiceObjects.py` |
|
||||
| `realestate` | **Immobilien** | `Projekt`, `Parzelle`, `Land`, `Kanton`, `Gemeinde`, `Dokument` | `routeRealEstate.py` | `interfaceDbRealEstateObjects.py` |
|
||||
|
||||
### 2.2 Workflow-Modes und Feature-Zuordnung
|
||||
|
||||
Der `WorkflowModeEnum` definiert verschiedene Modi:
|
||||
|
||||
```python
|
||||
# datamodelChat.py - Zeile 277-281
|
||||
class WorkflowModeEnum(str, Enum):
|
||||
WORKFLOW_DYNAMIC = "Dynamic" # → Feature: workflow-dynamic (WorkflowManager)
|
||||
WORKFLOW_AUTOMATION = "Automation" # → Feature: workflow-automation (WorkflowManager + Templates)
|
||||
WORKFLOW_CHATBOT = "Chatbot" # → Feature: chatbot (eigener Code in features/chatbot/)
|
||||
WORKFLOW_REACT = "React" # Legacy - kann entfallen
|
||||
```
|
||||
|
||||
**Code-Struktur:**
|
||||
|
||||
| Feature | Code-Basis | Beschreibung |
|
||||
|---------|-----------|--------------|
|
||||
| `chatbot` | `features/chatbot/mainChatbot.py` → `chatProcess()` | Einfacher Chat mit direkten AI-Calls |
|
||||
| `workflow-dynamic` | `features/workflow/mainWorkflow.py` → `chatStart()` | Interaktiver KI-Workflow mit Task-Planning |
|
||||
| `workflow-automation` | `features/workflow/mainWorkflow.py` → `executeAutomation()` | Zeitgesteuerte Workflows mit Templates |
|
||||
|
||||
**Hinweis:** `routeChatPlayground.py` ist **keine eigenes Feature**, sondern eine Test-Route, die sowohl `workflow-dynamic` als auch `workflow-automation` aufrufen kann (via Query-Parameter `workflowMode`).
|
||||
|
||||
**Begründung für Feature-Trennung:**
|
||||
- Unterschiedliche Berechtigungen pro Mode möglich
|
||||
- Unterschiedliche Instanzen pro Mandant (z.B. Mandant A hat Automation, Mandant B nicht)
|
||||
- Unterschiedliche Pricing-Modelle
|
||||
|
||||
### 2.3 System-Features (ohne Mandant)
|
||||
|
||||
Diese Features sind **global** und erfordern **keinen Mandanten**:
|
||||
|
||||
| Feature | Beschreibung | Routes |
|
||||
|---------|--------------|--------|
|
||||
| `user-profile` | Eigenes Profil bearbeiten | `routeDataUsers.py` |
|
||||
| `user-settings` | Persönliche Einstellungen | `routeOptions.py` |
|
||||
| `file-management` | Dateiverwaltung (mandantenübergreifend) | `routeDataFiles.py` |
|
||||
| `connections` | Verbindungen (OAuth, APIs) | `routeDataConnections.py` |
|
||||
|
||||
### 2.4 Admin-Features (nur sysadmin)
|
||||
|
||||
Diese Features sind nur für System-Administratoren:
|
||||
|
||||
| Feature | Beschreibung | Routes |
|
||||
|---------|--------------|--------|
|
||||
| `admin-mandates` | Mandanten verwalten | `routeDataMandates.py` |
|
||||
| `admin-users` | Alle User verwalten | `routeDataUsers.py` |
|
||||
| `admin-rbac` | RBAC-Regeln verwalten | `routeRbac.py`, `routeAdminRbacRoles.py` |
|
||||
| `admin-security` | Security-Einstellungen | `routeSecurityAdmin.py` |
|
||||
|
||||
### 2.5 Mandant-spezifische Felder in Datamodels
|
||||
|
||||
Alle Business-Feature-Models haben ein `mandateId`-Feld:
|
||||
|
||||
```python
|
||||
# Beispiele aus dem Code:
|
||||
|
||||
# datamodelChat.py - ChatWorkflow
|
||||
mandateId: str = Field(description="ID of the mandate this workflow belongs to")
|
||||
|
||||
# datamodelVoice.py - VoiceSettings
|
||||
mandateId: str = Field(description="ID of the mandate these settings belong to")
|
||||
|
||||
# datamodelTrustee.py - TrusteeOrganisation
|
||||
mandateId: Optional[str] = Field(default=None, description="Mandate ID")
|
||||
|
||||
# datamodelRealEstate.py - Projekt, Parzelle, etc.
|
||||
mandateId: str = Field(description="ID of the mandate")
|
||||
```
|
||||
|
||||
### 2.6 Zu ergänzen: Feature-Definition Model
|
||||
|
||||
Neues Model für Feature-Registrierung:
|
||||
|
||||
```python
|
||||
# modules/datamodels/datamodelFeatures.py (NEU)
|
||||
|
||||
class FeatureDefinition(BaseModel):
|
||||
"""Definition eines Features im System."""
|
||||
code: str = Field(description="Eindeutiger Feature-Code (z.B. 'trustee')")
|
||||
label: Dict[str, str] = Field(description="I18n-Labels {'en': '...', 'de': '...'}")
|
||||
icon: str = Field(description="Material Icon Name")
|
||||
category: str = Field(description="Kategorie: 'business', 'system', 'admin'")
|
||||
tables: List[str] = Field(description="Zugehörige DB-Tabellen")
|
||||
routes: List[str] = Field(description="API-Route-Prefixe")
|
||||
defaultRoles: List[str] = Field(description="Standard-Rollen für dieses Feature")
|
||||
enabled: bool = Field(default=True)
|
||||
|
||||
|
||||
class FeatureInstance(BaseModel):
|
||||
"""Instanz eines Features für einen Mandanten."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
featureCode: str = Field(description="Referenz auf FeatureDefinition.code")
|
||||
mandateId: str = Field(description="Referenz auf Mandate.id")
|
||||
instanceLabel: str = Field(description="Instanz-Name (z.B. 'Buchhaltung 2025')")
|
||||
enabled: bool = Field(default=True)
|
||||
settings: Dict[str, Any] = Field(default_factory=dict, description="Feature-spezifische Einstellungen")
|
||||
|
||||
|
||||
class FeatureAccess(BaseModel):
|
||||
"""Zugriff eines Users auf eine Feature-Instanz."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
userId: str = Field(description="Referenz auf User.id")
|
||||
featureInstanceId: str = Field(description="Referenz auf FeatureInstance.id")
|
||||
roleLabel: str = Field(description="Rolle in dieser Feature-Instanz")
|
||||
enabled: bool = Field(default=True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Datenmodell-Änderungen
|
||||
|
||||
### 3.1 Neues Model: `UserMandate`
|
||||
|
||||
```python
|
||||
# modules/datamodels/datamodelUam.py
|
||||
|
||||
class UserMandate(BaseModel):
|
||||
"""Membership: Verknüpft User mit Mandanten und deren Rollen."""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
userId: str = Field(description="Referenz auf User.id")
|
||||
mandateId: str = Field(description="Referenz auf Mandate.id")
|
||||
roleLabels: List[str] = Field(
|
||||
default_factory=lambda: ["user"],
|
||||
description="Rollen des Users in diesem Mandanten"
|
||||
)
|
||||
enabled: bool = Field(default=True)
|
||||
invitedBy: Optional[str] = Field(None, description="User-ID des Einladenden")
|
||||
invitedAt: Optional[float] = Field(None, description="Zeitpunkt der Einladung")
|
||||
acceptedAt: Optional[float] = Field(None, description="Zeitpunkt der Annahme")
|
||||
# System-Felder: _createdAt, _modifiedAt, _createdBy, _modifiedBy
|
||||
```
|
||||
|
||||
### 3.2 Änderung: `User` Model
|
||||
|
||||
```python
|
||||
# modules/datamodels/datamodelUam.py
|
||||
|
||||
class User(BaseModel):
|
||||
id: str = Field(...)
|
||||
username: str = Field(...)
|
||||
email: Optional[EmailStr] = Field(None)
|
||||
fullName: Optional[str] = Field(None)
|
||||
language: str = Field(default="en")
|
||||
enabled: bool = Field(default=True)
|
||||
# ENTFERNT: mandateId - User gehören keinem Mandanten
|
||||
# ENTFERNT: roleLabels - Rollen sind mandantsspezifisch
|
||||
authenticationAuthority: AuthAuthority = Field(default=AuthAuthority.LOCAL)
|
||||
|
||||
# NEU: Transiente Felder (nicht in DB, werden zur Laufzeit befüllt)
|
||||
currentMandateId: Optional[str] = Field(None, exclude=True)
|
||||
currentRoleLabels: List[str] = Field(default_factory=list, exclude=True)
|
||||
```
|
||||
|
||||
### 3.3 Änderung: `AccessRule` Model
|
||||
|
||||
```python
|
||||
# modules/datamodels/datamodelRbac.py
|
||||
|
||||
class AccessRule(BaseModel):
|
||||
id: str = Field(...)
|
||||
roleLabel: str = Field(...)
|
||||
context: AccessRuleContext = Field(...)
|
||||
item: Optional[str] = Field(None)
|
||||
view: bool = Field(False)
|
||||
read: Optional[AccessLevel] = Field(None)
|
||||
create: Optional[AccessLevel] = Field(None)
|
||||
update: Optional[AccessLevel] = Field(None)
|
||||
delete: Optional[AccessLevel] = Field(None)
|
||||
# NEU: Mandant-spezifische Regeln
|
||||
mandateId: Optional[str] = Field(
|
||||
None,
|
||||
description="Wenn gesetzt, gilt Regel nur für diesen Mandanten. None = Global."
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Authentifizierung & Kontext
|
||||
|
||||
### 4.1 JWT Token (vereinfacht)
|
||||
|
||||
**Aktuell:**
|
||||
```json
|
||||
{
|
||||
"sub": "admin",
|
||||
"userId": "uuid-123",
|
||||
"mandateId": "uuid-456",
|
||||
"exp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
**Neu:**
|
||||
```json
|
||||
{
|
||||
"sub": "admin",
|
||||
"userId": "uuid-123",
|
||||
"exp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
Der Token enthält **kein mandateId** mehr, da der User keinem Mandanten angehört.
|
||||
|
||||
### 4.2 Mandanten-Kontext per Request
|
||||
|
||||
Der Mandanten-Kontext wird **pro API-Call** bestimmt:
|
||||
|
||||
**Option A: Query-Parameter**
|
||||
```
|
||||
GET /api/trustee/organisations?mandateId=uuid-456
|
||||
```
|
||||
|
||||
**Option B: Header**
|
||||
```
|
||||
X-Mandate-Id: uuid-456
|
||||
```
|
||||
|
||||
**Option C: Implizit aus Daten**
|
||||
```
|
||||
POST /api/trustee/contracts
|
||||
Body: { "organisationId": "org-123" }
|
||||
→ Backend ermittelt mandateId aus Organisation
|
||||
```
|
||||
|
||||
### 4.3 Authentifizierung Flow
|
||||
|
||||
```
|
||||
1. Login
|
||||
└─ User authentifiziert sich
|
||||
└─ Token enthält nur userId
|
||||
└─ Response enthält Liste der Memberships
|
||||
|
||||
2. API-Call
|
||||
└─ Token wird validiert (userId)
|
||||
└─ mandateId wird aus Request extrahiert
|
||||
└─ Membership wird geprüft (userId + mandateId)
|
||||
└─ Rollen werden aus UserMandate geladen
|
||||
└─ RBAC wird mit diesen Rollen ausgeführt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. RBAC-Anpassungen
|
||||
|
||||
### 5.1 Regel-Auflösung (Mandant-aware)
|
||||
|
||||
```python
|
||||
# modules/security/rbac.py
|
||||
|
||||
def _getRulesForRole(self, roleLabel: str, context: AccessRuleContext, mandateId: Optional[str] = None) -> List[AccessRule]:
|
||||
"""
|
||||
Lädt Regeln mit Mandant-Priorisierung:
|
||||
1. Mandant-spezifische Regeln (mandateId = X)
|
||||
2. Globale Regeln (mandateId = None)
|
||||
|
||||
Spezifische überschreiben globale.
|
||||
"""
|
||||
allRules = self.dbApp.getRecordset(
|
||||
AccessRule,
|
||||
recordFilter={"roleLabel": roleLabel, "context": context.value}
|
||||
)
|
||||
|
||||
# Sortiere: mandateId-spezifisch vor global
|
||||
mandateRules = [r for r in allRules if r.get("mandateId") == mandateId]
|
||||
globalRules = [r for r in allRules if r.get("mandateId") is None]
|
||||
|
||||
# Spezifische haben Vorrang
|
||||
rulesByItem = {}
|
||||
for rule in globalRules:
|
||||
rulesByItem[rule.get("item")] = rule
|
||||
for rule in mandateRules:
|
||||
rulesByItem[rule.get("item")] = rule # Überschreibt global
|
||||
|
||||
return list(rulesByItem.values())
|
||||
```
|
||||
|
||||
### 5.2 GROUP-Filter anpassen
|
||||
|
||||
```python
|
||||
# modules/interfaces/interfaceRbac.py
|
||||
|
||||
def buildRbacWhereClause(
|
||||
permissions: UserPermissions,
|
||||
currentUser: User,
|
||||
table: str,
|
||||
connector,
|
||||
mandateId: str # NEU: Expliziter Parameter
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
GROUP filtert jetzt nach übergebenem mandateId,
|
||||
nicht mehr nach currentUser.mandateId.
|
||||
"""
|
||||
if permissions.read == AccessLevel.GROUP:
|
||||
if not mandateId:
|
||||
return {"condition": "1 = 0", "values": []}
|
||||
|
||||
return {
|
||||
"condition": '"mandateId" = %s',
|
||||
"values": [mandateId]
|
||||
}
|
||||
# ... rest bleibt gleich
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Interface-Anpassungen
|
||||
|
||||
### 6.1 Kontext ohne Pflicht-Mandat
|
||||
|
||||
```python
|
||||
# modules/interfaces/interfaceDbAppObjects.py
|
||||
|
||||
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None):
|
||||
"""
|
||||
Setzt User-Kontext. mandateId ist optional.
|
||||
Wenn mandateId gesetzt, wird Membership geprüft.
|
||||
"""
|
||||
self.currentUser = currentUser
|
||||
self.userId = currentUser.id
|
||||
self.currentMandateId = mandateId
|
||||
|
||||
if mandateId:
|
||||
# Membership prüfen und Rollen laden
|
||||
membership = self.getUserMandate(currentUser.id, mandateId)
|
||||
if not membership:
|
||||
raise PermissionError(f"User {currentUser.id} ist nicht Mitglied in Mandant {mandateId}")
|
||||
|
||||
# Rollen aus Membership für RBAC verwenden
|
||||
currentUser.currentRoleLabels = membership.roleLabels
|
||||
else:
|
||||
# Ohne Mandat: System-Rollen (sysadmin) oder leer
|
||||
currentUser.currentRoleLabels = ["sysadmin"] if self._isSysAdmin(currentUser) else []
|
||||
```
|
||||
|
||||
### 6.2 Neue Membership-Methoden
|
||||
|
||||
```python
|
||||
# modules/interfaces/interfaceDbAppObjects.py
|
||||
|
||||
def getUserMandates(self, userId: str) -> List[UserMandate]:
|
||||
"""Alle Mandanten-Mitgliedschaften eines Users."""
|
||||
records = self.db.getRecordset(UserMandate, recordFilter={"userId": userId})
|
||||
return [UserMandate(**r) for r in records]
|
||||
|
||||
def getUserMandate(self, userId: str, mandateId: str) -> Optional[UserMandate]:
|
||||
"""Spezifische Mitgliedschaft prüfen."""
|
||||
records = self.db.getRecordset(
|
||||
UserMandate,
|
||||
recordFilter={"userId": userId, "mandateId": mandateId}
|
||||
)
|
||||
return UserMandate(**records[0]) if records else None
|
||||
|
||||
def getMandateMembers(self, mandateId: str) -> List[UserMandate]:
|
||||
"""Alle Mitglieder eines Mandanten."""
|
||||
records = self.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
|
||||
return [UserMandate(**r) for r in records]
|
||||
|
||||
def addUserToMandate(self, userId: str, mandateId: str, roleLabels: List[str], invitedBy: str) -> UserMandate:
|
||||
"""Fügt User zu Mandant hinzu."""
|
||||
membership = UserMandate(
|
||||
userId=userId,
|
||||
mandateId=mandateId,
|
||||
roleLabels=roleLabels,
|
||||
invitedBy=invitedBy,
|
||||
invitedAt=getUtcTimestamp()
|
||||
)
|
||||
result = self.db.recordCreate(UserMandate, membership)
|
||||
return UserMandate(**result)
|
||||
|
||||
def removeUserFromMandate(self, userId: str, mandateId: str) -> bool:
|
||||
"""Entfernt User aus Mandant."""
|
||||
memberships = self.db.getRecordset(
|
||||
UserMandate,
|
||||
recordFilter={"userId": userId, "mandateId": mandateId}
|
||||
)
|
||||
if memberships:
|
||||
return self.db.recordDelete(UserMandate, memberships[0]["id"])
|
||||
return False
|
||||
|
||||
def updateMembershipRoles(self, membershipId: str, roleLabels: List[str]) -> UserMandate:
|
||||
"""Aktualisiert Rollen einer Mitgliedschaft."""
|
||||
result = self.db.recordModify(UserMandate, membershipId, {"roleLabels": roleLabels})
|
||||
return UserMandate(**result)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. API-Routen
|
||||
|
||||
### 6.1 Neue Membership-Routen
|
||||
|
||||
```python
|
||||
# modules/routes/routeMandateMemberships.py
|
||||
|
||||
router = APIRouter(prefix="/api/mandates", tags=["Mandate Memberships"])
|
||||
|
||||
@router.get("/my", response_model=List[MandateMembershipResponse])
|
||||
async def getMyMandates(currentUser: User = Depends(getCurrentUser)):
|
||||
"""Alle Mandanten des eingeloggten Users."""
|
||||
interface = getInterface(currentUser)
|
||||
memberships = interface.getUserMandates(currentUser.id)
|
||||
|
||||
result = []
|
||||
for m in memberships:
|
||||
mandate = interface.getMandate(m.mandateId)
|
||||
result.append({
|
||||
"membershipId": m.id,
|
||||
"mandateId": m.mandateId,
|
||||
"mandateName": mandate.name if mandate else "Unknown",
|
||||
"roleLabels": m.roleLabels,
|
||||
"enabled": m.enabled
|
||||
})
|
||||
return result
|
||||
|
||||
@router.get("/{mandateId}/members", response_model=List[MemberResponse])
|
||||
async def getMandateMembers(
|
||||
mandateId: str,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
):
|
||||
"""Alle Mitglieder eines Mandanten (nur für Admins)."""
|
||||
interface = getInterface(currentUser, mandateId)
|
||||
# Prüft automatisch Membership und admin-Rolle
|
||||
return interface.getMandateMembers(mandateId)
|
||||
|
||||
@router.post("/{mandateId}/members")
|
||||
async def addMember(
|
||||
mandateId: str,
|
||||
data: AddMemberRequest,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
):
|
||||
"""Fügt User zu Mandant hinzu (nur sysadmin oder mandant-admin)."""
|
||||
interface = getInterface(currentUser, mandateId)
|
||||
return interface.addUserToMandate(
|
||||
userId=data.userId,
|
||||
mandateId=mandateId,
|
||||
roleLabels=data.roleLabels,
|
||||
invitedBy=currentUser.id
|
||||
)
|
||||
|
||||
@router.delete("/{mandateId}/members/{userId}")
|
||||
async def removeMember(
|
||||
mandateId: str,
|
||||
userId: str,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
):
|
||||
"""Entfernt User aus Mandant."""
|
||||
interface = getInterface(currentUser, mandateId)
|
||||
return interface.removeUserFromMandate(userId, mandateId)
|
||||
|
||||
@router.put("/{mandateId}/members/{userId}/roles")
|
||||
async def updateMemberRoles(
|
||||
mandateId: str,
|
||||
userId: str,
|
||||
data: UpdateRolesRequest,
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
):
|
||||
"""Aktualisiert Rollen eines Mitglieds."""
|
||||
interface = getInterface(currentUser, mandateId)
|
||||
membership = interface.getUserMandate(userId, mandateId)
|
||||
return interface.updateMembershipRoles(membership.id, data.roleLabels)
|
||||
```
|
||||
|
||||
### 7.2 Anpassung bestehender Routen
|
||||
|
||||
```python
|
||||
# modules/routes/routeDataUsers.py
|
||||
|
||||
@router.get("/")
|
||||
async def getUsers(
|
||||
mandateId: Optional[str] = Query(None), # Explizit, nicht aus Token
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
):
|
||||
"""
|
||||
Users abrufen.
|
||||
- Ohne mandateId: Nur sysadmin sieht alle
|
||||
- Mit mandateId: Mitglieder des Mandanten
|
||||
"""
|
||||
if mandateId:
|
||||
interface = getInterface(currentUser, mandateId)
|
||||
memberships = interface.getMandateMembers(mandateId)
|
||||
userIds = [m.userId for m in memberships]
|
||||
return interface.getUsersByIds(userIds)
|
||||
else:
|
||||
# Nur sysadmin
|
||||
if not _isSysAdmin(currentUser):
|
||||
raise HTTPException(403, "mandateId required for non-sysadmin")
|
||||
interface = getInterface(currentUser)
|
||||
return interface.getAllUsers()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. RBAC Import/Export
|
||||
|
||||
### 8.1 Export-Format (JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"exportedAt": "2026-01-14T10:00:00Z",
|
||||
"exportedBy": "admin",
|
||||
"scope": {
|
||||
"mandateId": "uuid-456",
|
||||
"description": "Althaus Consulting - Custom Policies"
|
||||
},
|
||||
"roles": [
|
||||
{
|
||||
"roleLabel": "trustee-admin",
|
||||
"description": {"en": "Trustee Administrator", "de": "Treuhand-Administrator"},
|
||||
"isSystemRole": false
|
||||
}
|
||||
],
|
||||
"rules": [
|
||||
{
|
||||
"roleLabel": "trustee-admin",
|
||||
"context": "DATA",
|
||||
"item": "TrusteeContract",
|
||||
"view": true,
|
||||
"read": "g",
|
||||
"create": "g",
|
||||
"update": "g",
|
||||
"delete": "g",
|
||||
"mandateId": "uuid-456"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 Export-Endpoint
|
||||
|
||||
```python
|
||||
# modules/routes/routeRbac.py
|
||||
|
||||
@router.get("/export")
|
||||
async def exportRbac(
|
||||
mandateId: Optional[str] = Query(None),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
):
|
||||
"""
|
||||
Exportiert RBAC-Konfiguration.
|
||||
- mandateId=None: Globale Regeln
|
||||
- mandateId=X: Regeln für Mandant X
|
||||
"""
|
||||
interface = getInterface(currentUser, mandateId)
|
||||
|
||||
roles = interface.getAllRoles()
|
||||
rules = interface.getAccessRules(mandateId=mandateId)
|
||||
|
||||
return {
|
||||
"version": "1.0",
|
||||
"exportedAt": getUtcTimestamp(),
|
||||
"exportedBy": currentUser.username,
|
||||
"scope": {
|
||||
"mandateId": mandateId,
|
||||
"description": "Global" if not mandateId else f"Mandate: {mandateId}"
|
||||
},
|
||||
"roles": [r.model_dump() for r in roles],
|
||||
"rules": [r.model_dump() for r in rules]
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 Import-Endpoint
|
||||
|
||||
```python
|
||||
@router.post("/import")
|
||||
async def importRbac(
|
||||
data: RbacImportRequest,
|
||||
mode: str = Query("merge", description="merge oder replace"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
):
|
||||
"""
|
||||
Importiert RBAC-Konfiguration.
|
||||
- mode=merge: Upsert (bestehende werden aktualisiert)
|
||||
- mode=replace: Alle bestehenden Regeln des Scope löschen, dann importieren
|
||||
"""
|
||||
interface = getInterface(currentUser)
|
||||
mandateId = data.scope.get("mandateId")
|
||||
|
||||
# Nur sysadmin darf importieren
|
||||
if not _isSysAdmin(currentUser):
|
||||
raise HTTPException(403, "Only sysadmin can import RBAC")
|
||||
|
||||
if mode == "replace" and mandateId:
|
||||
# Lösche alle bestehenden Regeln für diesen Mandanten
|
||||
interface.deleteAccessRulesByMandate(mandateId)
|
||||
|
||||
imported = {"roles": 0, "rules": 0}
|
||||
|
||||
# Rollen importieren
|
||||
for roleData in data.roles:
|
||||
existing = interface.getRoleByLabel(roleData["roleLabel"])
|
||||
if existing:
|
||||
interface.updateRole(existing.id, Role(**roleData))
|
||||
else:
|
||||
interface.createRole(Role(**roleData))
|
||||
imported["roles"] += 1
|
||||
|
||||
# Regeln importieren
|
||||
for ruleData in data.rules:
|
||||
ruleData["mandateId"] = mandateId # Scope setzen
|
||||
|
||||
# Eindeutigkeit: roleLabel + context + item + mandateId
|
||||
existing = interface.findAccessRule(
|
||||
roleLabel=ruleData["roleLabel"],
|
||||
context=ruleData["context"],
|
||||
item=ruleData.get("item"),
|
||||
mandateId=mandateId
|
||||
)
|
||||
|
||||
if existing:
|
||||
interface.updateAccessRule(existing.id, AccessRule(**ruleData))
|
||||
else:
|
||||
interface.createAccessRule(AccessRule(**ruleData))
|
||||
imported["rules"] += 1
|
||||
|
||||
return {"status": "success", "imported": imported}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Migration
|
||||
|
||||
### 9.1 Datenbank-Migration
|
||||
|
||||
```python
|
||||
# migration_001_user_mandate.py
|
||||
|
||||
def migrate():
|
||||
"""
|
||||
Migriert bestehende User.mandateId zu UserMandate-Einträgen.
|
||||
"""
|
||||
db = getRootDbConnector()
|
||||
|
||||
# 1. UserMandate-Tabelle erstellen
|
||||
db._ensureTableExists(UserMandate)
|
||||
|
||||
# 2. Bestehende User migrieren
|
||||
users = db.getRecordset(UserInDB)
|
||||
|
||||
for user in users:
|
||||
mandateId = user.get("mandateId")
|
||||
if mandateId:
|
||||
# Prüfen ob Membership existiert
|
||||
existing = db.getRecordset(
|
||||
UserMandate,
|
||||
recordFilter={"userId": user["id"], "mandateId": mandateId}
|
||||
)
|
||||
|
||||
if not existing:
|
||||
# Membership erstellen
|
||||
membership = UserMandate(
|
||||
userId=user["id"],
|
||||
mandateId=mandateId,
|
||||
roleLabels=user.get("roleLabels", ["user"]),
|
||||
enabled=True,
|
||||
acceptedAt=getUtcTimestamp()
|
||||
)
|
||||
db.recordCreate(UserMandate, membership)
|
||||
print(f"Created membership for user {user['id']} in mandate {mandateId}")
|
||||
|
||||
# 3. Optional: mandateId und roleLabels aus User entfernen
|
||||
# (erst nach vollständigem Test der neuen Logik)
|
||||
|
||||
print("Migration completed")
|
||||
```
|
||||
|
||||
### 9.2 Abwärtskompatibilität
|
||||
|
||||
Während der Übergangsphase:
|
||||
|
||||
```python
|
||||
def getCurrentUser(token: str = Depends(cookieAuth)) -> User:
|
||||
"""
|
||||
Lädt User und setzt Legacy-Kompatibilität.
|
||||
"""
|
||||
user = _loadUserFromToken(token)
|
||||
|
||||
# Legacy: Falls Code noch user.mandateId erwartet
|
||||
# Erstes Mandat aus Memberships als Default
|
||||
memberships = getUserMandates(user.id)
|
||||
if memberships:
|
||||
user.mandateId = memberships[0].mandateId # Deprecated
|
||||
user.roleLabels = memberships[0].roleLabels # Deprecated
|
||||
|
||||
return user
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Schrittweise Umsetzung
|
||||
|
||||
### Phase 1: Datenmodell (Woche 1-2)
|
||||
- [ ] `UserMandate` Model erstellen
|
||||
- [ ] `AccessRule.mandateId` hinzufügen
|
||||
- [ ] `User` Model anpassen (transiente Felder)
|
||||
- [ ] Migrations-Script erstellen
|
||||
|
||||
### Phase 2: Backend-Core (Woche 2-3)
|
||||
- [ ] `interfaceDbAppObjects` Membership-Methoden
|
||||
- [ ] `interfaceRbac` mandateId-Parameter
|
||||
- [ ] `RbacClass` mandatsspezifische Regeln
|
||||
- [ ] Auth-Token ohne mandateId
|
||||
|
||||
### Phase 3: API-Routen (Woche 3-4)
|
||||
- [ ] Membership-Routen erstellen
|
||||
- [ ] Bestehende Routen anpassen (mandateId explizit)
|
||||
- [ ] RBAC Import/Export Endpoints
|
||||
|
||||
### Phase 4: Tests & Migration (Woche 4-5)
|
||||
- [ ] Unit-Tests für Memberships
|
||||
- [ ] Integration-Tests für RBAC
|
||||
- [ ] Migration bestehender Daten
|
||||
- [ ] Abwärtskompatibilität testen
|
||||
|
||||
### Phase 5: Frontend (Woche 5-6)
|
||||
- [ ] Mandanten-Auswahl UI
|
||||
- [ ] Membership-Verwaltung
|
||||
- [ ] RBAC Import/Export UI
|
||||
|
||||
---
|
||||
|
||||
## 11. Betroffene Dateien
|
||||
|
||||
### 11.1 Zu ändern
|
||||
|
||||
| Datei | Änderungen |
|
||||
|-------|------------|
|
||||
| `datamodels/datamodelUam.py` | `UserMandate` Model, `User` anpassen |
|
||||
| `datamodels/datamodelRbac.py` | `AccessRule.mandateId` |
|
||||
| `auth/authentication.py` | Token ohne mandateId |
|
||||
| `security/rbac.py` | Mandant-aware Regelauflösung |
|
||||
| `interfaces/interfaceRbac.py` | mandateId-Parameter |
|
||||
| `interfaces/interfaceDbAppObjects.py` | Membership-Methoden, Kontext-Logik |
|
||||
| `interfaces/interfaceDbTrusteeObjects.py` | Mandant aus Request statt User |
|
||||
| `interfaces/interfaceBootstrap.py` | Initiale Memberships |
|
||||
| `routes/routeDataUsers.py` | mandateId explizit |
|
||||
| `routes/routeDataMandates.py` | Membership-Filter |
|
||||
| `routes/routeRbac.py` | Import/Export Endpoints |
|
||||
|
||||
### 11.2 Neu zu erstellen
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `routes/routeMandateMemberships.py` | Membership-API |
|
||||
| `migrations/001_user_mandate.py` | Datenmigration |
|
||||
|
||||
### 11.3 Tests anzupassen
|
||||
|
||||
- `tests/unit/rbac/*`
|
||||
- `tests/integration/rbac/*`
|
||||
- `tests/functional/test*`
|
||||
|
||||
---
|
||||
|
||||
## 12. Risiken & Mitigationen
|
||||
|
||||
| Risiko | Mitigation |
|
||||
|--------|------------|
|
||||
| Breaking Changes | Übergangsphase mit Legacy-Kompatibilität |
|
||||
| Datenverlust | Migration erstellt Memberships, löscht nichts |
|
||||
| Performance | Membership-Cache pro Request |
|
||||
| Komplexität | Schrittweise Umsetzung, Feature-Flags |
|
||||
|
||||
---
|
||||
|
||||
## 13. Geklärte Design-Entscheidungen
|
||||
|
||||
1. **Frontend-Handling:** Features als Objekte/Gruppen organisiert, generisches Handling für Instanzen. Berechtigungen werden **summarisch** pro Feature-Instanz geladen (ein Request), nicht pro Objekt/Feld. → Siehe [ui_concept_nyla.md](./ui_concept_nyla.md)
|
||||
|
||||
2. **Default-Mandant:** User hat **keinen** Default-Mandanten. User ohne Mandant-Zugehörigkeit kann System-Basics nutzen (Login, Profil, Settings). Ein sysadmin hat typischerweise keinen Mandanten.
|
||||
|
||||
3. **Einladungs-Flow:** Email-Link mit überwachtem Token. Admin kann Token jederzeit widerrufen/löschen. Token hat Ablaufzeit.
|
||||
|
||||
4. **System-Rollen (sysadmin):** Global, nicht mandantsspezifisch. sysadmin wird direkt am User-Objekt markiert, nicht über Membership.
|
||||
609
implementation/Saas Multi Tenant Mandate/mandate_concept.md
Normal file
609
implementation/Saas Multi Tenant Mandate/mandate_concept.md
Normal file
|
|
@ -0,0 +1,609 @@
|
|||
# Multi-Tenant SaaS Plattform
|
||||
|
||||
## Architektur-Konzept für Mandantenfähigkeit
|
||||
|
||||
**Version:** 1.0
|
||||
|
||||
**Datum:** 14. Januar 2025
|
||||
|
||||
**Status:** Konzept
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
### Was ist das Ziel?
|
||||
|
||||
Wir entwickeln eine **mandantenfähige SaaS-Plattform**, die es verschiedenen Organisationen (Mandanten) ermöglicht, mehrere Geschäftsanwendungen (Features) über ein einziges System zu nutzen, während:
|
||||
|
||||
- ✅ **Datenisolation** zwischen Mandanten gewährleistet ist
|
||||
- ✅ **Flexible Zugriffskontrolle** auf Daten- und Feldebene möglich ist
|
||||
- ✅ **Ein Benutzer mit einem Account** in mehreren Mandanten arbeiten kann
|
||||
- ✅ **Self-Service-Onboarding** für neue Benutzer unterstützt wird
|
||||
|
||||
### Hauptmerkmale
|
||||
|
||||
| Merkmal | Beschreibung |
|
||||
| --- | --- |
|
||||
| **Multi-Mandant** | Verschiedene Firmen nutzen die gleiche Plattform isoliert |
|
||||
| **Multi-Feature** | Pro Mandant können verschiedene Geschäftsanwendungen aktiviert werden |
|
||||
| **Ein Account, viele Rollen** | Ein Benutzer kann in verschiedenen Mandanten unterschiedliche Rollen haben |
|
||||
| **Granulare Berechtigungen** | Zugriffskontrolle bis auf Feld-Ebene möglich |
|
||||
| **Konfigurierbar** | Berechtigungen können als Konfiguration importiert/exportiert werden |
|
||||
|
||||
---
|
||||
|
||||
## 2. Geschäftsziele & Nutzen
|
||||
|
||||
### 2.1 Für die Plattform-Betreiber
|
||||
|
||||
**Effizienz:**
|
||||
|
||||
- Ein System für alle Kunden (Mandanten)
|
||||
- Zentrale Wartung und Updates
|
||||
- Reduzierte Infrastruktur-Kosten
|
||||
|
||||
**Skalierbarkeit:**
|
||||
|
||||
- Beliebig viele Mandanten onboardbar
|
||||
- Neue Features können zentral ausgerollt werden
|
||||
- Pay-per-Use Modelle möglich
|
||||
|
||||
**Compliance:**
|
||||
|
||||
- Strikte Datentrennung zwischen Mandanten
|
||||
- Audit-fähige Zugriffskontrollen
|
||||
- DSGVO-konform durch granulare Berechtigungen
|
||||
|
||||
### 2.2 Für Mandanten (Kunden)
|
||||
|
||||
**Flexibilität:**
|
||||
|
||||
- Nur die Features nutzen, die benötigt werden
|
||||
- Benutzer können in mehreren Mandanten aktiv sein
|
||||
- Anpassbare Berechtigungsstrukturen
|
||||
|
||||
**Effizienz:**
|
||||
|
||||
- Schnelles Onboarding neuer Mitarbeiter
|
||||
- Self-Service Einladungssystem
|
||||
- Zentrale Benutzerverwaltung
|
||||
|
||||
**Sicherheit:**
|
||||
|
||||
- Eigene Daten sind isoliert von anderen Mandanten
|
||||
- Granulare Kontrolle über Datenzugriffe
|
||||
- Nachvollziehbare Berechtigungsstruktur
|
||||
|
||||
### 2.3 Für End-Benutzer
|
||||
|
||||
**Einfachheit:**
|
||||
|
||||
- Ein Login für alle Mandanten
|
||||
- Einfacher Wechsel zwischen Mandanten
|
||||
- Klare Rollenzuordnungen
|
||||
|
||||
**Transparenz:**
|
||||
|
||||
- User sieht, in welchen Mandanten er Mitglied ist
|
||||
- Klare Rollen und Berechtigungen
|
||||
- Keine versteckten Zugriffe
|
||||
|
||||
---
|
||||
|
||||
## 3. Kernkonzepte
|
||||
|
||||
### 3.1 Was ist ein Mandant?
|
||||
|
||||
Ein **Mandant** ist eine Organisation (z.B. eine Firma), die die Plattform nutzt. Jeder Mandant hat:
|
||||
|
||||
- Eigene Benutzer
|
||||
- Eigene Daten
|
||||
- Eigene Feature-Instanzen
|
||||
|
||||
**Beispiele:**
|
||||
|
||||
- Treuhandbüro "Soha Treuhand"
|
||||
- Beratungsfirma "Althaus Consulting"
|
||||
- Immobilienfirma "Universe Corp"
|
||||
|
||||
### 3.2 Was ist ein Feature?
|
||||
|
||||
Ein **Feature** ist eine Geschäftsanwendung innerhalb der Plattform. Beispiele:
|
||||
|
||||
| Feature | Beschreibung | Typische Nutzer |
|
||||
| --- | --- | --- |
|
||||
| **Chatbot** | KI-gestützte Chatbots für verschiedene Anwendungsfälle | Kundenservice, Support |
|
||||
| **Treuhand** | Buchhaltung und Treuhandverwaltung | Treuhänder, Kunden |
|
||||
| **Immobilien** | Verwaltung von Immobilienprojekten | Property Manager, Mieter |
|
||||
|
||||
### 3.3 Was ist eine Feature-Instanz?
|
||||
|
||||
Ein Mandant kann **mehrere Instanzen** desselben Features haben:
|
||||
|
||||
**Beispiel: Mandant "Althaus"**
|
||||
|
||||
- Chatbot-Instanz "Produkt-Support" (für Produktfragen)
|
||||
- Chatbot-Instanz "HR-Assistent" (für HR-Anfragen)
|
||||
- Chatbot-Instanz "Management-Tool" (für Strategiediskussionen)
|
||||
|
||||
Jede Instanz hat eigene Daten und Benutzer-Berechtigungen.
|
||||
|
||||
### 3.4 Benutzer-Zuordnung: Das "Ein Account, viele Rollen"-Prinzip
|
||||
|
||||
```
|
||||
Benutzerin: Shelly (shelly@universe.com)
|
||||
│
|
||||
├─ Mandant: Althaus
|
||||
│ ├─ Rolle im Mandant: Standard-Benutzer
|
||||
│ └─ Zugriff auf:
|
||||
│ ├─ Chatbot "Produkt-Support" → Rolle: Administrator
|
||||
│ └─ Chatbot "HR-Assistent" → Rolle: Benutzer
|
||||
│
|
||||
├─ Mandant: Soha Treuhand
|
||||
│ ├─ Rolle im Mandant: Standard-Benutzer
|
||||
│ └─ Zugriff auf:
|
||||
│ └─ Treuhand "Buchhaltung 2025" → Rolle: Kunde (nur eigene Daten)
|
||||
│
|
||||
└─ Mandant: Universe Corp
|
||||
├─ Rolle im Mandant: Mandanten-Administrator
|
||||
└─ Zugriff auf:
|
||||
└─ Alle Features im Mandant
|
||||
|
||||
```
|
||||
|
||||
**Ergebnis:** Shelly hat **einen Account**, aber **verschiedene Rollen** in **drei Mandanten**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Benutzerrollen & Verantwortlichkeiten
|
||||
|
||||
### 4.1 System-Administrator
|
||||
|
||||
**Wer:** Plattform-Betreiber (z.B. IT-Team)
|
||||
|
||||
**Verantwortlichkeiten:**
|
||||
|
||||
- ✅ Erstellt und verwaltet Mandanten
|
||||
- ✅ Schaltet Features für Mandanten frei
|
||||
- ✅ Weist Benutzer zu Mandanten zu
|
||||
- ✅ Definiert und verwaltet Berechtigungs-Policies
|
||||
- ✅ Hat Zugriff auf alle Daten (für Support/Wartung)
|
||||
|
||||
**Kann NICHT:**
|
||||
|
||||
- ❌ Automatisch in Feature-Instanzen arbeiten (muss sich explizit Zugriff geben)
|
||||
|
||||
### 4.2 Mandanten-Administrator
|
||||
|
||||
**Wer:** Führungsperson oder IT-Verantwortlicher der Organisation
|
||||
|
||||
**Verantwortlichkeiten:**
|
||||
|
||||
- ✅ Erstellt Feature-Instanzen im eigenen Mandant
|
||||
- ✅ Erstellt Einladungslinks für neue Benutzer
|
||||
- ✅ Vergibt Rollen innerhalb des Mandanten
|
||||
- ✅ Sieht alle Benutzer des eigenen Mandanten
|
||||
- ✅ Verwaltet Feature-Instanzen
|
||||
|
||||
**Kann NICHT:**
|
||||
|
||||
- ❌ Benutzer zum Mandanten hinzufügen (nur System-Admin)
|
||||
- ❌ Features für den Mandanten freischalten (nur System-Admin)
|
||||
- ❌ Andere Mandanten sehen oder darauf zugreifen
|
||||
|
||||
**Isolation:** Ein Mandanten-Administrator sieht nur die Benutzer und Daten seines eigenen Mandanten.
|
||||
|
||||
### 4.3 Standard-Benutzer
|
||||
|
||||
**Wer:** Mitarbeiter, Kunden, Partner der Organisation
|
||||
|
||||
**Verantwortlichkeiten:**
|
||||
|
||||
- ✅ Nutzt Feature-Instanzen entsprechend seiner Rollen
|
||||
- ✅ Arbeitet mit Daten im Rahmen seiner Berechtigungen
|
||||
|
||||
**Kann NICHT:**
|
||||
|
||||
- ❌ Feature-Instanzen erstellen
|
||||
- ❌ Andere Benutzer verwalten
|
||||
- ❌ Berechtigungen vergeben
|
||||
|
||||
### 4.4 Rollen innerhalb von Features
|
||||
|
||||
Jede Feature-Instanz hat eigene Rollen:
|
||||
|
||||
| Rolle | Typische Berechtigungen | Beispiel |
|
||||
| --- | --- | --- |
|
||||
| **Administrator** | Voller Zugriff auf alle Daten der Instanz | Chatbot-Admin sieht alle Konversationen |
|
||||
| **Benutzer** | Zugriff auf Instanz-Daten, kann eigene Daten erstellen | Marketing-Mitarbeiter nutzt Chatbot |
|
||||
| **Kunde** | Nur Zugriff auf eigene Daten | Treuhand-Kunde sieht nur eigene Buchhaltung |
|
||||
| **Betrachter** | Nur Lesezugriff | Externer Berater sieht Berichte, kann nichts ändern |
|
||||
|
||||
---
|
||||
|
||||
## 5. Anwendungsfälle (Use Cases)
|
||||
|
||||
### 5.1 Use Case 1: Patrick mit mehreren Firmen
|
||||
|
||||
**Situation:**
|
||||
Patrick hat 3 eigene Firmen und nutzt für jede ein anderes Treuhandbüro. Gleichzeitig arbeitet er als externer Berater für die Firma Althaus.
|
||||
|
||||
**Lösung:**
|
||||
|
||||
```
|
||||
Benutzer: Patrick (patrick@example.com)
|
||||
│
|
||||
├─ Mandant: Soha Treuhand (Treuhandbüro 1)
|
||||
│ └─ Treuhand-Instanz "PamoCreate AG" → Rolle: Kunde
|
||||
│ └─ Treuhand-Instanz "ValueOn AG" → Rolle: Kunde
|
||||
│ └─ Treuhand-Instanz "PowerOn GmbH" → Rolle: Kunde
|
||||
│
|
||||
├─ Mandant: SwissTreu (Treuhandbüro 2)
|
||||
│ └─ Treuhand-Instanz "Firma X" → Rolle: Kunde
|
||||
│
|
||||
└─ Mandant: Althaus (Beratungsfirma)
|
||||
└─ Chatbot-Instanz "Management-Tool" → Rolle: Administrator
|
||||
|
||||
```
|
||||
|
||||
**Vorteile:**
|
||||
|
||||
- Patrick hat **einen Login** für alle Mandanten
|
||||
- Patrick sieht bei Soha Treuhand nur seine 3 Firmen (nicht die Daten anderer Kunden)
|
||||
- Patrick ist bei Althaus Administrator und kann dort alles verwalten
|
||||
- Jeder Mandant sieht nur seine eigenen Daten
|
||||
|
||||
### 5.2 Use Case 2: Shelly als Multi-Mandant-Benutzer
|
||||
|
||||
**Situation:**
|
||||
Shelly arbeitet für ihre eigene Firma "Universe Corp", unterstützt aber auch andere Firmen als Freelancerin.
|
||||
|
||||
**Lösung:**
|
||||
|
||||
```
|
||||
Benutzerin: Shelly (shelly@universe.com)
|
||||
│
|
||||
├─ Mandant: Universe Corp (eigene Firma)
|
||||
│ ├─ Rolle: Mandanten-Administrator
|
||||
│ └─ Kann alle Features verwalten und Benutzer einladen
|
||||
│
|
||||
├─ Mandant: Althaus (Kunde als Freelancerin)
|
||||
│ └─ Chatbot-Instanz "Produkt-Support" → Rolle: Administrator
|
||||
│ └─ Chatbot-Instanz "HR-Assistent" → Rolle: Benutzer (nur für einen spezifischen Bot)
|
||||
│
|
||||
└─ Mandant: Soha Treuhand (nutzt deren Treuhand-Service)
|
||||
└─ Treuhand-Instanz "Universe Corp 2025" → Rolle: Kunde
|
||||
|
||||
```
|
||||
|
||||
**Vorteile:**
|
||||
|
||||
- Shelly verwaltet ihren eigenen Mandanten vollständig
|
||||
- Shelly kann bei Althaus als Expertin unterstützen
|
||||
- Shelly nutzt gleichzeitig Soha Treuhand als Kundin
|
||||
- Klare Rollentrennung in jedem Mandanten
|
||||
|
||||
### 5.3 Use Case 3: Treuhandbüro mit vielen Kunden
|
||||
|
||||
**Situation:**
|
||||
Das Treuhandbüro "Soha Treuhand" betreut 50 kleine Firmen. Jede Firma soll nur ihre eigenen Buchhaltungsdaten sehen.
|
||||
|
||||
**Lösung:**
|
||||
|
||||
```
|
||||
Mandant: Soha Treuhand
|
||||
│
|
||||
├─ Feature: Treuhand (aktiviert)
|
||||
│
|
||||
├─ Feature-Instanz: "Buchhaltung 2025"
|
||||
│ │
|
||||
│ ├─ Mitarbeiter "Anna" → Rolle: Administrator (sieht alle Kunden)
|
||||
│ ├─ Mitarbeiter "Tom" → Rolle: Benutzer (sieht alle Kunden)
|
||||
│ │
|
||||
│ ├─ Kunde "Patrick (PamoCreate AG)" → Rolle: Kunde (sieht nur PamoCreate)
|
||||
│ ├─ Kunde "Shelly (Universe Corp)" → Rolle: Kunde (sieht nur Universe)
|
||||
│ └─ ... 48 weitere Kunden ...
|
||||
|
||||
```
|
||||
|
||||
**Datenzugriff:**
|
||||
|
||||
- **Anna** (Administrator): Sieht alle 50 Firmen und deren Daten
|
||||
- **Tom** (Benutzer): Sieht alle 50 Firmen und deren Daten
|
||||
- **Patrick** (Kunde): Sieht **nur** Daten von PamoCreate AG
|
||||
- **Shelly** (Kunde): Sieht **nur** Daten von Universe Corp
|
||||
|
||||
**Technische Umsetzung:** Automatische Filterung basierend auf "Eigentümer"-Feld in der Datenbank.
|
||||
|
||||
---
|
||||
|
||||
## 6. Datensicherheit & Isolation
|
||||
|
||||
### 6.1 Mandanten-Isolation
|
||||
|
||||
**Prinzip:** Jeder Mandant ist vollständig von anderen Mandanten getrennt.
|
||||
|
||||
**Konkret:**
|
||||
|
||||
- Mandant A sieht **keine Daten** von Mandant B
|
||||
- Mandant A sieht **keine Benutzer** von Mandant B
|
||||
- Mandant A kann **nicht auf Feature-Instanzen** von Mandant B zugreifen
|
||||
|
||||
**Ausnahme:** System-Administrator (für Support und Wartung)
|
||||
|
||||
### 6.2 Zugriffskontrolle: Das "n/m/g/a"-System
|
||||
|
||||
Für jede Rolle in einer Feature-Instanz wird der **Datenzugriff** definiert:
|
||||
|
||||
| Scope | Name | Beschreibung | Beispiel |
|
||||
| --- | --- | --- | --- |
|
||||
| **n** | None | Kein Zugriff | Kunde sieht keine Admin-Einstellungen |
|
||||
| **m** | My | Nur eigene Daten | Kunde sieht nur eigene Rechnungen |
|
||||
| **g** | Group | Alle Daten der Instanz | Mitarbeiter sieht alle Kunden der Instanz |
|
||||
| **a** | All | Alle Daten des Features | Super-Admin sieht alle Instanzen |
|
||||
|
||||
**Anwendungsbeispiel:**
|
||||
|
||||
```
|
||||
Feature: Chatbot
|
||||
Instanz: "Produkt-Support"
|
||||
|
||||
Rolle "Kunde":
|
||||
- Konversationen: Scope "m" (my) → Nur eigene Chats
|
||||
- Einstellungen: Scope "n" (none) → Kein Zugriff
|
||||
|
||||
Rolle "Benutzer":
|
||||
- Konversationen: Scope "g" (group) → Alle Chats der Instanz
|
||||
- Einstellungen: Scope "n" (none) → Kein Zugriff
|
||||
|
||||
Rolle "Administrator":
|
||||
- Konversationen: Scope "g" (group) → Alle Chats der Instanz
|
||||
- Einstellungen: Scope "g" (group) → Voller Zugriff auf Einstellungen
|
||||
|
||||
```
|
||||
|
||||
### 6.3 Feld-Level-Sicherheit
|
||||
|
||||
Zusätzlich zur Tabellen-Ebene können einzelne **Datenfelder** geschützt werden:
|
||||
|
||||
**Beispiel: Benutzer-Tabelle**
|
||||
|
||||
| Feld | System-Admin | Mandanten-Admin | Standard-Benutzer |
|
||||
| --- | --- | --- | --- |
|
||||
| Name | Lesen/Schreiben | Lesen | Lesen (nur eigener) |
|
||||
| Email | Lesen/Schreiben | Lesen | Lesen/Schreiben (nur eigene) |
|
||||
| Passwort-Hash | Lesen/Schreiben | Kein Zugriff | Kein Zugriff |
|
||||
| isSystemAdmin | Lesen/Schreiben | Kein Zugriff | Kein Zugriff |
|
||||
|
||||
**Vorteil:** Sehr granulare Kontrolle über sensible Daten.
|
||||
|
||||
### 6.4 Berechtigungs-Policies (Regelsets)
|
||||
|
||||
**Was sind Policies?**
|
||||
Policies sind **konfigurierbare Regelsets**, die definieren, wer auf welche Daten zugreifen kann.
|
||||
|
||||
**Beispiel-Policy: "Chatbot Kunde"**
|
||||
|
||||
```
|
||||
Policy-Name: chatbot_customer_policy
|
||||
Gilt für: Rolle "Kunde" im Feature "Chatbot"
|
||||
|
||||
Regeln:
|
||||
1. Tabelle "Konversationen" → Lesen erlaubt (Scope: my)
|
||||
2. Tabelle "Konversationen" → Erstellen erlaubt (Scope: my)
|
||||
3. Tabelle "Nachrichten" → Lesen erlaubt (Scope: my)
|
||||
4. Tabelle "Admin-Einstellungen" → Kein Zugriff (Scope: none)
|
||||
|
||||
```
|
||||
|
||||
**Vorteile:**
|
||||
|
||||
- ✅ Policies können als Konfiguration exportiert/importiert werden
|
||||
- ✅ Änderungen benötigen keine Code-Anpassungen
|
||||
- ✅ Nachvollziehbar und audit-fähig
|
||||
- ✅ Wiederverwendbar über verschiedene Mandanten
|
||||
|
||||
---
|
||||
|
||||
## 7. Onboarding-Prozesse
|
||||
|
||||
### 7.1 Neuer Mandant wird erstellt
|
||||
|
||||
```
|
||||
1. System-Administrator erstellt Mandant
|
||||
└─ Mandant-Name: "Althaus"
|
||||
└─ Mandant-Typ: "Enterprise"
|
||||
|
||||
2. System-Administrator schaltet Features frei
|
||||
└─ Feature "Chatbot": Aktiviert
|
||||
└─ Feature "Treuhand": Nicht aktiviert
|
||||
|
||||
3. System-Administrator weist ersten Benutzer als Mandanten-Admin zu
|
||||
└─ User: admin@althaus.com
|
||||
└─ Rolle: Mandanten-Administrator
|
||||
|
||||
4. Mandanten-Administrator übernimmt
|
||||
└─ Erstellt Feature-Instanzen
|
||||
└─ Lädt weitere Benutzer ein
|
||||
|
||||
```
|
||||
|
||||
### 7.2 Neuer Benutzer wird eingeladen (Self-Service)
|
||||
|
||||
```
|
||||
1. Mandanten-Administrator erstellt Einladungslink
|
||||
└─ Feature-Instanz: "Chatbot Produkt-Support"
|
||||
└─ Rolle: "Benutzer"
|
||||
└─ Link: https://app.example.com/register?invite=abc123
|
||||
└─ Gültigkeit: 24 Stunden
|
||||
|
||||
2. Administrator sendet Link an neue Mitarbeiterin
|
||||
|
||||
3. Neue Mitarbeiterin öffnet Link und registriert sich
|
||||
└─ Benutzername: "anna"
|
||||
└─ Email: "anna@althaus.com"
|
||||
└─ Passwort: ***
|
||||
|
||||
4. System erstellt automatisch:
|
||||
└─ Benutzer-Account
|
||||
└─ Zuordnung zum Mandanten "Althaus"
|
||||
└─ Rolle "Benutzer" in "Chatbot Produkt-Support"
|
||||
|
||||
5. Anna kann sich sofort einloggen und arbeiten
|
||||
|
||||
```
|
||||
|
||||
### 7.3 Bestehender Benutzer wird zu weiterem Mandanten hinzugefügt
|
||||
|
||||
```
|
||||
1. System-Administrator weist Shelly zu neuem Mandanten zu
|
||||
└─ User: shelly@universe.com (besteht bereits)
|
||||
└─ Neuer Mandant: "Soha Treuhand"
|
||||
└─ Rolle: "Standard-Benutzer"
|
||||
|
||||
2. Mandanten-Admin von "Soha Treuhand" vergibt Feature-Rolle
|
||||
└─ Feature-Instanz: "Buchhaltung 2025"
|
||||
└─ Rolle: "Kunde"
|
||||
|
||||
3. Shelly erhält Email-Benachrichtigung
|
||||
|
||||
4. Shelly loggt sich ein und sieht neuen Mandanten im Menü
|
||||
└─ Kann zwischen "Universe Corp" und "Soha Treuhand" wechseln
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Skalierbarkeit & Flexibilität
|
||||
|
||||
### 8.1 Skalierung von Mandanten
|
||||
|
||||
**Horizontal:**
|
||||
|
||||
- Beliebig viele Mandanten können hinzugefügt werden
|
||||
- Jeder Mandant ist isoliert → keine Auswirkungen auf andere
|
||||
- Neue Mandanten können in Minuten onboardet werden
|
||||
|
||||
**Vertikal:**
|
||||
|
||||
- Pro Mandant können beliebig viele Feature-Instanzen erstellt werden
|
||||
- Pro Feature-Instanz können beliebig viele Benutzer hinzugefügt werden
|
||||
|
||||
### 8.2 Neue Features hinzufügen
|
||||
|
||||
```
|
||||
1. Entwicklung eines neuen Features (z.B. "CRM")
|
||||
|
||||
2. System-Administrator erstellt Feature-Definition
|
||||
└─ Feature-Code: "crm"
|
||||
└─ Verfügbare Rollen: ["admin", "user", "viewer"]
|
||||
|
||||
3. System-Administrator importiert Berechtigungs-Policies
|
||||
└─ Import von JSON-Konfiguration
|
||||
|
||||
4. System-Administrator schaltet Feature für Mandanten frei
|
||||
└─ Mandant "Althaus" → CRM aktiviert
|
||||
└─ Mandant "Soha Treuhand" → CRM nicht aktiviert
|
||||
|
||||
5. Mandanten-Administratoren können Feature nutzen
|
||||
└─ Erstellen CRM-Instanzen
|
||||
└─ Laden Benutzer ein
|
||||
|
||||
```
|
||||
|
||||
**Vorteil:** Neue Features können zentral entwickelt und flexibel ausgerollt werden.
|
||||
|
||||
### 8.3 Anpassbare Berechtigungen
|
||||
|
||||
**Szenario:** Mandant "Althaus" benötigt spezielle Berechtigungsstruktur.
|
||||
|
||||
**Lösung:**
|
||||
|
||||
1. System-Administrator exportiert Standard-Policies
|
||||
2. Mandanten-Administrator passt Policies an (mit Freigabe)
|
||||
3. System-Administrator importiert angepasste Policies für diesen Mandanten
|
||||
4. Nur "Althaus" nutzt angepasste Policies, andere Mandanten Standard
|
||||
|
||||
**Vorteil:** Flexibilität für Sonderanforderungen ohne Code-Änderungen.
|
||||
|
||||
---
|
||||
|
||||
## 9. Frequently Asked Questions (FAQ)
|
||||
|
||||
### F: Kann ein Benutzer wirklich mit einem Account in mehreren Mandanten arbeiten?
|
||||
|
||||
**A:** Ja, das ist ein Kernfeature. Ein Benutzer hat einen globalen Account und kann in beliebig vielen Mandanten Mitglied sein. In jedem Mandanten kann er unterschiedliche Rollen haben.
|
||||
|
||||
### F: Was passiert, wenn zwei Mandanten den gleichen Benutzer einladen wollen?
|
||||
|
||||
**A:** Da Benutzernamen eindeutig sind, Email-Adressen aber nicht, kann ein Benutzer:
|
||||
|
||||
- Entweder seinen bestehenden Account nutzen (System-Admin weist zu)
|
||||
- Oder ein zweiter Account wird mit anderem Benutzernamen erstellt
|
||||
|
||||
**Empfehlung:** System-Admin weist bestehenden Account zu, sodass ein Benutzer wirklich nur einen Account benötigt.
|
||||
|
||||
### F: Können Mandanten-Administratoren Benutzer von anderen Mandanten sehen?
|
||||
|
||||
**A:** Nein, strikte Isolation. Ein Mandanten-Admin sieht nur:
|
||||
|
||||
- Benutzer seines eigenen Mandanten
|
||||
- Feature-Instanzen seines eigenen Mandanten
|
||||
- Daten seines eigenen Mandanten
|
||||
|
||||
### F: Wie wird sichergestellt, dass Daten zwischen Mandanten getrennt bleiben?
|
||||
|
||||
**A:** Mehrschichtige Sicherheit:
|
||||
|
||||
1. **Architektur-Ebene:** Jede Datenabfrage filtert automatisch nach Mandant
|
||||
2. **Berechtigungs-Ebene:** Policies prüfen Zugriff auf Tabellen-/Feldebene
|
||||
3. **Datenbank-Ebene:** Row-Level Security kann zusätzlich aktiviert werden
|
||||
|
||||
### F: Was ist der Unterschied zwischen "Mandanten-Admin" und "Feature-Administrator"?
|
||||
|
||||
**A:**
|
||||
|
||||
- **Mandanten-Admin:** Verwaltet den gesamten Mandanten (erstellt Feature-Instanzen, lädt Benutzer ein)
|
||||
- **Feature-Administrator:** Hat Admin-Rolle in einer spezifischen Feature-Instanz (z.B. Chatbot-Admin)
|
||||
|
||||
Ein Benutzer kann beides gleichzeitig sein.
|
||||
|
||||
### F: Können Berechtigungen für einzelne Benutzer individuell angepasst werden?
|
||||
|
||||
**A:** Ja, über "Custom Permissions". Standard sind Rollen-basierte Policies, aber für Sonderfälle können individuelle Überschreibungen definiert werden.
|
||||
|
||||
### F: Wie werden neue Features ausgerollt?
|
||||
|
||||
**A:** Zentral und kontrolliert:
|
||||
|
||||
1. Feature wird entwickelt
|
||||
2. System-Admin schaltet Feature für ausgewählte Mandanten frei
|
||||
3. Mandanten-Admins können Feature-Instanzen erstellen
|
||||
4. Schrittweises Rollout möglich (Beta-Mandanten zuerst)
|
||||
|
||||
### F: Was passiert, wenn ein Mandant gekündigt wird?
|
||||
|
||||
**A:**
|
||||
|
||||
1. System-Admin deaktiviert Mandanten
|
||||
2. Alle Benutzer verlieren Zugriff auf diesen Mandanten
|
||||
3. Daten können archiviert oder gelöscht werden (nach DSGVO)
|
||||
4. Benutzer, die nur in diesem Mandanten waren, verlieren Zugriff
|
||||
5. Benutzer, die in anderen Mandanten sind, behalten diese Zugriffe
|
||||
|
||||
---
|
||||
|
||||
## 10. Glossar
|
||||
|
||||
| Begriff | Erklärung |
|
||||
| --- | --- |
|
||||
| **Mandant** | Organisation (Firma), die die Plattform nutzt |
|
||||
| **Feature** | Geschäftsanwendung (z.B. Chatbot, Treuhand, CRM) |
|
||||
| **Feature-Instanz** | Konkrete Instanz eines Features in einem Mandanten |
|
||||
| **System-Administrator** | Plattform-Betreiber mit globalen Rechten |
|
||||
| **Mandanten-Administrator** | Verwalter eines spezifischen Mandanten |
|
||||
| **Policy** | Konfigurierbare Berechtigungsregel |
|
||||
| **Scope (n/m/g/a)** | Datenzugriffs-Bereich (None/My/Group/All) |
|
||||
| **Isolation** | Strikte Trennung zwischen Mandanten |
|
||||
| **Onboarding** | Prozess der Neukundenaufnahm |
|
||||
880
implementation/Saas Multi Tenant Mandate/ui_concept_nyla.md
Normal file
880
implementation/Saas Multi Tenant Mandate/ui_concept_nyla.md
Normal file
|
|
@ -0,0 +1,880 @@
|
|||
# Multi-Tenant UI Konzept - Frontend Nyla
|
||||
|
||||
## Architektur für mandantenunabhängige Benutzer
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 14. Januar 2026
|
||||
**Status:** Entwurf
|
||||
**Frontend:** Nyla (React)
|
||||
|
||||
---
|
||||
|
||||
## 1. Grundprinzip
|
||||
|
||||
### 1.1 User ohne Mandanten-Zugehörigkeit
|
||||
|
||||
Ein User gehört **keinem Mandanten** an. Er sieht:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ User: patrick@example.com │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ SYSTEM (immer verfügbar) │ │
|
||||
│ │ • Profil bearbeiten │ │
|
||||
│ │ • Einstellungen │ │
|
||||
│ │ • Abmelden │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ FEATURE: Trustee │ │
|
||||
│ │ ├─ Instanz: "Soha Treuhand / PamoCreate AG" │ │
|
||||
│ │ ├─ Instanz: "Soha Treuhand / ValueOn AG" │ │
|
||||
│ │ └─ Instanz: "SwissTreu / Firma X" │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ FEATURE: Chatbot │ │
|
||||
│ │ └─ Instanz: "Althaus / Management-Tool" │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 Keine mandateId im Frontend
|
||||
|
||||
**Alt:** Frontend speicherte `mandateId` im State und sendete bei jedem Request.
|
||||
|
||||
**Neu:** Frontend kennt nur **Feature-Instanzen**. Der Mandant ergibt sich aus der Instanz.
|
||||
|
||||
```typescript
|
||||
// ALT
|
||||
const currentMandateId = useStore(state => state.mandateId);
|
||||
await api.get('/trustee/contracts', { params: { mandateId: currentMandateId }});
|
||||
|
||||
// NEU
|
||||
const currentInstance = useStore(state => state.currentFeatureInstance);
|
||||
await api.get('/trustee/contracts', { params: { instanceId: currentInstance.id }});
|
||||
// Backend ermittelt mandateId aus der Instanz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Feature-Objekt-Struktur
|
||||
|
||||
### 2.1 Feature als UI-Gruppe
|
||||
|
||||
Jedes Feature ist ein **Objekt**, das im UI als Gruppe organisiert ist:
|
||||
|
||||
```typescript
|
||||
interface Feature {
|
||||
code: string; // "trustee", "chatbot", "crm"
|
||||
label: I18nLabel; // { en: "Trustee", de: "Treuhand" }
|
||||
icon: string; // Material Icon Name
|
||||
instances: FeatureInstance[];
|
||||
}
|
||||
|
||||
interface FeatureInstance {
|
||||
id: string; // UUID der Instanz
|
||||
featureCode: string; // "trustee"
|
||||
mandateId: string; // Referenz (für Backend)
|
||||
mandateName: string; // "Soha Treuhand" (für Anzeige)
|
||||
instanceLabel: string; // "PamoCreate AG" (spezifischer Name)
|
||||
userRole: string; // Rolle des Users in dieser Instanz
|
||||
permissions: InstancePermissions; // Summarische Berechtigungen
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Generisches Instanz-Handling
|
||||
|
||||
Das Handling für Feature-Instanzen ist **generisch**. Die aktuelle Instanz ergibt sich aus der **URL** (Route-Parameter):
|
||||
|
||||
```typescript
|
||||
// stores/featureStore.ts
|
||||
|
||||
interface FeatureState {
|
||||
features: Feature[];
|
||||
|
||||
// Actions
|
||||
loadFeatures: () => Promise<void>;
|
||||
getInstanceById: (instanceId: string) => FeatureInstance | undefined;
|
||||
getFeatureByCode: (featureCode: string) => Feature | undefined;
|
||||
}
|
||||
|
||||
const useFeatureStore = create<FeatureState>((set, get) => ({
|
||||
features: [],
|
||||
|
||||
loadFeatures: async () => {
|
||||
// Ein API-Call lädt alle Features + Instanzen + Permissions
|
||||
const response = await api.get('/features/my');
|
||||
set({ features: response.data });
|
||||
},
|
||||
|
||||
getInstanceById: (instanceId) => {
|
||||
return get().features
|
||||
.flatMap(f => f.instances)
|
||||
.find(i => i.id === instanceId);
|
||||
},
|
||||
|
||||
getFeatureByCode: (featureCode) => {
|
||||
return get().features.find(f => f.code === featureCode);
|
||||
}
|
||||
}));
|
||||
```
|
||||
|
||||
### 2.3 Instanz aus URL-Parameter
|
||||
|
||||
Die aktuelle Instanz wird **nicht** im Store gespeichert, sondern aus der URL gelesen:
|
||||
|
||||
```typescript
|
||||
// hooks/useCurrentInstance.ts
|
||||
|
||||
export function useCurrentInstance(): FeatureInstance | undefined {
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
const getInstanceById = useFeatureStore(s => s.getInstanceById);
|
||||
|
||||
return instanceId ? getInstanceById(instanceId) : undefined;
|
||||
}
|
||||
|
||||
// Verwendung in Komponenten:
|
||||
function ContractList() {
|
||||
const instance = useCurrentInstance();
|
||||
|
||||
if (!instance) {
|
||||
return <Navigate to="/" />; // Keine Instanz in URL
|
||||
}
|
||||
|
||||
// Arbeite mit instance.permissions, instance.mandateId, etc.
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Berechtigungs-Abfrage (Summarisch)
|
||||
|
||||
### 3.1 Problem: Rate Limits
|
||||
|
||||
**Vermeiden:**
|
||||
```typescript
|
||||
// SCHLECHT: Ein Request pro Objekt/Feld
|
||||
for (const contract of contracts) {
|
||||
const canEdit = await api.get(`/rbac/check?table=Contract&id=${contract.id}&action=update`);
|
||||
const canDelete = await api.get(`/rbac/check?table=Contract&id=${contract.id}&action=delete`);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Lösung: Summarische Berechtigungen
|
||||
|
||||
Berechtigungen werden **einmalig pro Feature-Instanz** geladen:
|
||||
|
||||
```typescript
|
||||
interface InstancePermissions {
|
||||
// Tabellen-Level (CRUD pro Tabelle)
|
||||
tables: {
|
||||
[tableName: string]: {
|
||||
view: boolean;
|
||||
read: AccessLevel; // "n" | "m" | "g" | "a"
|
||||
create: AccessLevel;
|
||||
update: AccessLevel;
|
||||
delete: AccessLevel;
|
||||
}
|
||||
};
|
||||
|
||||
// Feld-Level (nur wo eingeschränkt)
|
||||
fields?: {
|
||||
[tableName: string]: {
|
||||
[fieldName: string]: {
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// View-Level (Navigation)
|
||||
views: {
|
||||
[viewCode: string]: boolean;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 API-Endpunkt für Permissions
|
||||
|
||||
```typescript
|
||||
// GET /features/my
|
||||
// Lädt alles in einem Request
|
||||
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"code": "trustee",
|
||||
"label": { "en": "Trustee", "de": "Treuhand" },
|
||||
"instances": [
|
||||
{
|
||||
"id": "inst-123",
|
||||
"mandateId": "mand-456",
|
||||
"mandateName": "Soha Treuhand",
|
||||
"instanceLabel": "PamoCreate AG",
|
||||
"userRole": "customer",
|
||||
"permissions": {
|
||||
"tables": {
|
||||
"TrusteeOrganisation": { "view": true, "read": "m", "create": "n", "update": "m", "delete": "n" },
|
||||
"TrusteeContract": { "view": true, "read": "m", "create": "n", "update": "n", "delete": "n" },
|
||||
"TrusteeDocument": { "view": true, "read": "m", "create": "m", "update": "m", "delete": "n" }
|
||||
},
|
||||
"views": {
|
||||
"trustee-dashboard": true,
|
||||
"trustee-contracts": true,
|
||||
"trustee-admin": false
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Permission-Hooks im Frontend
|
||||
|
||||
```typescript
|
||||
// hooks/usePermissions.ts
|
||||
|
||||
export function useTablePermission(tableName: string) {
|
||||
const instance = useCurrentInstance(); // Aus URL-Parameter
|
||||
|
||||
if (!instance) {
|
||||
return { view: false, read: 'n', create: 'n', update: 'n', delete: 'n' };
|
||||
}
|
||||
|
||||
return instance.permissions.tables[tableName] ?? {
|
||||
view: false, read: 'n', create: 'n', update: 'n', delete: 'n'
|
||||
};
|
||||
}
|
||||
|
||||
export function useCanView(viewCode: string): boolean {
|
||||
const instance = useCurrentInstance(); // Aus URL-Parameter
|
||||
return instance?.permissions.views[viewCode] ?? false;
|
||||
}
|
||||
|
||||
export function useCanEditRecord(tableName: string, record: any): boolean {
|
||||
const permission = useTablePermission(tableName);
|
||||
const userId = useAuthStore(s => s.user?.id);
|
||||
|
||||
switch (permission.update) {
|
||||
case 'n': return false;
|
||||
case 'm': return record._createdBy === userId;
|
||||
case 'g': return true; // Instanz-Scope
|
||||
case 'a': return true; // Alle
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Navigation & UI-Struktur
|
||||
|
||||
### 4.1 Hauptnavigation
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ [Logo] PowerOn [User] [Logout] │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ SYSTEM │
|
||||
│ ○ Dashboard │
|
||||
│ ○ Profil │
|
||||
│ ○ Einstellungen │
|
||||
│ │
|
||||
│ ▼ TRUSTEE [Feature] │
|
||||
│ │ │
|
||||
│ ├─▼ Soha Treuhand / PamoCreate AG [Instanz] │
|
||||
│ │ ○ Übersicht │
|
||||
│ │ ○ Verträge │
|
||||
│ │ ○ Dokumente │
|
||||
│ │ ○ Positionen │
|
||||
│ │ │
|
||||
│ ├─▼ Soha Treuhand / ValueOn AG [Instanz] │
|
||||
│ │ ○ Übersicht │
|
||||
│ │ ○ Verträge │
|
||||
│ │ ○ Dokumente │
|
||||
│ │ │
|
||||
│ └─▶ SwissTreu / Firma X [Instanz] │
|
||||
│ (collapsed) │
|
||||
│ │
|
||||
│ ▼ CHATBOT [Feature] │
|
||||
│ │ │
|
||||
│ └─▼ Althaus / Management-Tool [Instanz] │
|
||||
│ ○ Conversations │
|
||||
│ ○ Settings │
|
||||
│ │
|
||||
│ ───────────────────────────────────── │
|
||||
│ ADMIN (nur wenn sysadmin) │
|
||||
│ ○ Mandanten │
|
||||
│ ○ Users │
|
||||
│ ○ RBAC │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Hierarchie:**
|
||||
```
|
||||
Feature (Gruppe)
|
||||
└─ Instanz (Subgruppe)
|
||||
└─ Objekte/Views (Navigation Items)
|
||||
```
|
||||
```
|
||||
|
||||
### 4.2 Feature-Gruppe mit Instanz-Subgruppen
|
||||
|
||||
Jedes Feature zeigt **alle Instanzen als Subgruppen** an:
|
||||
|
||||
```tsx
|
||||
// components/FeatureNavGroup.tsx
|
||||
|
||||
function FeatureNavGroup({ featureCode }: { featureCode: string }) {
|
||||
const { features } = useFeatureStore();
|
||||
const feature = features.find(f => f.code === featureCode);
|
||||
|
||||
if (!feature || feature.instances.length === 0) {
|
||||
return null; // Feature nicht anzeigen wenn keine Instanzen
|
||||
}
|
||||
|
||||
return (
|
||||
<NavGroup>
|
||||
{/* Feature als Hauptgruppe */}
|
||||
<NavGroupHeader collapsible>
|
||||
<Icon name={feature.icon} />
|
||||
<span>{feature.label.de}</span>
|
||||
</NavGroupHeader>
|
||||
|
||||
{/* Jede Instanz als Subgruppe */}
|
||||
{feature.instances.map(instance => (
|
||||
<InstanceNavSubgroup
|
||||
key={instance.id}
|
||||
instance={instance}
|
||||
featureCode={featureCode}
|
||||
/>
|
||||
))}
|
||||
</NavGroup>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Instanz-Subgruppe Komponente
|
||||
|
||||
```tsx
|
||||
// components/InstanceNavSubgroup.tsx
|
||||
|
||||
function InstanceNavSubgroup({ instance, featureCode }: {
|
||||
instance: FeatureInstance;
|
||||
featureCode: string;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<NavSubgroup>
|
||||
{/* Instanz als Subgruppen-Header */}
|
||||
<NavSubgroupHeader
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
collapsible
|
||||
>
|
||||
<Icon name={isExpanded ? 'expand_more' : 'chevron_right'} />
|
||||
<span>{instance.mandateName} / {instance.instanceLabel}</span>
|
||||
<RoleBadge role={instance.userRole} />
|
||||
</NavSubgroupHeader>
|
||||
|
||||
{/* Objekte/Views der Instanz */}
|
||||
{isExpanded && (
|
||||
<NavSubgroupItems>
|
||||
{instance.permissions.views[`${featureCode}-dashboard`] && (
|
||||
<NavItem to={`/${featureCode}/${instance.id}/dashboard`}>
|
||||
Übersicht
|
||||
</NavItem>
|
||||
)}
|
||||
{instance.permissions.views[`${featureCode}-contracts`] && (
|
||||
<NavItem to={`/${featureCode}/${instance.id}/contracts`}>
|
||||
Verträge
|
||||
</NavItem>
|
||||
)}
|
||||
{instance.permissions.views[`${featureCode}-documents`] && (
|
||||
<NavItem to={`/${featureCode}/${instance.id}/documents`}>
|
||||
Dokumente
|
||||
</NavItem>
|
||||
)}
|
||||
{instance.permissions.views[`${featureCode}-positions`] && (
|
||||
<NavItem to={`/${featureCode}/${instance.id}/positions`}>
|
||||
Positionen
|
||||
</NavItem>
|
||||
)}
|
||||
</NavSubgroupItems>
|
||||
)}
|
||||
</NavSubgroup>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 URL-Struktur mit Instanz-ID
|
||||
|
||||
Die URL enthält immer die **Instanz-ID**, damit klar ist, in welcher Instanz gearbeitet wird:
|
||||
|
||||
```
|
||||
/trustee/{instanceId}/dashboard
|
||||
/trustee/{instanceId}/contracts
|
||||
/trustee/{instanceId}/contracts/{contractId}
|
||||
/trustee/{instanceId}/documents
|
||||
|
||||
/chatbot/{instanceId}/conversations
|
||||
/chatbot/{instanceId}/settings
|
||||
```
|
||||
|
||||
**Router-Setup:**
|
||||
```tsx
|
||||
// routes.tsx
|
||||
|
||||
<Route path="/:featureCode/:instanceId/*" element={<FeatureLayout />}>
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="contracts" element={<ContractList />} />
|
||||
<Route path="contracts/:contractId" element={<ContractDetail />} />
|
||||
<Route path="documents" element={<DocumentList />} />
|
||||
{/* ... */}
|
||||
</Route>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. System-Bereich (Ohne Mandant)
|
||||
|
||||
### 5.1 Immer verfügbare Funktionen
|
||||
|
||||
User ohne Mandant-Zugehörigkeit können:
|
||||
|
||||
```typescript
|
||||
const SYSTEM_FEATURES = {
|
||||
// Immer verfügbar
|
||||
profile: true,
|
||||
settings: true,
|
||||
logout: true,
|
||||
|
||||
// Nur für sysadmin
|
||||
adminMandates: (user) => user.isSysAdmin,
|
||||
adminUsers: (user) => user.isSysAdmin,
|
||||
adminRbac: (user) => user.isSysAdmin,
|
||||
};
|
||||
```
|
||||
|
||||
### 5.2 Dashboard für User ohne Instanzen
|
||||
|
||||
```tsx
|
||||
// pages/Dashboard.tsx
|
||||
|
||||
function Dashboard() {
|
||||
const { features } = useFeatureStore();
|
||||
const hasInstances = features.some(f => f.instances.length > 0);
|
||||
|
||||
if (!hasInstances) {
|
||||
return (
|
||||
<WelcomeScreen>
|
||||
<h1>Willkommen bei PowerOn</h1>
|
||||
<p>Du hast aktuell Zugriff auf keine Feature-Instanzen.</p>
|
||||
<p>Kontaktiere einen Administrator, um Zugriff zu erhalten.</p>
|
||||
|
||||
<Card>
|
||||
<h3>Was du jetzt tun kannst:</h3>
|
||||
<ul>
|
||||
<li><Link to="/profile">Profil bearbeiten</Link></li>
|
||||
<li><Link to="/settings">Einstellungen anpassen</Link></li>
|
||||
</ul>
|
||||
</Card>
|
||||
</WelcomeScreen>
|
||||
);
|
||||
}
|
||||
|
||||
return <FeatureOverviewDashboard features={features} />;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Daten-Requests mit Instanz-Kontext
|
||||
|
||||
### 6.1 API-Client mit Instanz-Parameter
|
||||
|
||||
Da die Instanz-ID in der URL steht, wird sie **explizit** an API-Calls übergeben:
|
||||
|
||||
```typescript
|
||||
// services/api.ts
|
||||
|
||||
const api = axios.create({ baseURL: '/api' });
|
||||
|
||||
// Kein Interceptor nötig - Instanz-ID kommt aus URL und wird explizit übergeben
|
||||
```
|
||||
|
||||
### 6.2 Feature-spezifische Queries
|
||||
|
||||
```typescript
|
||||
// hooks/useTrusteeContracts.ts
|
||||
|
||||
export function useTrusteeContracts() {
|
||||
const instance = useCurrentInstance(); // Aus URL: /trustee/:instanceId/contracts
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['trustee', 'contracts', instance?.id],
|
||||
queryFn: async () => {
|
||||
if (!instance) throw new Error('No instance in URL');
|
||||
|
||||
// Instanz-ID explizit im Request
|
||||
const response = await api.get(`/trustee/${instance.id}/contracts`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!instance && instance.featureCode === 'trustee'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Berechtigungs-gesteuerte UI-Elemente
|
||||
|
||||
### 7.1 Conditional Rendering
|
||||
|
||||
```tsx
|
||||
// components/ContractList.tsx
|
||||
|
||||
function ContractList() {
|
||||
const { data: contracts } = useTrusteeContracts();
|
||||
const tablePermission = useTablePermission('TrusteeContract');
|
||||
const userId = useAuthStore(s => s.user?.id);
|
||||
|
||||
const canCreate = tablePermission.create !== 'n';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header>
|
||||
<h2>Verträge</h2>
|
||||
{canCreate && (
|
||||
<Button onClick={openCreateDialog}>Neuer Vertrag</Button>
|
||||
)}
|
||||
</Header>
|
||||
|
||||
<Table>
|
||||
{contracts?.map(contract => {
|
||||
const canEdit = canEditRecord(tablePermission.update, contract, userId);
|
||||
const canDelete = canEditRecord(tablePermission.delete, contract, userId);
|
||||
|
||||
return (
|
||||
<TableRow key={contract.id}>
|
||||
<TableCell>{contract.name}</TableCell>
|
||||
<TableCell>
|
||||
{canEdit && <IconButton icon="edit" onClick={() => editContract(contract)} />}
|
||||
{canDelete && <IconButton icon="delete" onClick={() => deleteContract(contract)} />}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function canEditRecord(accessLevel: AccessLevel, record: any, userId: string): boolean {
|
||||
switch (accessLevel) {
|
||||
case 'n': return false;
|
||||
case 'm': return record._createdBy === userId;
|
||||
case 'g':
|
||||
case 'a': return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Permission-Wrapper Komponente
|
||||
|
||||
```tsx
|
||||
// components/PermissionGate.tsx
|
||||
|
||||
interface PermissionGateProps {
|
||||
table: string;
|
||||
action: 'view' | 'read' | 'create' | 'update' | 'delete';
|
||||
record?: any; // Für "m" (my) Prüfung
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
function PermissionGate({ table, action, record, children, fallback = null }: PermissionGateProps) {
|
||||
const permission = useTablePermission(table);
|
||||
const userId = useAuthStore(s => s.user?.id);
|
||||
|
||||
let hasPermission = false;
|
||||
|
||||
if (action === 'view') {
|
||||
hasPermission = permission.view;
|
||||
} else {
|
||||
const level = permission[action];
|
||||
if (level === 'n') {
|
||||
hasPermission = false;
|
||||
} else if (level === 'm' && record) {
|
||||
hasPermission = record._createdBy === userId;
|
||||
} else if (level === 'g' || level === 'a') {
|
||||
hasPermission = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasPermission ? <>{children}</> : <>{fallback}</>;
|
||||
}
|
||||
|
||||
// Verwendung:
|
||||
<PermissionGate table="TrusteeContract" action="create">
|
||||
<Button>Neuer Vertrag</Button>
|
||||
</PermissionGate>
|
||||
|
||||
<PermissionGate table="TrusteeContract" action="update" record={contract}>
|
||||
<IconButton icon="edit" />
|
||||
</PermissionGate>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Login & Session
|
||||
|
||||
### 8.1 Login-Response (ohne mandateId)
|
||||
|
||||
```typescript
|
||||
// Nach erfolgreichem Login:
|
||||
{
|
||||
"token": "jwt...",
|
||||
"user": {
|
||||
"id": "user-123",
|
||||
"username": "patrick",
|
||||
"email": "patrick@example.com",
|
||||
"isSysAdmin": false
|
||||
},
|
||||
"features": [
|
||||
// Alle Features + Instanzen + Permissions (siehe Abschnitt 3.3)
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 Auth-Store
|
||||
|
||||
```typescript
|
||||
// stores/authStore.ts
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
|
||||
login: (credentials: LoginCredentials) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
login: async (credentials) => {
|
||||
const response = await api.post('/auth/login', credentials);
|
||||
|
||||
// Token speichern
|
||||
set({
|
||||
user: response.data.user,
|
||||
token: response.data.token,
|
||||
isAuthenticated: true
|
||||
});
|
||||
|
||||
// Features laden (separater Store)
|
||||
useFeatureStore.getState().setFeatures(response.data.features);
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
set({ user: null, token: null, isAuthenticated: false });
|
||||
useFeatureStore.getState().reset();
|
||||
}
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Einladungs-Flow
|
||||
|
||||
### 9.1 Einladungs-Link
|
||||
|
||||
```
|
||||
https://app.poweron.ch/invite?token=abc123xyz
|
||||
```
|
||||
|
||||
### 9.2 Einladungs-Seite
|
||||
|
||||
```tsx
|
||||
// pages/Invite.tsx
|
||||
|
||||
function InvitePage() {
|
||||
const token = useSearchParam('token');
|
||||
const [inviteData, setInviteData] = useState<InviteData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Token validieren (ohne Login)
|
||||
api.get(`/invite/validate?token=${token}`)
|
||||
.then(res => setInviteData(res.data))
|
||||
.catch(err => setError(err.response?.data?.detail || 'Ungültiger Link'));
|
||||
}, [token]);
|
||||
|
||||
if (error) {
|
||||
return <ErrorScreen message={error} />;
|
||||
}
|
||||
|
||||
if (!inviteData) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<InviteForm>
|
||||
<h1>Einladung zu {inviteData.mandateName}</h1>
|
||||
<p>Du wurdest eingeladen, als <strong>{inviteData.roleLabel}</strong> mitzuarbeiten.</p>
|
||||
|
||||
{inviteData.existingUser ? (
|
||||
// User existiert bereits - nur Login erforderlich
|
||||
<LoginForm
|
||||
onSuccess={() => acceptInvite(token)}
|
||||
message="Melde dich an, um die Einladung anzunehmen."
|
||||
/>
|
||||
) : (
|
||||
// Neuer User - Registrierung
|
||||
<RegisterForm
|
||||
prefillEmail={inviteData.email}
|
||||
onSuccess={(userId) => acceptInvite(token, userId)}
|
||||
/>
|
||||
)}
|
||||
</InviteForm>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 Einladungs-Verwaltung (Admin)
|
||||
|
||||
```tsx
|
||||
// pages/admin/Invitations.tsx
|
||||
|
||||
function InvitationsAdmin() {
|
||||
const { data: invitations } = useQuery({
|
||||
queryKey: ['admin', 'invitations'],
|
||||
queryFn: () => api.get('/admin/invitations').then(r => r.data)
|
||||
});
|
||||
|
||||
const revokeInvite = useMutation({
|
||||
mutationFn: (token: string) => api.delete(`/admin/invitations/${token}`),
|
||||
onSuccess: () => queryClient.invalidateQueries(['admin', 'invitations'])
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Ausstehende Einladungen</h2>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableColumn>Email</TableColumn>
|
||||
<TableColumn>Mandant</TableColumn>
|
||||
<TableColumn>Rolle</TableColumn>
|
||||
<TableColumn>Erstellt</TableColumn>
|
||||
<TableColumn>Gültig bis</TableColumn>
|
||||
<TableColumn>Aktionen</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invitations?.map(inv => (
|
||||
<TableRow key={inv.token}>
|
||||
<TableCell>{inv.email}</TableCell>
|
||||
<TableCell>{inv.mandateName}</TableCell>
|
||||
<TableCell>{inv.roleLabel}</TableCell>
|
||||
<TableCell>{formatDate(inv.createdAt)}</TableCell>
|
||||
<TableCell>{formatDate(inv.expiresAt)}</TableCell>
|
||||
<TableCell>
|
||||
<IconButton
|
||||
icon="delete"
|
||||
onClick={() => revokeInvite.mutate(inv.token)}
|
||||
title="Einladung widerrufen"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Zusammenfassung
|
||||
|
||||
### 10.1 Wichtige Prinzipien
|
||||
|
||||
| Prinzip | Umsetzung |
|
||||
|---------|-----------|
|
||||
| **Kein mandateId** | User gehört keinem Mandanten, arbeitet in Feature-Instanzen |
|
||||
| **Summarische Permissions** | Ein Request lädt alle Berechtigungen pro Instanz |
|
||||
| **Generisches Feature-Handling** | Alle Features haben gleiche Struktur |
|
||||
| **Instanz aus URL** | `/feature/{instanceId}/...` - Instanz-ID immer in URL |
|
||||
| **Navigation = Hierarchie** | Feature → Instanz (Subgruppe) → Objekte |
|
||||
| **System-Features immer verfügbar** | Profil, Settings, Logout ohne Instanz |
|
||||
|
||||
### 10.2 State-Architektur
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Frontend State │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ AuthStore │
|
||||
│ ├─ user: User │
|
||||
│ ├─ token: string │
|
||||
│ └─ isAuthenticated: boolean │
|
||||
│ │
|
||||
│ FeatureStore │
|
||||
│ ├─ features: Feature[] │
|
||||
│ │ └─ instances: FeatureInstance[] │
|
||||
│ │ └─ permissions: InstancePermissions │
|
||||
│ └─ getInstanceById(id): FeatureInstance │
|
||||
│ │
|
||||
│ Router (URL) │
|
||||
│ └─ /:featureCode/:instanceId/* → aktuelle Instanz │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 10.3 API-Endpunkte (Frontend-relevant)
|
||||
|
||||
| Endpunkt | Beschreibung |
|
||||
|----------|--------------|
|
||||
| `GET /features/my` | Alle Features + Instanzen + Permissions |
|
||||
| `GET /invite/validate?token=X` | Einladung validieren |
|
||||
| `POST /invite/accept` | Einladung annehmen |
|
||||
| `GET /admin/invitations` | Alle Einladungen (Admin) |
|
||||
| `DELETE /admin/invitations/{token}` | Einladung widerrufen |
|
||||
|
||||
---
|
||||
|
||||
## 11. Migration (Frontend)
|
||||
|
||||
### 11.1 Zu entfernen
|
||||
|
||||
- `currentMandateId` aus globalem State
|
||||
- `mandateId` aus API-Requests (Query-Params)
|
||||
- Mandanten-Switcher
|
||||
|
||||
### 11.2 Zu ändern
|
||||
|
||||
- **Navigation:** Feature → Instanz-Subgruppen → Objekte (hierarchisch)
|
||||
- **URLs:** Müssen `/:featureCode/:instanceId/...` enthalten
|
||||
- **Permission-Checks:** Auf `useTablePermission` + `useCurrentInstance` umstellen
|
||||
- **API-Calls:** Instanz-ID aus URL-Parameter verwenden
|
||||
|
||||
### 11.3 Neu
|
||||
|
||||
- `FeatureStore` mit allen Instanzen und Permissions
|
||||
- `useCurrentInstance()` Hook (liest aus URL)
|
||||
- `FeatureNavGroup` + `InstanceNavSubgroup` Komponenten
|
||||
- `PermissionGate` Wrapper
|
||||
- Einladungs-Flow UI
|
||||
Loading…
Reference in a new issue