feat(rbac): add roles and permissions
This commit is contained in:
parent
86a32a942c
commit
694b7bd09a
37 changed files with 2682 additions and 218 deletions
411
ARCHITECTURE.md
Normal file
411
ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
# Olympus CRM Architektur
|
||||||
|
|
||||||
|
## Projektueberblick
|
||||||
|
|
||||||
|
Olympus CRM ist als mehrschichtige CRM-Anwendung aufgebaut. Die Architektur trennt klar zwischen Browser, Frontend/BFF, Backend-API und Datenbank.
|
||||||
|
|
||||||
|
Die zentrale Regel lautet:
|
||||||
|
|
||||||
|
**Der Browser kommuniziert ausschliesslich mit Athena. Athena kommuniziert serverseitig mit Hermes. Hermes kommuniziert mit PostgreSQL.**
|
||||||
|
|
||||||
|
Diese Trennung ist verbindlich, besonders fuer Authentifizierung, API-Zugriffe und spaetere Modul-Erweiterungen wie Kunden, Projekte, Lieferanten, Kontakte und Mitarbeiter.
|
||||||
|
|
||||||
|
## Systemkomponenten
|
||||||
|
|
||||||
|
### Athena: Next.js 16 Frontend
|
||||||
|
|
||||||
|
Athena liegt unter `frontend/athena` und ist das Frontend sowie die BFF-Schicht des Systems.
|
||||||
|
|
||||||
|
Aufgaben:
|
||||||
|
|
||||||
|
- rendert die Benutzeroberflaeche mit Next.js 16 App Router
|
||||||
|
- stellt eigene API-Routes unter `/api/...` bereit
|
||||||
|
- setzt und loescht das HttpOnly-Auth-Cookie
|
||||||
|
- ruft Hermes serverseitig auf
|
||||||
|
- schuetzt Seiten ueber `proxy.ts`
|
||||||
|
|
||||||
|
Der Browser darf keine Hermes-URLs direkt aufrufen.
|
||||||
|
|
||||||
|
### Hermes: FastAPI Backend
|
||||||
|
|
||||||
|
Hermes liegt unter `backend/hermes` und stellt die interne Backend-API bereit.
|
||||||
|
|
||||||
|
Aufgaben:
|
||||||
|
|
||||||
|
- validiert Requests mit Pydantic v2
|
||||||
|
- verarbeitet Fachlogik und Persistenz
|
||||||
|
- prueft JWT Bearer Tokens
|
||||||
|
- stellt REST-Endpunkte bereit
|
||||||
|
- nutzt SQLAlchemy 2.x fuer Datenbankzugriffe
|
||||||
|
- setzt keine Browser-Cookies
|
||||||
|
|
||||||
|
Hermes bleibt Cookie-frei. Authentifizierte Hermes-Endpunkte erwarten `Authorization: Bearer <jwt>`.
|
||||||
|
|
||||||
|
### PostgreSQL Datenbank
|
||||||
|
|
||||||
|
PostgreSQL ist die relationale Datenbank fuer Olympus CRM. Hermes greift ueber SQLAlchemy darauf zu.
|
||||||
|
|
||||||
|
Schemaaenderungen erfolgen ueber Alembic-Migrationen. Manuelle Datenbankeingriffe sind nur in Notfaellen zulaessig und muessen nachvollziehbar dokumentiert werden.
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
Die gemeinsame Docker-Compose-Konfiguration liegt im Projektroot in `docker-compose.yml`.
|
||||||
|
|
||||||
|
Sie verbindet:
|
||||||
|
|
||||||
|
- `hermes`
|
||||||
|
- `athena`
|
||||||
|
- das konfigurierte Docker-Netzwerk
|
||||||
|
- die notwendigen Runtime-Umgebungsvariablen
|
||||||
|
|
||||||
|
## BFF-Architektur
|
||||||
|
|
||||||
|
Olympus nutzt eine Backend-for-Frontend-Architektur.
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
|
||||||
|
- Browser spricht ausschliesslich mit Athena.
|
||||||
|
- Athena spricht serverseitig mit Hermes.
|
||||||
|
- Hermes setzt keine Browser-Cookies.
|
||||||
|
- Cross-Origin-Cookies werden nicht verwendet.
|
||||||
|
- Frontend-API-Zugriffe laufen ueber relative Athena-Routen wie `/api/users`.
|
||||||
|
|
||||||
|
Der Datenfluss bleibt dadurch kontrolliert, sicher und leichter hinter Reverse Proxies betreibbar.
|
||||||
|
|
||||||
|
## Authentifizierungsfluss
|
||||||
|
|
||||||
|
### Login
|
||||||
|
|
||||||
|
1. Der Browser sendet Benutzername und Passwort an Athena: `POST /api/login`.
|
||||||
|
2. Athena ruft serverseitig Hermes auf: `POST /auth/login`.
|
||||||
|
3. Hermes validiert die Zugangsdaten.
|
||||||
|
4. Hermes gibt JWT, Ablaufzeit und Benutzerdaten als JSON zurueck.
|
||||||
|
5. Athena setzt das JWT in einem HttpOnly-Cookie.
|
||||||
|
6. Der Browser wird auf eine geschuetzte Seite weitergeleitet.
|
||||||
|
|
||||||
|
Hermes setzt kein Cookie.
|
||||||
|
|
||||||
|
### Logout
|
||||||
|
|
||||||
|
1. Der Browser sendet `POST /api/logout` an Athena.
|
||||||
|
2. Athena loescht das HttpOnly-Cookie.
|
||||||
|
3. Der Benutzer wird zur Login-Seite gefuehrt.
|
||||||
|
|
||||||
|
### HttpOnly Cookie
|
||||||
|
|
||||||
|
Das Auth-Cookie wird von Athena gesetzt.
|
||||||
|
|
||||||
|
Eigenschaften:
|
||||||
|
|
||||||
|
- `HttpOnly`
|
||||||
|
- `SameSite=Lax`
|
||||||
|
- `Path=/`
|
||||||
|
- `Secure` in Produktion
|
||||||
|
- Ablaufzeit orientiert sich an der JWT-Ablaufzeit
|
||||||
|
|
||||||
|
Tokens duerfen nicht in `localStorage` oder `sessionStorage` gespeichert werden.
|
||||||
|
|
||||||
|
### JWT
|
||||||
|
|
||||||
|
Hermes erzeugt JWT Access Tokens.
|
||||||
|
|
||||||
|
Wichtige Claims:
|
||||||
|
|
||||||
|
- `sub`: Benutzername
|
||||||
|
- `exp`: Ablaufzeit
|
||||||
|
- `iat`: Ausstellungszeitpunkt
|
||||||
|
- `iss`: konfigurierter Issuer
|
||||||
|
- `type`: Token-Typ, aktuell `access`
|
||||||
|
|
||||||
|
### Issuer
|
||||||
|
|
||||||
|
Der Issuer wird ueber `JWT_ISSUER` konfiguriert. Hermes prueft den Issuer bei der Token-Validierung.
|
||||||
|
|
||||||
|
### Ablaufzeit
|
||||||
|
|
||||||
|
Die Ablaufzeit wird ueber `ACCESS_TOKEN_EXPIRE_MINUTES` konfiguriert. Athena nutzt die von Hermes gelieferte Ablaufzeit fuer das Cookie.
|
||||||
|
|
||||||
|
### `proxy.ts`
|
||||||
|
|
||||||
|
Athena schuetzt Routen ueber `frontend/athena/proxy.ts`.
|
||||||
|
|
||||||
|
Aufgaben:
|
||||||
|
|
||||||
|
- geschuetzte Bereiche ohne Cookie auf `/login` umleiten
|
||||||
|
- eingeloggte Benutzer von `/login` auf `/dashboard` umleiten
|
||||||
|
|
||||||
|
## API-Datenfluss
|
||||||
|
|
||||||
|
Standardfluss:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser -> Athena /api/... -> Hermes -> PostgreSQL
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiel Benutzerliste:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser
|
||||||
|
GET /api/users
|
||||||
|
|
||||||
|
Athena
|
||||||
|
GET {HERMES_INTERNAL_URL}/users
|
||||||
|
Authorization: Bearer <jwt aus HttpOnly Cookie>
|
||||||
|
|
||||||
|
Hermes
|
||||||
|
SQLAlchemy Query gegen PostgreSQL
|
||||||
|
```
|
||||||
|
|
||||||
|
Athena ist die einzige API-Oberflaeche fuer den Browser.
|
||||||
|
|
||||||
|
## RBAC: Rollen und Berechtigungen
|
||||||
|
|
||||||
|
Olympus verwendet ein serverseitiges RBAC-System als Grundlage fuer alle CRM-Module.
|
||||||
|
|
||||||
|
Konzepte:
|
||||||
|
|
||||||
|
- Benutzer besitzen genau eine primaere Rolle.
|
||||||
|
- Rollen besitzen mehrere Berechtigungen.
|
||||||
|
- Berechtigungen sind stabile String-Keys wie `users.read`.
|
||||||
|
- Hermes prueft Berechtigungen serverseitig ueber Dependencies.
|
||||||
|
- Athena darf Permissions fuer UI-Sichtbarkeit nutzen, aber niemals als Auth-Quelle.
|
||||||
|
|
||||||
|
Tabellen:
|
||||||
|
|
||||||
|
- `roles`
|
||||||
|
- `permissions`
|
||||||
|
- `role_permissions`
|
||||||
|
- `users.role_id`
|
||||||
|
|
||||||
|
Das alte `users.role`-Feld bleibt vorerst als Legacy-Kompatibilitaet erhalten. Neue Logik verwendet `users.role_id` und die `roles`-Beziehung.
|
||||||
|
|
||||||
|
### Standardrollen
|
||||||
|
|
||||||
|
- `administrator`
|
||||||
|
- `management`
|
||||||
|
- `sales`
|
||||||
|
- `technician`
|
||||||
|
- `support`
|
||||||
|
- `warehouse`
|
||||||
|
- `guest`
|
||||||
|
|
||||||
|
`administrator` erhaelt alle Berechtigungen. `management` erhaelt mindestens Lesezugriff auf Dashboard, Benutzer, Kunden, Projekte und Tickets. `guest` erhaelt minimal `dashboard.read`.
|
||||||
|
|
||||||
|
### Standardberechtigungen
|
||||||
|
|
||||||
|
Benutzer:
|
||||||
|
|
||||||
|
- `users.read`
|
||||||
|
- `users.create`
|
||||||
|
- `users.update`
|
||||||
|
- `users.delete`
|
||||||
|
- `users.password.update`
|
||||||
|
|
||||||
|
Rollen:
|
||||||
|
|
||||||
|
- `roles.read`
|
||||||
|
- `roles.create`
|
||||||
|
- `roles.update`
|
||||||
|
- `roles.delete`
|
||||||
|
- `roles.assign`
|
||||||
|
|
||||||
|
Kunden:
|
||||||
|
|
||||||
|
- `customers.read`
|
||||||
|
- `customers.create`
|
||||||
|
- `customers.update`
|
||||||
|
- `customers.delete`
|
||||||
|
|
||||||
|
Projekte:
|
||||||
|
|
||||||
|
- `projects.read`
|
||||||
|
- `projects.create`
|
||||||
|
- `projects.update`
|
||||||
|
- `projects.delete`
|
||||||
|
|
||||||
|
Tickets:
|
||||||
|
|
||||||
|
- `tickets.read`
|
||||||
|
- `tickets.create`
|
||||||
|
- `tickets.update`
|
||||||
|
- `tickets.delete`
|
||||||
|
|
||||||
|
Dashboard und System:
|
||||||
|
|
||||||
|
- `dashboard.read`
|
||||||
|
- `system.settings.read`
|
||||||
|
- `system.settings.update`
|
||||||
|
|
||||||
|
### Berechtigungspruefung
|
||||||
|
|
||||||
|
Zentrale Hermes-Dependencies:
|
||||||
|
|
||||||
|
- `get_current_user`
|
||||||
|
- `get_current_active_user`
|
||||||
|
- `require_permission("permission.name")`
|
||||||
|
- `require_any_permission([...])`
|
||||||
|
- `require_all_permissions([...])`
|
||||||
|
|
||||||
|
Backend-Endpunkte muessen immer selbst pruefen. Frontend-Helfer wie `hasPermission` dienen nur dazu, Buttons oder Navigation auszublenden.
|
||||||
|
|
||||||
|
### Rollen-API
|
||||||
|
|
||||||
|
Hermes:
|
||||||
|
|
||||||
|
- `GET /roles`
|
||||||
|
- `GET /roles/{id}`
|
||||||
|
- `POST /roles`
|
||||||
|
- `PUT /roles/{id}`
|
||||||
|
- `DELETE /roles/{id}`
|
||||||
|
- `PUT /roles/{id}/permissions`
|
||||||
|
- `GET /permissions`
|
||||||
|
|
||||||
|
Athena stellt entsprechende BFF-Routen unter `/api/roles`, `/api/permissions` und `/api/me` bereit.
|
||||||
|
|
||||||
|
## Benutzerverwaltung
|
||||||
|
|
||||||
|
Die Benutzerverwaltung ist das erste Enterprise-Modul und dient als Vorlage fuer weitere Module.
|
||||||
|
|
||||||
|
Funktionen:
|
||||||
|
|
||||||
|
- Benutzerliste
|
||||||
|
- Detailseite
|
||||||
|
- Erstellen
|
||||||
|
- Bearbeiten
|
||||||
|
- Loeschen
|
||||||
|
- Passwort separat aendern
|
||||||
|
- Suche, Filter, Sortierung und Pagination im Frontend
|
||||||
|
|
||||||
|
Backend-Endpunkte in Hermes:
|
||||||
|
|
||||||
|
- `GET /users`
|
||||||
|
- `GET /users/{id}`
|
||||||
|
- `POST /users`
|
||||||
|
- `PUT /users/{id}`
|
||||||
|
- `PUT /users/{id}/password`
|
||||||
|
- `DELETE /users/{id}`
|
||||||
|
|
||||||
|
Athena stellt die passenden BFF-Routen unter `/api/users` und `/api/users/[id]` bereit.
|
||||||
|
|
||||||
|
Diese Endpunkte sind per RBAC geschuetzt:
|
||||||
|
|
||||||
|
- `GET /users` und `GET /users/{id}`: `users.read`
|
||||||
|
- `POST /users`: `users.create` und fuer Rollenzuweisung `roles.assign`
|
||||||
|
- `PUT /users/{id}`: `users.update`; Rollenwechsel benoetigt `roles.assign`
|
||||||
|
- `PUT /users/{id}/password`: `users.password.update`
|
||||||
|
- `DELETE /users/{id}`: `users.delete`
|
||||||
|
|
||||||
|
### Passwortaenderung
|
||||||
|
|
||||||
|
Wenn beim Bearbeiten kein Passwort uebergeben wird, bleibt das bestehende Passwort unveraendert.
|
||||||
|
|
||||||
|
Wenn ein Passwort gesetzt wird, wird es in Hermes mit `pwdlib` neu gehasht.
|
||||||
|
|
||||||
|
### Self-Delete-Schutz
|
||||||
|
|
||||||
|
Der aktuell angemeldete Benutzer darf sich nicht selbst loeschen. Hermes verhindert dies serverseitig.
|
||||||
|
|
||||||
|
### Rollenmodell
|
||||||
|
|
||||||
|
Benutzer besitzen eine primaere RBAC-Rolle ueber `role_id`. Die Rolle bestimmt die serverseitigen Berechtigungen.
|
||||||
|
|
||||||
|
## Verzeichnisstruktur
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/hermes
|
||||||
|
app/
|
||||||
|
api/
|
||||||
|
core/
|
||||||
|
db/
|
||||||
|
models/
|
||||||
|
repositories/
|
||||||
|
schemas/
|
||||||
|
alembic/
|
||||||
|
alembic.ini
|
||||||
|
dockerfile
|
||||||
|
pyproject.toml
|
||||||
|
|
||||||
|
frontend/athena
|
||||||
|
app/
|
||||||
|
api/
|
||||||
|
roles/
|
||||||
|
users/
|
||||||
|
components/
|
||||||
|
lib/
|
||||||
|
types/
|
||||||
|
proxy.ts
|
||||||
|
Dockerfile
|
||||||
|
package.json
|
||||||
|
|
||||||
|
docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Umgebungsvariablen
|
||||||
|
|
||||||
|
### Hermes
|
||||||
|
|
||||||
|
`DATABASE_URL`
|
||||||
|
: PostgreSQL-Verbindungsstring.
|
||||||
|
|
||||||
|
`SECRET_KEY`
|
||||||
|
: Signierschluessel fuer JWTs. Muss geheim bleiben.
|
||||||
|
|
||||||
|
`JWT_ISSUER`
|
||||||
|
: Erwarteter JWT-Issuer, z. B. `hermes`.
|
||||||
|
|
||||||
|
`ACCESS_TOKEN_EXPIRE_MINUTES`
|
||||||
|
: Ablaufzeit fuer Access Tokens in Minuten.
|
||||||
|
|
||||||
|
### Athena
|
||||||
|
|
||||||
|
`HERMES_INTERNAL_URL`
|
||||||
|
: Serverseitige URL, ueber die Athena Hermes erreicht.
|
||||||
|
|
||||||
|
`AUTH_COOKIE_SECURE`
|
||||||
|
: Steuert das `Secure`-Flag des Cookies. In Produktion `true`.
|
||||||
|
|
||||||
|
## Datenbankmigrationen
|
||||||
|
|
||||||
|
Olympus verwendet Alembic fuer Datenbankmigrationen.
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
|
||||||
|
- Schemaaenderungen immer ueber Alembic.
|
||||||
|
- Migrationen muessen ins Repository aufgenommen werden.
|
||||||
|
- Vor Deployment `alembic upgrade head` testen.
|
||||||
|
- Keine manuellen DB-Aenderungen ausser in Notfaellen.
|
||||||
|
|
||||||
|
Typischer Ablauf:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/hermes
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sicherheitsprinzipien
|
||||||
|
|
||||||
|
Verbindliche Regeln:
|
||||||
|
|
||||||
|
- Keine Tokens im `localStorage`.
|
||||||
|
- Keine Tokens im `sessionStorage`.
|
||||||
|
- Keine Browserzugriffe auf Hermes.
|
||||||
|
- Keine Cross-Origin-Cookies.
|
||||||
|
- Hermes bleibt Cookie-frei.
|
||||||
|
- Athena setzt HttpOnly Cookies.
|
||||||
|
- Mutierende Athena-API-Routes pruefen Same-Origin.
|
||||||
|
- Hermes prueft JWTs serverseitig.
|
||||||
|
- Hermes prueft RBAC-Berechtigungen serverseitig.
|
||||||
|
- Secrets gehoeren in Umgebungsvariablen, nicht in den Code.
|
||||||
|
|
||||||
|
## Produktionshinweise
|
||||||
|
|
||||||
|
Fuer Produktion gilt:
|
||||||
|
|
||||||
|
- HTTPS ist erforderlich.
|
||||||
|
- `AUTH_COOKIE_SECURE=true` setzen.
|
||||||
|
- Reverse Proxy ist moeglich und soll Forwarded Header korrekt setzen.
|
||||||
|
- `HERMES_INTERNAL_URL` muss serverseitig erreichbar sein.
|
||||||
|
- `SECRET_KEY` muss stark, geheim und stabil sein.
|
||||||
|
- Datenbankmigrationen vor App-Rollout ausfuehren.
|
||||||
|
|
||||||
|
Lokaler HTTP-Betrieb ist nur fuer Entwicklung gedacht. In Produktion duerfen keine unsicheren Cookie-Einstellungen verwendet werden.
|
||||||
270
CONTRIBUTING.md
Normal file
270
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
# Mitwirken an Olympus CRM
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt verbindliche Entwicklungsregeln fuer Olympus CRM.
|
||||||
|
|
||||||
|
Ziel ist ein stabiler, nachvollziehbarer und produktionsfaehiger Entwicklungsprozess. Keine Quickfixes, keine Workarounds, keine Architektur-Abkuerzungen.
|
||||||
|
|
||||||
|
## Branching-Modell
|
||||||
|
|
||||||
|
`main` bleibt immer lauffaehig.
|
||||||
|
|
||||||
|
Neue Arbeit erfolgt in eigenen Branches:
|
||||||
|
|
||||||
|
- Features: `feature/<name>`
|
||||||
|
- Bugfixes: `fix/<name>`
|
||||||
|
- Dokumentation: `docs/<name>`
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
```text
|
||||||
|
feature/users-detail-page
|
||||||
|
feature/customer-module
|
||||||
|
fix/auth-cookie-localhost
|
||||||
|
docs/architecture
|
||||||
|
```
|
||||||
|
|
||||||
|
Direkte Commits auf `main` sind zu vermeiden.
|
||||||
|
|
||||||
|
## Commit-Konventionen
|
||||||
|
|
||||||
|
Olympus verwendet Conventional Commits.
|
||||||
|
|
||||||
|
Format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
type(scope): kurze beschreibung
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
```text
|
||||||
|
feat(auth): add bff login route
|
||||||
|
feat(users): implement user detail page
|
||||||
|
fix(auth): accept forwarded origin behind proxy
|
||||||
|
docs: add architecture documentation
|
||||||
|
refactor(users): extract reusable data table
|
||||||
|
```
|
||||||
|
|
||||||
|
Zulaessige Typen:
|
||||||
|
|
||||||
|
- `feat`: neue Funktion
|
||||||
|
- `fix`: Fehlerbehebung
|
||||||
|
- `docs`: Dokumentation
|
||||||
|
- `refactor`: interne Umstrukturierung ohne Verhaltensaenderung
|
||||||
|
- `test`: Tests
|
||||||
|
- `chore`: Wartung, Build, Tooling
|
||||||
|
|
||||||
|
Commits sollen klein, fachlich zusammenhaengend und reviewbar sein.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
Eine Aenderung gilt erst als fertig, wenn diese Punkte erfuellt sind:
|
||||||
|
|
||||||
|
- `npm run lint` erfolgreich in `frontend/athena`
|
||||||
|
- `npx next build --webpack` erfolgreich in `frontend/athena`
|
||||||
|
- `python3 -m compileall backend/hermes/app` erfolgreich im Projektroot
|
||||||
|
- Alembic-Migrationen geprueft, falls Datenbankschema betroffen ist
|
||||||
|
- Docker Compose startet
|
||||||
|
- keine `.env` im Git
|
||||||
|
- keine `node_modules` im Git
|
||||||
|
- keine `.next` im Git
|
||||||
|
- keine `.venv` im Git
|
||||||
|
- keine toten Imports
|
||||||
|
- keine ungenutzten Dateien
|
||||||
|
- keine Debug-Ausgaben wie `console.log`, `alert` oder `confirm`
|
||||||
|
|
||||||
|
Standard-Checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend/athena
|
||||||
|
npm run lint
|
||||||
|
npx next build --webpack
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m compileall backend/hermes/app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coding Standards
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- TypeScript strikt verwenden.
|
||||||
|
- Next.js 16 App Router Konventionen einhalten.
|
||||||
|
- Browser-API-Zugriffe nur ueber Athena `/api/...`.
|
||||||
|
- Wiederverwendbare Komponenten bevorzugen.
|
||||||
|
- UI-Zustaende immer abbilden: Loading, Error, Empty State.
|
||||||
|
- Keine Browser-Dialoge wie `alert()` oder `confirm()`.
|
||||||
|
- Keine Tokens in Browser-JavaScript speichern.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- FastAPI sauber nach API, Schema, Repository und Core trennen.
|
||||||
|
- SQLAlchemy 2.x Patterns verwenden.
|
||||||
|
- Pydantic v2 fuer Request- und Response-Modelle verwenden.
|
||||||
|
- HTTP-Fehler bewusst mit passenden Statuscodes ausgeben.
|
||||||
|
- Fachliche Konflikte als `409 Conflict`.
|
||||||
|
- Nicht gefundene Ressourcen als `404 Not Found`.
|
||||||
|
- Authentifizierung serverseitig pruefen.
|
||||||
|
- Berechtigungen mit `require_permission`, `require_any_permission` oder `require_all_permissions` pruefen.
|
||||||
|
|
||||||
|
### Allgemein
|
||||||
|
|
||||||
|
- Keine TODOs als Ersatz fuer fertige Implementierung.
|
||||||
|
- Keine Quickfixes.
|
||||||
|
- Keine Workarounds.
|
||||||
|
- Keine ungeprueften Annahmen bei Auth, Datenbank oder Docker.
|
||||||
|
- Bestehende Architektur respektieren.
|
||||||
|
|
||||||
|
## Architekturregeln
|
||||||
|
|
||||||
|
Diese Regeln sind verbindlich:
|
||||||
|
|
||||||
|
- Browser spricht niemals direkt mit Hermes.
|
||||||
|
- Alle Frontend-API-Zugriffe laufen ueber Athena `/api`.
|
||||||
|
- Athena ruft Hermes serverseitig auf.
|
||||||
|
- Hermes bleibt Cookie-frei.
|
||||||
|
- Authentifizierung bleibt serverseitig.
|
||||||
|
- Hermes erwartet JWT Bearer Tokens.
|
||||||
|
- Athena setzt und loescht das HttpOnly Cookie.
|
||||||
|
- Keine Cross-Origin-Cookie-Loesungen.
|
||||||
|
- RBAC wird serverseitig in Hermes durchgesetzt.
|
||||||
|
- Frontend-Permissions dienen nur der UI und ersetzen keine Backend-Pruefung.
|
||||||
|
|
||||||
|
Wenn eine Aufgabe diese Regeln zu verletzen scheint, muss zuerst die Architekturentscheidung geklaert werden.
|
||||||
|
|
||||||
|
## Neue Module
|
||||||
|
|
||||||
|
Neue CRM-Module sollen die bestehenden Muster wiederverwenden.
|
||||||
|
|
||||||
|
Beispiele fuer spaetere Module:
|
||||||
|
|
||||||
|
- Kunden
|
||||||
|
- Projekte
|
||||||
|
- Lieferanten
|
||||||
|
- Kontakte
|
||||||
|
- Mitarbeiter
|
||||||
|
|
||||||
|
Verbindliche Wiederverwendung:
|
||||||
|
|
||||||
|
- `DataTable` fuer tabellarische Listen
|
||||||
|
- `ConfirmDialog` fuer destruktive Aktionen
|
||||||
|
- `SearchInput` fuer Suchfelder
|
||||||
|
- `StatusBadge` oder aehnliche Badge-Komponenten fuer Statusanzeigen
|
||||||
|
- `api` Client aus `frontend/athena/lib/api.ts`
|
||||||
|
- Athena API-Routes als BFF-Schicht
|
||||||
|
- konsistente Fehlerbehandlung
|
||||||
|
- zentrale Permission-Helfer aus `frontend/athena/lib/permissions.ts`
|
||||||
|
|
||||||
|
Neue Module sollen mindestens diese UI-Zustaende unterstuetzen:
|
||||||
|
|
||||||
|
- Ladezustand
|
||||||
|
- Fehlerzustand
|
||||||
|
- Empty State
|
||||||
|
- Validierung
|
||||||
|
- Erfolg ohne Page Reload
|
||||||
|
|
||||||
|
### Neue Permissions
|
||||||
|
|
||||||
|
Neue Module muessen eigene stabile Permission-Strings erhalten.
|
||||||
|
|
||||||
|
Namensschema:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<module>.<action>
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
```text
|
||||||
|
customers.read
|
||||||
|
customers.create
|
||||||
|
projects.update
|
||||||
|
tickets.delete
|
||||||
|
```
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
|
||||||
|
- Permission in `backend/hermes/app/rbac/defaults.py` ergaenzen.
|
||||||
|
- Rollen-Mapping in `ROLE_PERMISSION_NAMES` pruefen.
|
||||||
|
- Falls noetig Alembic-Migration fuer bestehende Installationen ergaenzen.
|
||||||
|
- Hermes-Endpunkte mit `require_permission(...)` schuetzen.
|
||||||
|
- Athena UI-Aktionen mit `hasPermission(...)` ausblenden oder deaktivieren.
|
||||||
|
- Backend bleibt immer massgeblich.
|
||||||
|
|
||||||
|
## Review-Checkliste
|
||||||
|
|
||||||
|
Vor Merge pruefen:
|
||||||
|
|
||||||
|
- Auth-Architektur nicht gebrochen
|
||||||
|
- Browser ruft Hermes nicht direkt auf
|
||||||
|
- Docker laeuft
|
||||||
|
- Migrationen vorhanden, falls Schema geaendert wurde
|
||||||
|
- Migrationen getestet
|
||||||
|
- UI konsistent mit bestehenden Komponenten
|
||||||
|
- keine Secrets committed
|
||||||
|
- keine `.env` committed
|
||||||
|
- keine toten Imports
|
||||||
|
- keine ungenutzten Dateien
|
||||||
|
- keine Debug-Ausgaben
|
||||||
|
- keine Build-Artefakte committed
|
||||||
|
- keine neuen CORS- oder Cookie-Workarounds
|
||||||
|
- Permission-Pruefung fuer neue Endpunkte vorhanden
|
||||||
|
- Permission-UI nur als Komfort, nicht als Sicherheitsgrenze
|
||||||
|
|
||||||
|
## Migrationsregeln
|
||||||
|
|
||||||
|
Datenbankschema wird ausschliesslich ueber Alembic geaendert.
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
|
||||||
|
- Jede Schemaaenderung braucht eine Migration.
|
||||||
|
- Migrationen werden ins Repository aufgenommen.
|
||||||
|
- `alembic upgrade head` muss getestet werden.
|
||||||
|
- Downgrade sollte sinnvoll sein, sofern moeglich.
|
||||||
|
- Keine manuellen Produktions-DB-Aenderungen ohne Dokumentation.
|
||||||
|
|
||||||
|
Typischer Ablauf:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/hermes
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sicherheitsregeln
|
||||||
|
|
||||||
|
Verbindlich:
|
||||||
|
|
||||||
|
- Keine Secrets im Code.
|
||||||
|
- Keine Secrets in Commits.
|
||||||
|
- Keine Tokens im Browser-JavaScript.
|
||||||
|
- Keine Auth in `localStorage`.
|
||||||
|
- Keine Auth in `sessionStorage`.
|
||||||
|
- Keine CORS-Cookie-Workarounds.
|
||||||
|
- Keine Cross-Origin-Cookies.
|
||||||
|
- Produktiv nur HTTPS.
|
||||||
|
- `AUTH_COOKIE_SECURE=true` in Produktion.
|
||||||
|
- `SECRET_KEY` stark und geheim halten.
|
||||||
|
|
||||||
|
Bei Auth-Aenderungen muessen Login, Logout, geschuetzte Seiten und Athena-API-Routes gemeinsam geprueft werden.
|
||||||
|
|
||||||
|
Bei RBAC-Aenderungen muessen Rollen, Permissions, betroffene API-Endpunkte, Navigation und UI-Aktionen gemeinsam geprueft werden.
|
||||||
|
|
||||||
|
## Lokale Entwicklung
|
||||||
|
|
||||||
|
Lokaler Betrieb kann ueber Docker Compose erfolgen.
|
||||||
|
|
||||||
|
Wichtige lokale Einstellungen:
|
||||||
|
|
||||||
|
```env
|
||||||
|
AUTH_COOKIE_SECURE=false
|
||||||
|
ATHENA_PUBLIC_ORIGIN=http://localhost:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
Diese Werte sind nur fuer lokale HTTP-Entwicklung gedacht. In Produktion muessen sichere Werte verwendet werden.
|
||||||
|
|
||||||
|
## Dokumentationspflicht
|
||||||
|
|
||||||
|
Architekturentscheidungen, neue Module, neue Umgebungsvariablen und Datenbankmigrationen muessen dokumentiert werden.
|
||||||
|
|
||||||
|
Wenn sich die BFF-Architektur, Authentifizierung, Docker-Konfiguration oder Datenbankstruktur aendert, muss `ARCHITECTURE.md` aktualisiert werden.
|
||||||
269
backend/hermes/alembic/versions/7b2c9f4a0d31_add_rbac.py
Normal file
269
backend/hermes/alembic/versions/7b2c9f4a0d31_add_rbac.py
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
"""add rbac
|
||||||
|
|
||||||
|
Revision ID: 7b2c9f4a0d31
|
||||||
|
Revises: 3f4c7b8d1e2a
|
||||||
|
Create Date: 2026-07-02 13:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "7b2c9f4a0d31"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "3f4c7b8d1e2a"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
ROLES = [
|
||||||
|
("administrator", "Administrator", "Vollzugriff auf Olympus CRM"),
|
||||||
|
("management", "Management", "Lesender Zugriff auf operative Kernmodule"),
|
||||||
|
("sales", "Vertrieb", "Zugriff auf Kunden und Projekte"),
|
||||||
|
("technician", "Technik", "Zugriff auf Tickets und Projekte"),
|
||||||
|
("support", "Support", "Operativer Support-Zugriff"),
|
||||||
|
("warehouse", "Lager", "Zugriff auf lagernahe Prozesse"),
|
||||||
|
("guest", "Gast", "Minimaler Zugriff"),
|
||||||
|
]
|
||||||
|
|
||||||
|
PERMISSIONS = [
|
||||||
|
("users.read", "Benutzer lesen", "Benutzer anzeigen", "users"),
|
||||||
|
("users.create", "Benutzer erstellen", "Benutzer anlegen", "users"),
|
||||||
|
("users.update", "Benutzer bearbeiten", "Benutzer aktualisieren", "users"),
|
||||||
|
("users.delete", "Benutzer loeschen", "Benutzer entfernen", "users"),
|
||||||
|
("users.password.update", "Passwort aendern", "Benutzerpasswort aendern", "users"),
|
||||||
|
("roles.read", "Rollen lesen", "Rollen anzeigen", "roles"),
|
||||||
|
("roles.create", "Rollen erstellen", "Rollen anlegen", "roles"),
|
||||||
|
("roles.update", "Rollen bearbeiten", "Rollen aktualisieren", "roles"),
|
||||||
|
("roles.delete", "Rollen loeschen", "Rollen entfernen", "roles"),
|
||||||
|
("roles.assign", "Rollen zuweisen", "Berechtigungen Rollen zuweisen", "roles"),
|
||||||
|
("customers.read", "Kunden lesen", "Kunden anzeigen", "customers"),
|
||||||
|
("customers.create", "Kunden erstellen", "Kunden anlegen", "customers"),
|
||||||
|
("customers.update", "Kunden bearbeiten", "Kunden aktualisieren", "customers"),
|
||||||
|
("customers.delete", "Kunden loeschen", "Kunden entfernen", "customers"),
|
||||||
|
("projects.read", "Projekte lesen", "Projekte anzeigen", "projects"),
|
||||||
|
("projects.create", "Projekte erstellen", "Projekte anlegen", "projects"),
|
||||||
|
("projects.update", "Projekte bearbeiten", "Projekte aktualisieren", "projects"),
|
||||||
|
("projects.delete", "Projekte loeschen", "Projekte entfernen", "projects"),
|
||||||
|
("tickets.read", "Tickets lesen", "Tickets anzeigen", "tickets"),
|
||||||
|
("tickets.create", "Tickets erstellen", "Tickets anlegen", "tickets"),
|
||||||
|
("tickets.update", "Tickets bearbeiten", "Tickets aktualisieren", "tickets"),
|
||||||
|
("tickets.delete", "Tickets loeschen", "Tickets entfernen", "tickets"),
|
||||||
|
("dashboard.read", "Dashboard lesen", "Dashboard anzeigen", "dashboard"),
|
||||||
|
("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"),
|
||||||
|
("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "system"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"roles",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("description", sa.String(length=500), server_default="", nullable=False),
|
||||||
|
sa.Column("is_system", sa.Boolean(), server_default=sa.true(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_roles_name"), "roles", ["name"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"permissions",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(length=160), nullable=False),
|
||||||
|
sa.Column("description", sa.String(length=500), server_default="", nullable=False),
|
||||||
|
sa.Column("module", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_permissions_module"), "permissions", ["module"], unique=False)
|
||||||
|
op.create_index(op.f("ix_permissions_name"), "permissions", ["name"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"role_permissions",
|
||||||
|
sa.Column("role_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("permission_id", sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["permission_id"], ["permissions.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("role_id", "permission_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
roles_table = sa.table(
|
||||||
|
"roles",
|
||||||
|
sa.column("name", sa.String),
|
||||||
|
sa.column("display_name", sa.String),
|
||||||
|
sa.column("description", sa.String),
|
||||||
|
sa.column("is_system", sa.Boolean),
|
||||||
|
)
|
||||||
|
permissions_table = sa.table(
|
||||||
|
"permissions",
|
||||||
|
sa.column("name", sa.String),
|
||||||
|
sa.column("display_name", sa.String),
|
||||||
|
sa.column("description", sa.String),
|
||||||
|
sa.column("module", sa.String),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.bulk_insert(
|
||||||
|
roles_table,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"display_name": display_name,
|
||||||
|
"description": description,
|
||||||
|
"is_system": True,
|
||||||
|
}
|
||||||
|
for name, display_name, description in ROLES
|
||||||
|
],
|
||||||
|
)
|
||||||
|
op.bulk_insert(
|
||||||
|
permissions_table,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"display_name": display_name,
|
||||||
|
"description": description,
|
||||||
|
"module": module,
|
||||||
|
}
|
||||||
|
for name, display_name, description, module in PERMISSIONS
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT roles.id, permissions.id
|
||||||
|
FROM roles
|
||||||
|
CROSS JOIN permissions
|
||||||
|
WHERE roles.name = 'administrator'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT roles.id, permissions.id
|
||||||
|
FROM roles
|
||||||
|
JOIN permissions ON permissions.name IN (
|
||||||
|
'dashboard.read',
|
||||||
|
'users.read',
|
||||||
|
'customers.read',
|
||||||
|
'projects.read',
|
||||||
|
'tickets.read'
|
||||||
|
)
|
||||||
|
WHERE roles.name = 'management'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT roles.id, permissions.id
|
||||||
|
FROM roles
|
||||||
|
JOIN permissions ON permissions.name IN (
|
||||||
|
'dashboard.read',
|
||||||
|
'customers.read',
|
||||||
|
'customers.create',
|
||||||
|
'customers.update',
|
||||||
|
'projects.read',
|
||||||
|
'tickets.read'
|
||||||
|
)
|
||||||
|
WHERE roles.name = 'sales'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT roles.id, permissions.id
|
||||||
|
FROM roles
|
||||||
|
JOIN permissions ON permissions.name IN (
|
||||||
|
'dashboard.read',
|
||||||
|
'projects.read',
|
||||||
|
'tickets.read',
|
||||||
|
'tickets.create',
|
||||||
|
'tickets.update'
|
||||||
|
)
|
||||||
|
WHERE roles.name = 'technician'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT roles.id, permissions.id
|
||||||
|
FROM roles
|
||||||
|
JOIN permissions ON permissions.name IN (
|
||||||
|
'dashboard.read',
|
||||||
|
'users.read',
|
||||||
|
'customers.read',
|
||||||
|
'tickets.read',
|
||||||
|
'tickets.create',
|
||||||
|
'tickets.update'
|
||||||
|
)
|
||||||
|
WHERE roles.name = 'support'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT roles.id, permissions.id
|
||||||
|
FROM roles
|
||||||
|
JOIN permissions ON permissions.name IN (
|
||||||
|
'dashboard.read'
|
||||||
|
)
|
||||||
|
WHERE roles.name IN ('warehouse', 'guest')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column("users", sa.Column("role_id", sa.Integer(), nullable=True))
|
||||||
|
op.create_index(op.f("ix_users_role_id"), "users", ["role_id"], unique=False)
|
||||||
|
op.create_foreign_key("fk_users_role_id_roles", "users", "roles", ["role_id"], ["id"])
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET role_id = roles.id
|
||||||
|
FROM roles
|
||||||
|
WHERE roles.name = CASE
|
||||||
|
WHEN users.role IN ('admin', 'administrator') THEN 'administrator'
|
||||||
|
WHEN users.role IN ('manager', 'management') THEN 'management'
|
||||||
|
WHEN users.role = 'sales' THEN 'sales'
|
||||||
|
WHEN users.role IN ('technician', 'tech') THEN 'technician'
|
||||||
|
WHEN users.role = 'warehouse' THEN 'warehouse'
|
||||||
|
WHEN users.role = 'guest' THEN 'guest'
|
||||||
|
ELSE 'support'
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET role_id = (SELECT id FROM roles WHERE name = 'administrator')
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id
|
||||||
|
FROM users
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM users
|
||||||
|
JOIN roles ON users.role_id = roles.id
|
||||||
|
WHERE roles.name = 'administrator'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.alter_column("users", "role_id", existing_type=sa.Integer(), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("fk_users_role_id_roles", "users", type_="foreignkey")
|
||||||
|
op.drop_index(op.f("ix_users_role_id"), table_name="users")
|
||||||
|
op.drop_column("users", "role_id")
|
||||||
|
op.drop_table("role_permissions")
|
||||||
|
op.drop_index(op.f("ix_permissions_name"), table_name="permissions")
|
||||||
|
op.drop_index(op.f("ix_permissions_module"), table_name="permissions")
|
||||||
|
op.drop_table("permissions")
|
||||||
|
op.drop_index(op.f("ix_roles_name"), table_name="roles")
|
||||||
|
op.drop_table("roles")
|
||||||
|
|
@ -6,8 +6,11 @@ from app.core.security import (
|
||||||
get_access_token_expire_seconds,
|
get_access_token_expire_seconds,
|
||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
|
from app.core.rbac import get_current_active_user, get_user_permission_names
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.repositories.user_repository import UserRepository
|
||||||
|
from app.schemas.rbac import CurrentUserResponse
|
||||||
from app.schemas.user import LoginRequest, LoginResponse
|
from app.schemas.user import LoginRequest, LoginResponse
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
|
|
@ -21,7 +24,7 @@ def login(
|
||||||
login: LoginRequest,
|
login: LoginRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
user = db.query(User).filter(User.username == login.username).first()
|
user = UserRepository.get_by_username(db, login.username)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -43,3 +46,14 @@ def login(
|
||||||
"expires_in": get_access_token_expire_seconds(),
|
"expires_in": get_access_token_expire_seconds(),
|
||||||
"user": user,
|
"user": user,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=CurrentUserResponse)
|
||||||
|
def me(current_user: User = Depends(get_current_active_user)):
|
||||||
|
return {
|
||||||
|
"id": current_user.id,
|
||||||
|
"username": current_user.username,
|
||||||
|
"email": current_user.email,
|
||||||
|
"role": current_user.primary_role.name,
|
||||||
|
"permissions": sorted(get_user_permission_names(current_user)),
|
||||||
|
}
|
||||||
|
|
|
||||||
27
backend/hermes/app/api/permissions.py
Normal file
27
backend/hermes/app/api/permissions.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.rbac import require_permission
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.rbac_repository import RbacRepository
|
||||||
|
from app.schemas.rbac import PermissionResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/permissions",
|
||||||
|
tags=["Permissions"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[PermissionResponse])
|
||||||
|
@router.get("/", response_model=list[PermissionResponse], include_in_schema=False)
|
||||||
|
def get_permissions(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("roles.read")),
|
||||||
|
):
|
||||||
|
logger.info("permissions.list", extra={"actor_user_id": current_user.id})
|
||||||
|
return RbacRepository.get_permissions(db)
|
||||||
153
backend/hermes/app/api/roles.py
Normal file
153
backend/hermes/app/api/roles.py
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.rbac import require_permission
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.rbac_repository import RbacRepository
|
||||||
|
from app.schemas.rbac import RoleCreate, RolePermissionUpdate, RoleResponse, RoleUpdate
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/roles",
|
||||||
|
tags=["Roles"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_role_or_404(db: Session, role_id: int):
|
||||||
|
role = RbacRepository.get_role_by_id(db, role_id)
|
||||||
|
|
||||||
|
if role is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Rolle nicht gefunden",
|
||||||
|
)
|
||||||
|
|
||||||
|
return role
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_unique_role_name(db: Session, name: str, role_id: int | None = None) -> None:
|
||||||
|
role = RbacRepository.get_role_by_name(db, name)
|
||||||
|
if role is not None and role.id != role_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Rollenname ist bereits vergeben",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_permission_ids_exist(db: Session, permission_ids: list[int]) -> None:
|
||||||
|
permissions = RbacRepository.get_permissions_by_ids(db, permission_ids)
|
||||||
|
if len(permissions) != len(set(permission_ids)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Mindestens eine Berechtigung existiert nicht",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[RoleResponse])
|
||||||
|
@router.get("/", response_model=list[RoleResponse], include_in_schema=False)
|
||||||
|
def get_roles(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("roles.read")),
|
||||||
|
):
|
||||||
|
logger.info("roles.list", extra={"actor_user_id": current_user.id})
|
||||||
|
return RbacRepository.get_roles(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{role_id}", response_model=RoleResponse)
|
||||||
|
def get_role(
|
||||||
|
role_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("roles.read")),
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"roles.detail",
|
||||||
|
extra={"actor_user_id": current_user.id, "target_role_id": role_id},
|
||||||
|
)
|
||||||
|
return get_role_or_404(db, role_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
@router.post(
|
||||||
|
"/",
|
||||||
|
response_model=RoleResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
def create_role(
|
||||||
|
role: RoleCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("roles.create")),
|
||||||
|
):
|
||||||
|
ensure_unique_role_name(db, role.name)
|
||||||
|
ensure_permission_ids_exist(db, role.permission_ids)
|
||||||
|
|
||||||
|
logger.info("roles.create", extra={"actor_user_id": current_user.id})
|
||||||
|
return RbacRepository.create_role(db, role)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{role_id}", response_model=RoleResponse)
|
||||||
|
def update_role(
|
||||||
|
role_id: int,
|
||||||
|
role: RoleUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("roles.update")),
|
||||||
|
):
|
||||||
|
db_role = get_role_or_404(db, role_id)
|
||||||
|
ensure_unique_role_name(db, role.name, role_id)
|
||||||
|
ensure_permission_ids_exist(db, role.permission_ids)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"roles.update",
|
||||||
|
extra={"actor_user_id": current_user.id, "target_role_id": role_id},
|
||||||
|
)
|
||||||
|
return RbacRepository.update_role(db, db_role, role)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_role(
|
||||||
|
role_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("roles.delete")),
|
||||||
|
):
|
||||||
|
db_role = get_role_or_404(db, role_id)
|
||||||
|
if db_role.is_system:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Systemrollen dürfen nicht gelöscht werden",
|
||||||
|
)
|
||||||
|
|
||||||
|
assigned_user_id = db.scalar(select(User.id).where(User.role_id == role_id).limit(1))
|
||||||
|
if assigned_user_id is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Rolle ist Benutzern zugewiesen und kann nicht gelöscht werden",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"roles.delete",
|
||||||
|
extra={"actor_user_id": current_user.id, "target_role_id": role_id},
|
||||||
|
)
|
||||||
|
RbacRepository.delete_role(db, db_role)
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{role_id}/permissions", response_model=RoleResponse)
|
||||||
|
def update_role_permissions(
|
||||||
|
role_id: int,
|
||||||
|
permission_update: RolePermissionUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("roles.assign")),
|
||||||
|
):
|
||||||
|
db_role = get_role_or_404(db, role_id)
|
||||||
|
ensure_permission_ids_exist(db, permission_update.permission_ids)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"roles.permissions_update",
|
||||||
|
extra={"actor_user_id": current_user.id, "target_role_id": role_id},
|
||||||
|
)
|
||||||
|
return RbacRepository.update_role_permissions(db, db_role, permission_update)
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.security import verify_access_token
|
from app.core.rbac import get_user_permission_names, require_permission
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.repositories.rbac_repository import RbacRepository
|
||||||
from app.repositories.user_repository import UserRepository
|
from app.repositories.user_repository import UserRepository
|
||||||
from app.schemas.user import UserCreate, UserPasswordUpdate, UserResponse, UserUpdate
|
from app.schemas.user import UserCreate, UserPasswordUpdate, UserResponse, UserUpdate
|
||||||
|
|
||||||
|
|
@ -17,45 +17,6 @@ router = APIRouter(
|
||||||
tags=["Users"],
|
tags=["Users"],
|
||||||
)
|
)
|
||||||
|
|
||||||
bearer_scheme = HTTPBearer(auto_error=False)
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
|
||||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> User:
|
|
||||||
if credentials is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Nicht authentifiziert",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
username = verify_access_token(credentials.credentials)
|
|
||||||
user = UserRepository.get_by_username(db, username)
|
|
||||||
|
|
||||||
if user is None or not user.is_active:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Nicht authentifiziert",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def require_role(*allowed_roles: str):
|
|
||||||
def dependency(current_user: User = Depends(get_current_user)) -> User:
|
|
||||||
if current_user.role not in allowed_roles:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Keine Berechtigung",
|
|
||||||
)
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
return dependency
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_or_404(db: Session, user_id: int) -> User:
|
def get_user_or_404(db: Session, user_id: int) -> User:
|
||||||
db_user = UserRepository.get_by_id(db, user_id)
|
db_user = UserRepository.get_by_id(db, user_id)
|
||||||
|
|
||||||
|
|
@ -80,11 +41,36 @@ def raise_conflict(field: str) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_actor_permission(current_user: User, permission: str) -> None:
|
||||||
|
if permission not in get_user_permission_names(current_user):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Keine Berechtigung",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_role(db: Session, user: UserCreate | UserUpdate) -> None:
|
||||||
|
role = None
|
||||||
|
if user.role_id is not None:
|
||||||
|
role = RbacRepository.get_role_by_id(db, user.role_id)
|
||||||
|
else:
|
||||||
|
role = RbacRepository.get_role_by_name(db, user.role)
|
||||||
|
|
||||||
|
if role is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Rolle nicht gefunden",
|
||||||
|
)
|
||||||
|
|
||||||
|
user.role_id = role.id
|
||||||
|
user.role = role.name
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[UserResponse])
|
@router.get("", response_model=list[UserResponse])
|
||||||
@router.get("/", response_model=list[UserResponse], include_in_schema=False)
|
@router.get("/", response_model=list[UserResponse], include_in_schema=False)
|
||||||
def get_users(
|
def get_users(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_permission("users.read")),
|
||||||
):
|
):
|
||||||
logger.info("users.list", extra={"actor_user_id": current_user.id})
|
logger.info("users.list", extra={"actor_user_id": current_user.id})
|
||||||
return UserRepository.get_all(db)
|
return UserRepository.get_all(db)
|
||||||
|
|
@ -94,7 +80,7 @@ def get_users(
|
||||||
def get_user(
|
def get_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_permission("users.read")),
|
||||||
):
|
):
|
||||||
logger.info(
|
logger.info(
|
||||||
"users.detail",
|
"users.detail",
|
||||||
|
|
@ -113,8 +99,10 @@ def get_user(
|
||||||
def create_user(
|
def create_user(
|
||||||
user: UserCreate,
|
user: UserCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_permission("users.create")),
|
||||||
):
|
):
|
||||||
|
require_actor_permission(current_user, "roles.assign")
|
||||||
|
apply_role(db, user)
|
||||||
conflict = UserRepository.find_conflict(
|
conflict = UserRepository.find_conflict(
|
||||||
db,
|
db,
|
||||||
username=user.username,
|
username=user.username,
|
||||||
|
|
@ -133,9 +121,12 @@ def update_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
user: UserUpdate,
|
user: UserUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_permission("users.update")),
|
||||||
):
|
):
|
||||||
db_user = get_user_or_404(db, user_id)
|
db_user = get_user_or_404(db, user_id)
|
||||||
|
apply_role(db, user)
|
||||||
|
if user.role_id != db_user.role_id:
|
||||||
|
require_actor_permission(current_user, "roles.assign")
|
||||||
conflict = UserRepository.find_conflict(
|
conflict = UserRepository.find_conflict(
|
||||||
db,
|
db,
|
||||||
username=user.username,
|
username=user.username,
|
||||||
|
|
@ -158,7 +149,7 @@ def update_user_password(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
password_update: UserPasswordUpdate,
|
password_update: UserPasswordUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_permission("users.password.update")),
|
||||||
):
|
):
|
||||||
db_user = get_user_or_404(db, user_id)
|
db_user = get_user_or_404(db, user_id)
|
||||||
|
|
||||||
|
|
@ -173,7 +164,7 @@ def update_user_password(
|
||||||
def delete_user(
|
def delete_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(require_permission("users.delete")),
|
||||||
):
|
):
|
||||||
if current_user.id == user_id:
|
if current_user.id == user_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
|
||||||
94
backend/hermes/app/core/rbac.py
Normal file
94
backend/hermes/app/core/rbac.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.security import verify_access_token
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.user_repository import UserRepository
|
||||||
|
|
||||||
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
if credentials is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Nicht authentifiziert",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
username = verify_access_token(credentials.credentials)
|
||||||
|
user = UserRepository.get_by_username(db, username)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Nicht authentifiziert",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
if not current_user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Nicht authentifiziert",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_permission_names(user: User) -> set[str]:
|
||||||
|
if user.primary_role is None:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
return {permission.name for permission in user.primary_role.permissions}
|
||||||
|
|
||||||
|
|
||||||
|
def require_permission(permission: str):
|
||||||
|
def dependency(current_user: User = Depends(get_current_active_user)) -> User:
|
||||||
|
if permission not in get_user_permission_names(current_user):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Keine Berechtigung",
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
return dependency
|
||||||
|
|
||||||
|
|
||||||
|
def require_any_permission(permissions: Iterable[str]):
|
||||||
|
permission_set = set(permissions)
|
||||||
|
|
||||||
|
def dependency(current_user: User = Depends(get_current_active_user)) -> User:
|
||||||
|
if get_user_permission_names(current_user).isdisjoint(permission_set):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Keine Berechtigung",
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
return dependency
|
||||||
|
|
||||||
|
|
||||||
|
def require_all_permissions(permissions: Iterable[str]):
|
||||||
|
permission_set = set(permissions)
|
||||||
|
|
||||||
|
def dependency(current_user: User = Depends(get_current_active_user)) -> User:
|
||||||
|
if not permission_set.issubset(get_user_permission_names(current_user)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Keine Berechtigung",
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
return dependency
|
||||||
|
|
@ -20,6 +20,7 @@ class Base(DeclarativeBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# <<< HIER IMPORTIEREN >>>
|
# <<< HIER IMPORTIEREN >>>
|
||||||
|
import app.models.rbac
|
||||||
import app.models.user
|
import app.models.user
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,12 @@ from starlette import status
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from app.api.auth import router as auth_router
|
from app.api.auth import router as auth_router
|
||||||
|
from app.api.permissions import router as permissions_router
|
||||||
|
from app.api.roles import router as roles_router
|
||||||
from app.api.users import router as users_router
|
from app.api.users import router as users_router
|
||||||
|
from app.db.database import SessionLocal
|
||||||
from app.db.health import check_database
|
from app.db.health import check_database
|
||||||
|
from app.rbac.seed import seed_rbac
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Hermes API",
|
title="Hermes API",
|
||||||
|
|
@ -20,10 +24,21 @@ app = FastAPI(
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(users_router)
|
app.include_router(users_router)
|
||||||
|
app.include_router(roles_router)
|
||||||
|
app.include_router(permissions_router)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def startup_seed_rbac():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
seed_rbac(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(HTTPException)
|
@app.exception_handler(HTTPException)
|
||||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
|
||||||
58
backend/hermes/app/models/rbac.py
Normal file
58
backend/hermes/app/models/rbac.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Table, Column, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
role_permissions = Table(
|
||||||
|
"role_permissions",
|
||||||
|
Base.metadata,
|
||||||
|
Column("role_id", ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("permission_id", ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Role(Base):
|
||||||
|
__tablename__ = "roles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(80), unique=True, index=True)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(120))
|
||||||
|
description: Mapped[str] = mapped_column(String(500), default="", server_default="")
|
||||||
|
is_system: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
permissions: Mapped[list["Permission"]] = relationship(
|
||||||
|
secondary=role_permissions,
|
||||||
|
back_populates="roles",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Permission(Base):
|
||||||
|
__tablename__ = "permissions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(120), unique=True, index=True)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(160))
|
||||||
|
description: Mapped[str] = mapped_column(String(500), default="", server_default="")
|
||||||
|
module: Mapped[str] = mapped_column(String(80), index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
roles: Mapped[list[Role]] = relationship(
|
||||||
|
secondary=role_permissions,
|
||||||
|
back_populates="permissions",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
@ -2,8 +2,10 @@ from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean
|
from sqlalchemy import Boolean
|
||||||
from sqlalchemy import DateTime
|
from sqlalchemy import DateTime
|
||||||
|
from sqlalchemy import ForeignKey
|
||||||
from sqlalchemy import String
|
from sqlalchemy import String
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.orm import Mapped
|
from sqlalchemy.orm import Mapped
|
||||||
from sqlalchemy.orm import mapped_column
|
from sqlalchemy.orm import mapped_column
|
||||||
|
|
||||||
|
|
@ -48,6 +50,13 @@ class User(Base):
|
||||||
server_default="user"
|
server_default="user"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
role_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("roles.id"),
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
primary_role = relationship("Role", lazy="joined")
|
||||||
|
|
||||||
is_active: Mapped[bool] = mapped_column(
|
is_active: Mapped[bool] = mapped_column(
|
||||||
Boolean,
|
Boolean,
|
||||||
default=True,
|
default=True,
|
||||||
|
|
|
||||||
112
backend/hermes/app/rbac/defaults.py
Normal file
112
backend/hermes/app/rbac/defaults.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
STANDARD_ROLES = [
|
||||||
|
{
|
||||||
|
"name": "administrator",
|
||||||
|
"display_name": "Administrator",
|
||||||
|
"description": "Vollzugriff auf Olympus CRM",
|
||||||
|
"is_system": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "management",
|
||||||
|
"display_name": "Management",
|
||||||
|
"description": "Lesender Zugriff auf operative Kernmodule",
|
||||||
|
"is_system": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sales",
|
||||||
|
"display_name": "Vertrieb",
|
||||||
|
"description": "Zugriff auf Kunden und Projekte",
|
||||||
|
"is_system": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "technician",
|
||||||
|
"display_name": "Technik",
|
||||||
|
"description": "Zugriff auf Tickets und Projekte",
|
||||||
|
"is_system": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "support",
|
||||||
|
"display_name": "Support",
|
||||||
|
"description": "Operativer Support-Zugriff",
|
||||||
|
"is_system": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "warehouse",
|
||||||
|
"display_name": "Lager",
|
||||||
|
"description": "Zugriff auf lagernahe Prozesse",
|
||||||
|
"is_system": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "guest",
|
||||||
|
"display_name": "Gast",
|
||||||
|
"description": "Minimaler Zugriff",
|
||||||
|
"is_system": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
STANDARD_PERMISSIONS = [
|
||||||
|
("users.read", "Benutzer lesen", "Benutzer anzeigen", "users"),
|
||||||
|
("users.create", "Benutzer erstellen", "Benutzer anlegen", "users"),
|
||||||
|
("users.update", "Benutzer bearbeiten", "Benutzer aktualisieren", "users"),
|
||||||
|
("users.delete", "Benutzer loeschen", "Benutzer entfernen", "users"),
|
||||||
|
("users.password.update", "Passwort aendern", "Benutzerpasswort aendern", "users"),
|
||||||
|
("roles.read", "Rollen lesen", "Rollen anzeigen", "roles"),
|
||||||
|
("roles.create", "Rollen erstellen", "Rollen anlegen", "roles"),
|
||||||
|
("roles.update", "Rollen bearbeiten", "Rollen aktualisieren", "roles"),
|
||||||
|
("roles.delete", "Rollen loeschen", "Rollen entfernen", "roles"),
|
||||||
|
("roles.assign", "Rollen zuweisen", "Berechtigungen Rollen zuweisen", "roles"),
|
||||||
|
("customers.read", "Kunden lesen", "Kunden anzeigen", "customers"),
|
||||||
|
("customers.create", "Kunden erstellen", "Kunden anlegen", "customers"),
|
||||||
|
("customers.update", "Kunden bearbeiten", "Kunden aktualisieren", "customers"),
|
||||||
|
("customers.delete", "Kunden loeschen", "Kunden entfernen", "customers"),
|
||||||
|
("projects.read", "Projekte lesen", "Projekte anzeigen", "projects"),
|
||||||
|
("projects.create", "Projekte erstellen", "Projekte anlegen", "projects"),
|
||||||
|
("projects.update", "Projekte bearbeiten", "Projekte aktualisieren", "projects"),
|
||||||
|
("projects.delete", "Projekte loeschen", "Projekte entfernen", "projects"),
|
||||||
|
("tickets.read", "Tickets lesen", "Tickets anzeigen", "tickets"),
|
||||||
|
("tickets.create", "Tickets erstellen", "Tickets anlegen", "tickets"),
|
||||||
|
("tickets.update", "Tickets bearbeiten", "Tickets aktualisieren", "tickets"),
|
||||||
|
("tickets.delete", "Tickets loeschen", "Tickets entfernen", "tickets"),
|
||||||
|
("dashboard.read", "Dashboard lesen", "Dashboard anzeigen", "dashboard"),
|
||||||
|
("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"),
|
||||||
|
("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "system"),
|
||||||
|
]
|
||||||
|
|
||||||
|
ROLE_PERMISSION_NAMES = {
|
||||||
|
"administrator": "*",
|
||||||
|
"management": {
|
||||||
|
"dashboard.read",
|
||||||
|
"users.read",
|
||||||
|
"customers.read",
|
||||||
|
"projects.read",
|
||||||
|
"tickets.read",
|
||||||
|
},
|
||||||
|
"sales": {
|
||||||
|
"dashboard.read",
|
||||||
|
"customers.read",
|
||||||
|
"customers.create",
|
||||||
|
"customers.update",
|
||||||
|
"projects.read",
|
||||||
|
"tickets.read",
|
||||||
|
},
|
||||||
|
"technician": {
|
||||||
|
"dashboard.read",
|
||||||
|
"projects.read",
|
||||||
|
"tickets.read",
|
||||||
|
"tickets.create",
|
||||||
|
"tickets.update",
|
||||||
|
},
|
||||||
|
"support": {
|
||||||
|
"dashboard.read",
|
||||||
|
"users.read",
|
||||||
|
"customers.read",
|
||||||
|
"tickets.read",
|
||||||
|
"tickets.create",
|
||||||
|
"tickets.update",
|
||||||
|
},
|
||||||
|
"warehouse": {
|
||||||
|
"dashboard.read",
|
||||||
|
},
|
||||||
|
"guest": {
|
||||||
|
"dashboard.read",
|
||||||
|
},
|
||||||
|
}
|
||||||
63
backend/hermes/app/rbac/seed.py
Normal file
63
backend/hermes/app/rbac/seed.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.rbac import Permission, Role
|
||||||
|
from app.rbac.defaults import ROLE_PERMISSION_NAMES, STANDARD_PERMISSIONS, STANDARD_ROLES
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def seed_rbac(db: Session) -> None:
|
||||||
|
roles_by_name = {
|
||||||
|
role.name: role
|
||||||
|
for role in db.scalars(select(Role)).all()
|
||||||
|
}
|
||||||
|
permissions_by_name = {
|
||||||
|
permission.name: permission
|
||||||
|
for permission in db.scalars(select(Permission)).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
for role_data in STANDARD_ROLES:
|
||||||
|
role = roles_by_name.get(role_data["name"])
|
||||||
|
if role is None:
|
||||||
|
role = Role(**role_data)
|
||||||
|
db.add(role)
|
||||||
|
roles_by_name[role.name] = role
|
||||||
|
else:
|
||||||
|
role.display_name = role_data["display_name"]
|
||||||
|
role.description = role_data["description"]
|
||||||
|
role.is_system = role_data["is_system"]
|
||||||
|
|
||||||
|
for name, display_name, description, module in STANDARD_PERMISSIONS:
|
||||||
|
permission = permissions_by_name.get(name)
|
||||||
|
if permission is None:
|
||||||
|
permission = Permission(
|
||||||
|
name=name,
|
||||||
|
display_name=display_name,
|
||||||
|
description=description,
|
||||||
|
module=module,
|
||||||
|
)
|
||||||
|
db.add(permission)
|
||||||
|
permissions_by_name[name] = permission
|
||||||
|
else:
|
||||||
|
permission.display_name = display_name
|
||||||
|
permission.description = description
|
||||||
|
permission.module = module
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
all_permissions = set(permissions_by_name.values())
|
||||||
|
for role_name, permission_names in ROLE_PERMISSION_NAMES.items():
|
||||||
|
role = roles_by_name[role_name]
|
||||||
|
if permission_names == "*":
|
||||||
|
role.permissions = list(all_permissions)
|
||||||
|
else:
|
||||||
|
role.permissions = [
|
||||||
|
permissions_by_name[permission_name]
|
||||||
|
for permission_name in permission_names
|
||||||
|
]
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info("rbac.seeded")
|
||||||
93
backend/hermes/app/repositories/rbac_repository.py
Normal file
93
backend/hermes/app/repositories/rbac_repository.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from app.models.rbac import Permission, Role
|
||||||
|
from app.schemas.rbac import RoleCreate, RolePermissionUpdate, RoleUpdate
|
||||||
|
|
||||||
|
|
||||||
|
class RbacRepository:
|
||||||
|
@staticmethod
|
||||||
|
def get_roles(db: Session) -> list[Role]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(Role)
|
||||||
|
.options(selectinload(Role.permissions))
|
||||||
|
.order_by(Role.name)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_role_by_id(db: Session, role_id: int) -> Role | None:
|
||||||
|
return db.scalar(
|
||||||
|
select(Role)
|
||||||
|
.where(Role.id == role_id)
|
||||||
|
.options(selectinload(Role.permissions))
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_role_by_name(db: Session, name: str) -> Role | None:
|
||||||
|
return db.scalar(
|
||||||
|
select(Role)
|
||||||
|
.where(Role.name == name)
|
||||||
|
.options(selectinload(Role.permissions))
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_permissions(db: Session) -> list[Permission]:
|
||||||
|
return list(db.scalars(select(Permission).order_by(Permission.module, Permission.name)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_permissions_by_ids(db: Session, permission_ids: list[int]) -> list[Permission]:
|
||||||
|
if not permission_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return list(db.scalars(select(Permission).where(Permission.id.in_(permission_ids))))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_role(db: Session, role: RoleCreate) -> Role:
|
||||||
|
db_role = Role(
|
||||||
|
name=role.name,
|
||||||
|
display_name=role.display_name,
|
||||||
|
description=role.description,
|
||||||
|
is_system=False,
|
||||||
|
)
|
||||||
|
db_role.permissions = RbacRepository.get_permissions_by_ids(db, role.permission_ids)
|
||||||
|
|
||||||
|
db.add(db_role)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_role)
|
||||||
|
|
||||||
|
return RbacRepository.get_role_by_id(db, db_role.id) or db_role
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_role(db: Session, db_role: Role, role: RoleUpdate) -> Role:
|
||||||
|
db_role.name = role.name
|
||||||
|
db_role.display_name = role.display_name
|
||||||
|
db_role.description = role.description
|
||||||
|
db_role.permissions = RbacRepository.get_permissions_by_ids(db, role.permission_ids)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_role)
|
||||||
|
|
||||||
|
return RbacRepository.get_role_by_id(db, db_role.id) or db_role
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_role_permissions(
|
||||||
|
db: Session,
|
||||||
|
db_role: Role,
|
||||||
|
permission_update: RolePermissionUpdate,
|
||||||
|
) -> Role:
|
||||||
|
db_role.permissions = RbacRepository.get_permissions_by_ids(
|
||||||
|
db,
|
||||||
|
permission_update.permission_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_role)
|
||||||
|
|
||||||
|
return RbacRepository.get_role_by_id(db, db_role.id) or db_role
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_role(db: Session, db_role: Role) -> None:
|
||||||
|
db.delete(db_role)
|
||||||
|
db.commit()
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||||
|
|
||||||
from app.core.security import hash_password
|
from app.core.security import hash_password
|
||||||
|
from app.models.rbac import Role
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.user import UserCreate, UserPasswordUpdate, UserUpdate
|
from app.schemas.user import UserCreate, UserPasswordUpdate, UserUpdate
|
||||||
|
|
||||||
|
|
@ -9,15 +10,31 @@ from app.schemas.user import UserCreate, UserPasswordUpdate, UserUpdate
|
||||||
class UserRepository:
|
class UserRepository:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_all(db: Session) -> list[User]:
|
def get_all(db: Session) -> list[User]:
|
||||||
return list(db.scalars(select(User).order_by(User.created_at.desc())))
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(User)
|
||||||
|
.options(
|
||||||
|
joinedload(User.primary_role).selectinload(Role.permissions),
|
||||||
|
)
|
||||||
|
.order_by(User.created_at.desc())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_by_id(db: Session, user_id: int) -> User | None:
|
def get_by_id(db: Session, user_id: int) -> User | None:
|
||||||
return db.get(User, user_id)
|
return db.scalar(
|
||||||
|
select(User)
|
||||||
|
.where(User.id == user_id)
|
||||||
|
.options(joinedload(User.primary_role).selectinload(Role.permissions))
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_by_username(db: Session, username: str) -> User | None:
|
def get_by_username(db: Session, username: str) -> User | None:
|
||||||
return db.scalar(select(User).where(User.username == username))
|
return db.scalar(
|
||||||
|
select(User)
|
||||||
|
.where(User.username == username)
|
||||||
|
.options(joinedload(User.primary_role).selectinload(Role.permissions))
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_conflict(
|
def find_conflict(
|
||||||
|
|
@ -52,6 +69,7 @@ class UserRepository:
|
||||||
username=user.username,
|
username=user.username,
|
||||||
email=str(user.email),
|
email=str(user.email),
|
||||||
role=user.role,
|
role=user.role,
|
||||||
|
role_id=user.role_id,
|
||||||
is_active=user.is_active,
|
is_active=user.is_active,
|
||||||
password_hash=hash_password(user.password),
|
password_hash=hash_password(user.password),
|
||||||
)
|
)
|
||||||
|
|
@ -60,7 +78,7 @@ class UserRepository:
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_user)
|
db.refresh(db_user)
|
||||||
|
|
||||||
return db_user
|
return UserRepository.get_by_id(db, db_user.id) or db_user
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def update(db: Session, db_user: User, user: UserUpdate) -> User:
|
def update(db: Session, db_user: User, user: UserUpdate) -> User:
|
||||||
|
|
@ -69,6 +87,8 @@ class UserRepository:
|
||||||
db_user.username = user.username
|
db_user.username = user.username
|
||||||
db_user.email = str(user.email)
|
db_user.email = str(user.email)
|
||||||
db_user.role = user.role
|
db_user.role = user.role
|
||||||
|
if user.role_id is not None:
|
||||||
|
db_user.role_id = user.role_id
|
||||||
db_user.is_active = user.is_active
|
db_user.is_active = user.is_active
|
||||||
|
|
||||||
if user.password:
|
if user.password:
|
||||||
|
|
@ -77,7 +97,7 @@ class UserRepository:
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_user)
|
db.refresh(db_user)
|
||||||
|
|
||||||
return db_user
|
return UserRepository.get_by_id(db, db_user.id) or db_user
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def update_password(
|
def update_password(
|
||||||
|
|
|
||||||
74
backend/hermes/app/schemas/rbac.py
Normal file
74
backend/hermes/app/schemas/rbac.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
RoleName = str
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
description: str
|
||||||
|
module: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class RoleBase(BaseModel):
|
||||||
|
name: str = Field(min_length=3, max_length=80)
|
||||||
|
display_name: str = Field(min_length=2, max_length=120)
|
||||||
|
description: str = Field(default="", max_length=500)
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def validate_name(cls, value: str) -> str:
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
allowed = normalized.replace("_", "").replace("-", "").replace(".", "")
|
||||||
|
if not allowed.isalnum():
|
||||||
|
raise ValueError(
|
||||||
|
"Rollenname darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten"
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@field_validator("display_name", "description", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_text(cls, value: object) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
class RoleCreate(RoleBase):
|
||||||
|
permission_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RoleUpdate(RoleBase):
|
||||||
|
permission_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RolePermissionUpdate(BaseModel):
|
||||||
|
permission_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RoleResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
description: str
|
||||||
|
is_system: bool
|
||||||
|
permissions: list[PermissionResponse]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentUserResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
role: str
|
||||||
|
permissions: list[str]
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
||||||
|
|
||||||
UserRole = Literal["admin", "manager", "user"]
|
from app.schemas.rbac import RoleResponse
|
||||||
|
|
||||||
|
UserRole = str
|
||||||
|
|
||||||
|
|
||||||
class UserBase(BaseModel):
|
class UserBase(BaseModel):
|
||||||
|
|
@ -11,7 +11,8 @@ class UserBase(BaseModel):
|
||||||
last_name: str = Field(default="", max_length=100)
|
last_name: str = Field(default="", max_length=100)
|
||||||
username: str = Field(min_length=3, max_length=50)
|
username: str = Field(min_length=3, max_length=50)
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
role: UserRole = "user"
|
role: UserRole = "support"
|
||||||
|
role_id: int | None = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
|
||||||
@field_validator("first_name", "last_name", "username", mode="before")
|
@field_validator("first_name", "last_name", "username", mode="before")
|
||||||
|
|
@ -65,6 +66,8 @@ class UserResponse(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
role: UserRole
|
role: UserRole
|
||||||
|
role_id: int
|
||||||
|
primary_role: RoleResponse
|
||||||
is_active: bool
|
is_active: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
|
||||||
7
frontend/athena/app/api/me/route.ts
Normal file
7
frontend/athena/app/api/me/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return proxyHermesRequest(request, "/auth/me");
|
||||||
|
}
|
||||||
7
frontend/athena/app/api/permissions/route.ts
Normal file
7
frontend/athena/app/api/permissions/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return proxyHermesRequest(request, "/permissions");
|
||||||
|
}
|
||||||
21
frontend/athena/app/api/roles/[id]/permissions/route.ts
Normal file
21
frontend/athena/app/api/roles/[id]/permissions/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyHermesRequest(request, `/roles/${id}/permissions`);
|
||||||
|
}
|
||||||
39
frontend/athena/app/api/roles/[id]/route.ts
Normal file
39
frontend/athena/app/api/roles/[id]/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function proxyRoleRequest(request: NextRequest, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
return proxyHermesRequest(request, `/roles/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, context: Params) {
|
||||||
|
return proxyRoleRequest(request, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, context: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyRoleRequest(request, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, context: Params) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyRoleRequest(request, context);
|
||||||
|
}
|
||||||
18
frontend/athena/app/api/roles/route.ts
Normal file
18
frontend/athena/app/api/roles/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return proxyHermesRequest(request, "/roles");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyHermesRequest(request, "/roles");
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,6 @@
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
import {
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
clearAuthCookie,
|
|
||||||
getAccessToken,
|
|
||||||
getHermesUrl,
|
|
||||||
hermesJsonResponse,
|
|
||||||
unauthorizedResponse,
|
|
||||||
upstreamConfigurationErrorResponse,
|
|
||||||
upstreamUnavailableResponse,
|
|
||||||
} from "@/lib/server/hermes";
|
|
||||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
|
|
@ -18,48 +10,11 @@ type Params = {
|
||||||
};
|
};
|
||||||
|
|
||||||
async function proxyUserRequest(request: NextRequest, { params }: Params) {
|
async function proxyUserRequest(request: NextRequest, { params }: Params) {
|
||||||
const token = await getAccessToken();
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return unauthorizedResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
const hermesUrl = getHermesUrl();
|
|
||||||
|
|
||||||
if (!hermesUrl) {
|
|
||||||
return upstreamConfigurationErrorResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const action = request.nextUrl.searchParams.get("action");
|
const action = request.nextUrl.searchParams.get("action");
|
||||||
const path = action === "password" ? `/users/${id}/password` : `/users/${id}`;
|
const path = action === "password" ? `/users/${id}/password` : `/users/${id}`;
|
||||||
|
|
||||||
let hermesResponse: Response;
|
return proxyHermesRequest(request, path);
|
||||||
|
|
||||||
try {
|
|
||||||
hermesResponse = await fetch(`${hermesUrl}${path}`, {
|
|
||||||
method: request.method,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
Accept: "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: request.method === "GET" || request.method === "DELETE"
|
|
||||||
? undefined
|
|
||||||
: await request.text(),
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return upstreamUnavailableResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hermesResponse.status === 401) {
|
|
||||||
const response = await hermesJsonResponse(hermesResponse);
|
|
||||||
clearAuthCookie(response);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
return hermesJsonResponse(hermesResponse);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest, context: Params) {
|
export async function GET(request: NextRequest, context: Params) {
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,10 @@
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
import {
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
clearAuthCookie,
|
|
||||||
getAccessToken,
|
|
||||||
getHermesUrl,
|
|
||||||
hermesJsonResponse,
|
|
||||||
unauthorizedResponse,
|
|
||||||
upstreamConfigurationErrorResponse,
|
|
||||||
upstreamUnavailableResponse,
|
|
||||||
} from "@/lib/server/hermes";
|
|
||||||
import { assertSameOrigin } from "@/lib/server/request-guards";
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
async function proxyUsersRequest(request: NextRequest) {
|
async function proxyUsersRequest(request: NextRequest) {
|
||||||
const token = await getAccessToken();
|
return proxyHermesRequest(request, `/users${request.nextUrl.search}`);
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return unauthorizedResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
const hermesUrl = getHermesUrl();
|
|
||||||
|
|
||||||
if (!hermesUrl) {
|
|
||||||
return upstreamConfigurationErrorResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
let hermesResponse: Response;
|
|
||||||
|
|
||||||
try {
|
|
||||||
hermesResponse = await fetch(`${hermesUrl}/users${request.nextUrl.search}`, {
|
|
||||||
method: request.method,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
Accept: "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: request.method === "GET" ? undefined : await request.text(),
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return upstreamUnavailableResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hermesResponse.status === 401) {
|
|
||||||
const response = await hermesJsonResponse(hermesResponse);
|
|
||||||
clearAuthCookie(response);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
return hermesJsonResponse(hermesResponse);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
|
|
||||||
535
frontend/athena/app/roles/page.tsx
Normal file
535
frontend/athena/app/roles/page.tsx
Normal file
|
|
@ -0,0 +1,535 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import type { FormEvent } from "react";
|
||||||
|
import { Edit, Eye, Shield, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||||
|
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
||||||
|
import SearchInput from "@/components/common/SearchInput";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import type { CurrentUser, Permission, Role, RolePayload } from "@/types/rbac";
|
||||||
|
|
||||||
|
const pageSize = 10;
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown) {
|
||||||
|
if (typeof error === "object" && error !== null && "response" in error) {
|
||||||
|
const response = (error as { response?: { data?: { detail?: string } } }).response;
|
||||||
|
return response?.data?.detail ?? "Aktion konnte nicht abgeschlossen werden";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Aktion konnte nicht abgeschlossen werden";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortableValue(role: Role, key: string) {
|
||||||
|
const value = role[key as keyof Role];
|
||||||
|
return typeof value === "string" ? value.toLowerCase() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RolesPage() {
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [permissions, setPermissions] = useState<Permission[]>([]);
|
||||||
|
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [formError, setFormError] = useState("");
|
||||||
|
const [deleteError, setDeleteError] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [sortKey, setSortKey] = useState("name");
|
||||||
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
|
||||||
|
const [detailsRole, setDetailsRole] = useState<Role | null>(null);
|
||||||
|
const [deleteRole, setDeleteRole] = useState<Role | null>(null);
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
|
||||||
|
const loadRoles = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [rolesResponse, permissionsResponse, meResponse] = await Promise.all([
|
||||||
|
api.get<Role[]>("/roles"),
|
||||||
|
api.get<Permission[]>("/permissions"),
|
||||||
|
api.get<CurrentUser>("/me"),
|
||||||
|
]);
|
||||||
|
setRoles(rolesResponse.data);
|
||||||
|
setPermissions(permissionsResponse.data);
|
||||||
|
setCurrentUser(meResponse.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(getErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void loadRoles();
|
||||||
|
});
|
||||||
|
}, [loadRoles]);
|
||||||
|
|
||||||
|
const filteredRoles = useMemo(() => {
|
||||||
|
const term = search.trim().toLowerCase();
|
||||||
|
|
||||||
|
return roles
|
||||||
|
.filter((role) => {
|
||||||
|
if (!term) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
role.name,
|
||||||
|
role.display_name,
|
||||||
|
role.description,
|
||||||
|
].some((value) => value.toLowerCase().includes(term));
|
||||||
|
})
|
||||||
|
.sort((first, second) => {
|
||||||
|
const firstValue = getSortableValue(first, sortKey);
|
||||||
|
const secondValue = getSortableValue(second, sortKey);
|
||||||
|
|
||||||
|
if (firstValue === secondValue) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = firstValue > secondValue ? 1 : -1;
|
||||||
|
return sortDirection === "asc" ? result : -result;
|
||||||
|
});
|
||||||
|
}, [roles, search, sortDirection, sortKey]);
|
||||||
|
|
||||||
|
const pageCount = Math.max(1, Math.ceil(filteredRoles.length / pageSize));
|
||||||
|
const currentPage = Math.min(page, pageCount);
|
||||||
|
const pageRoles = filteredRoles.slice(
|
||||||
|
(currentPage - 1) * pageSize,
|
||||||
|
currentPage * pageSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns: DataTableColumn<Role>[] = [
|
||||||
|
{
|
||||||
|
key: "display_name",
|
||||||
|
label: "Rolle",
|
||||||
|
sortable: true,
|
||||||
|
render: (role) => (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-950">{role.display_name}</p>
|
||||||
|
<p className="text-xs text-slate-500">{role.name}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "description",
|
||||||
|
label: "Beschreibung",
|
||||||
|
sortable: true,
|
||||||
|
render: (role) => role.description || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "is_system",
|
||||||
|
label: "Typ",
|
||||||
|
sortable: true,
|
||||||
|
render: (role) => (
|
||||||
|
<span className="inline-flex h-6 items-center rounded-full bg-slate-100 px-2 text-xs font-medium text-slate-700">
|
||||||
|
{role.is_system ? "Systemrolle" : "Individuell"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "permissions",
|
||||||
|
label: "Berechtigungen",
|
||||||
|
render: (role) => `${role.permissions.length}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "created_at",
|
||||||
|
label: "Erstellt",
|
||||||
|
sortable: true,
|
||||||
|
render: (role) => formatDate(role.created_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "Aktionen",
|
||||||
|
className: "px-4 py-3 text-right",
|
||||||
|
render: (role) => (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="ghost" size="icon-sm" onClick={() => setDetailsRole(role)} title="Details">
|
||||||
|
<Eye size={16} />
|
||||||
|
</Button>
|
||||||
|
{hasPermission(currentUser, "roles.update") && (
|
||||||
|
<Button type="button" variant="ghost" size="icon-sm" onClick={() => openEditDialog(role)} title="Bearbeiten">
|
||||||
|
<Edit size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasPermission(currentUser, "roles.delete") && !role.is_system && (
|
||||||
|
<Button type="button" variant="destructive" size="icon-sm" onClick={() => setDeleteRole(role)} title="Löschen">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function handleSort(key: string) {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSortKey(key);
|
||||||
|
setSortDirection("asc");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
setSelectedRole(null);
|
||||||
|
setFormError("");
|
||||||
|
setFormOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(role: Role) {
|
||||||
|
setSelectedRole(role);
|
||||||
|
setFormError("");
|
||||||
|
setFormOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRole(payload: RolePayload) {
|
||||||
|
setPending(true);
|
||||||
|
setFormError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (selectedRole) {
|
||||||
|
const response = await api.put<Role>(`/roles/${selectedRole.id}`, payload);
|
||||||
|
setRoles((current) => current.map((role) => (
|
||||||
|
role.id === selectedRole.id ? response.data : role
|
||||||
|
)));
|
||||||
|
} else {
|
||||||
|
const response = await api.post<Role>("/roles", payload);
|
||||||
|
setRoles((current) => [response.data, ...current]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(getErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deleteRole) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPending(true);
|
||||||
|
setDeleteError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/roles/${deleteRole.id}`);
|
||||||
|
setRoles((current) => current.filter((role) => role.id !== deleteRole.id));
|
||||||
|
setDeleteRole(null);
|
||||||
|
} catch (err) {
|
||||||
|
setDeleteError(getErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-950">Rollen & Rechte</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
{filteredRoles.length} von {roles.length} Rollen
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasPermission(currentUser, "roles.create") && (
|
||||||
|
<Button type="button" onClick={openCreateDialog}>
|
||||||
|
<Shield size={16} />
|
||||||
|
Neue Rolle
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-white p-4">
|
||||||
|
<SearchInput
|
||||||
|
value={search}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSearch(value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="Rollen suchen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={pageRoles}
|
||||||
|
rowKey={(role) => role.id}
|
||||||
|
sortKey={sortKey}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
emptyTitle="Keine Rollen gefunden"
|
||||||
|
emptyDescription="Passe Suche oder Filter an oder erstelle eine neue Rolle."
|
||||||
|
onSort={handleSort}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm text-slate-600">
|
||||||
|
<span>
|
||||||
|
Seite {currentPage} von {pageCount}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={currentPage === pageCount}
|
||||||
|
onClick={() => setPage((current) => Math.min(pageCount, current + 1))}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RoleFormDialog
|
||||||
|
key={selectedRole?.id ?? "new"}
|
||||||
|
open={formOpen}
|
||||||
|
role={selectedRole}
|
||||||
|
permissions={permissions}
|
||||||
|
pending={pending}
|
||||||
|
serverError={formError}
|
||||||
|
onOpenChange={setFormOpen}
|
||||||
|
onSubmit={saveRole}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RoleDetailDialog
|
||||||
|
role={detailsRole}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setDetailsRole(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(deleteRole)}
|
||||||
|
title="Rolle löschen"
|
||||||
|
description="Möchten Sie diese Rolle wirklich löschen?"
|
||||||
|
pending={pending}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setDeleteRole(null);
|
||||||
|
setDeleteError("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
>
|
||||||
|
{deleteRole && (
|
||||||
|
<dl className="grid gap-2 text-sm">
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<dt className="text-slate-500">Name</dt>
|
||||||
|
<dd className="font-medium">{deleteRole.display_name}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<dt className="text-slate-500">Systemrolle</dt>
|
||||||
|
<dd className="font-medium">{deleteRole.is_system ? "Ja" : "Nein"}</dd>
|
||||||
|
</div>
|
||||||
|
{deleteError && <p className="text-red-600">{deleteError}</p>}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</ConfirmDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleFormDialog({
|
||||||
|
open,
|
||||||
|
role,
|
||||||
|
permissions,
|
||||||
|
pending,
|
||||||
|
serverError,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
role: Role | null;
|
||||||
|
permissions: Permission[];
|
||||||
|
pending: boolean;
|
||||||
|
serverError?: string;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (payload: RolePayload) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const initialPermissionIds = role?.permissions.map((permission) => permission.id) ?? [];
|
||||||
|
const [name, setName] = useState(role?.name ?? "");
|
||||||
|
const [displayName, setDisplayName] = useState(role?.display_name ?? "");
|
||||||
|
const [description, setDescription] = useState(role?.description ?? "");
|
||||||
|
const [permissionIds, setPermissionIds] = useState<number[]>(initialPermissionIds);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedPermissions = permissions.reduce<Record<string, Permission[]>>((groups, permission) => {
|
||||||
|
groups[permission.module] = groups[permission.module] ?? [];
|
||||||
|
groups[permission.module].push(permission);
|
||||||
|
return groups;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const valid = name.trim().length >= 3 && displayName.trim().length >= 2;
|
||||||
|
|
||||||
|
function togglePermission(permissionId: number) {
|
||||||
|
setPermissionIds((current) => (
|
||||||
|
current.includes(permissionId)
|
||||||
|
? current.filter((id) => id !== permissionId)
|
||||||
|
: [...current, permissionId]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSubmit({
|
||||||
|
name,
|
||||||
|
display_name: displayName,
|
||||||
|
description,
|
||||||
|
permission_ids: permissionIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-3xl">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{role ? "Rolle bearbeiten" : "Rolle erstellen"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Rollenstammdaten und Berechtigungen verwalten.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Technischer Name</Label>
|
||||||
|
<Input value={name} onChange={(event) => setName(event.target.value)} disabled={role?.is_system} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Anzeigename</Label>
|
||||||
|
<Input value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 sm:col-span-2">
|
||||||
|
<Label>Beschreibung</Label>
|
||||||
|
<Input value={description} onChange={(event) => setDescription(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-80 overflow-auto rounded-lg border">
|
||||||
|
{Object.entries(groupedPermissions).map(([module, modulePermissions]) => (
|
||||||
|
<div key={module} className="border-b p-4 last:border-b-0">
|
||||||
|
<h3 className="mb-3 text-sm font-semibold capitalize text-slate-900">{module}</h3>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{modulePermissions.map((permission) => (
|
||||||
|
<label key={permission.id} className="flex items-start gap-2 rounded border p-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={permissionIds.includes(permission.id)}
|
||||||
|
onChange={() => togglePermission(permission.id)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block font-medium">{permission.display_name}</span>
|
||||||
|
<span className="block text-xs text-slate-500">{permission.name}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{serverError && <p className="text-sm text-red-600">{serverError}</p>}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" disabled={pending} onClick={() => onOpenChange(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!valid || pending}>
|
||||||
|
{pending ? "Speichern..." : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleDetailDialog({
|
||||||
|
role,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
role: Role | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog open={Boolean(role)} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
{role && (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{role.display_name}</DialogTitle>
|
||||||
|
<DialogDescription>{role.description || role.name}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-3 text-sm sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-500">Technischer Name</p>
|
||||||
|
<p className="font-medium">{role.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-500">Typ</p>
|
||||||
|
<p className="font-medium">{role.is_system ? "Systemrolle" : "Individuell"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium">Berechtigungen</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{role.permissions.map((permission) => (
|
||||||
|
<span key={permission.id} className="rounded-full bg-slate-100 px-2 py-1 text-xs text-slate-700">
|
||||||
|
{permission.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,8 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import UserFormDialog from "@/components/users/UserFormDialog";
|
import UserFormDialog from "@/components/users/UserFormDialog";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import type { CurrentUser, Role } from "@/types/rbac";
|
||||||
import type { User, UserPayload } from "@/types/user";
|
import type { User, UserPayload } from "@/types/user";
|
||||||
|
|
||||||
function getErrorMessage(error: unknown) {
|
function getErrorMessage(error: unknown) {
|
||||||
|
|
@ -41,6 +43,8 @@ function formatDateTime(value: string) {
|
||||||
export default function UserDetailPage() {
|
export default function UserDetailPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
@ -52,8 +56,19 @@ export default function UserDetailPage() {
|
||||||
|
|
||||||
const loadUser = useCallback(async () => {
|
const loadUser = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get<User>(`/users/${params.id}`);
|
const [userResponse, meResponse] = await Promise.all([
|
||||||
setUser(response.data);
|
api.get<User>(`/users/${params.id}`),
|
||||||
|
api.get<CurrentUser>("/me"),
|
||||||
|
]);
|
||||||
|
setUser(userResponse.data);
|
||||||
|
setCurrentUser(meResponse.data);
|
||||||
|
|
||||||
|
if (hasPermission(meResponse.data, "roles.read")) {
|
||||||
|
const rolesResponse = await api.get<Role[]>("/roles");
|
||||||
|
setRoles(rolesResponse.data);
|
||||||
|
} else {
|
||||||
|
setRoles([userResponse.data.primary_role]);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(getErrorMessage(err));
|
setError(getErrorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -147,14 +162,18 @@ export default function UserDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="button" variant="outline" onClick={() => setPasswordOpen(true)}>
|
{hasPermission(currentUser, "users.password.update") && (
|
||||||
<KeyRound size={16} />
|
<Button type="button" variant="outline" onClick={() => setPasswordOpen(true)}>
|
||||||
Passwort ändern
|
<KeyRound size={16} />
|
||||||
</Button>
|
Passwort ändern
|
||||||
<Button type="button" onClick={() => setFormOpen(true)}>
|
</Button>
|
||||||
<Pencil size={16} />
|
)}
|
||||||
Bearbeiten
|
{hasPermission(currentUser, "users.update") && (
|
||||||
</Button>
|
<Button type="button" onClick={() => setFormOpen(true)}>
|
||||||
|
<Pencil size={16} />
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -165,7 +184,7 @@ export default function UserDetailPage() {
|
||||||
<Detail label="Nachname" value={user.last_name || "-"} />
|
<Detail label="Nachname" value={user.last_name || "-"} />
|
||||||
<Detail label="Benutzername" value={user.username} />
|
<Detail label="Benutzername" value={user.username} />
|
||||||
<Detail label="E-Mail" value={user.email} />
|
<Detail label="E-Mail" value={user.email} />
|
||||||
<Detail label="Rolle" value={user.role} />
|
<Detail label="Rolle" value={user.primary_role.display_name} />
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-slate-500">Status</dt>
|
<dt className="text-sm text-slate-500">Status</dt>
|
||||||
<dd className="mt-1"><StatusBadge active={user.is_active} /></dd>
|
<dd className="mt-1"><StatusBadge active={user.is_active} /></dd>
|
||||||
|
|
@ -180,6 +199,7 @@ export default function UserDetailPage() {
|
||||||
user={user}
|
user={user}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
serverError={formError}
|
serverError={formError}
|
||||||
|
roles={roles}
|
||||||
onOpenChange={setFormOpen}
|
onOpenChange={setFormOpen}
|
||||||
onSubmit={saveUser}
|
onSubmit={saveUser}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import { Button } from "@/components/ui/button";
|
||||||
import UserFormDialog from "@/components/users/UserFormDialog";
|
import UserFormDialog from "@/components/users/UserFormDialog";
|
||||||
import UserTable from "@/components/users/UserTable";
|
import UserTable from "@/components/users/UserTable";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { hasAllPermissions, hasPermission } from "@/lib/permissions";
|
||||||
|
import type { CurrentUser, Role } from "@/types/rbac";
|
||||||
import type { User, UserPayload, UserRole } from "@/types/user";
|
import type { User, UserPayload, UserRole } from "@/types/user";
|
||||||
|
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
|
@ -31,6 +33,8 @@ function getSortableValue(user: User, key: string) {
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [formError, setFormError] = useState("");
|
const [formError, setFormError] = useState("");
|
||||||
|
|
@ -48,8 +52,22 @@ export default function UsersPage() {
|
||||||
|
|
||||||
const loadUsers = useCallback(async () => {
|
const loadUsers = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get<User[]>("/users");
|
const [usersResponse, meResponse] = await Promise.all([
|
||||||
setUsers(response.data);
|
api.get<User[]>("/users"),
|
||||||
|
api.get<CurrentUser>("/me"),
|
||||||
|
]);
|
||||||
|
setUsers(usersResponse.data);
|
||||||
|
setCurrentUser(meResponse.data);
|
||||||
|
|
||||||
|
if (hasPermission(meResponse.data, "roles.read")) {
|
||||||
|
const rolesResponse = await api.get<Role[]>("/roles");
|
||||||
|
setRoles(rolesResponse.data);
|
||||||
|
} else {
|
||||||
|
const userRoles = usersResponse.data.map((user) => user.primary_role);
|
||||||
|
setRoles(userRoles.filter((role, index, all) => (
|
||||||
|
all.findIndex((item) => item.id === role.id) === index
|
||||||
|
)));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(getErrorMessage(err));
|
setError(getErrorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -77,10 +95,11 @@ export default function UsersPage() {
|
||||||
user.last_name,
|
user.last_name,
|
||||||
user.username,
|
user.username,
|
||||||
user.email,
|
user.email,
|
||||||
user.role,
|
user.primary_role.display_name,
|
||||||
|
user.primary_role.name,
|
||||||
].some((value) => value.toLowerCase().includes(term));
|
].some((value) => value.toLowerCase().includes(term));
|
||||||
})
|
})
|
||||||
.filter((user) => roleFilter === "all" || user.role === roleFilter)
|
.filter((user) => roleFilter === "all" || user.primary_role.name === roleFilter)
|
||||||
.filter((user) => {
|
.filter((user) => {
|
||||||
if (statusFilter === "all") {
|
if (statusFilter === "all") {
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -182,10 +201,12 @@ export default function UsersPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="button" onClick={openCreateDialog}>
|
{hasAllPermissions(currentUser, ["users.create", "roles.assign"]) && (
|
||||||
<Plus size={16} />
|
<Button type="button" onClick={openCreateDialog}>
|
||||||
Neuer Benutzer
|
<Plus size={16} />
|
||||||
</Button>
|
Neuer Benutzer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 lg:flex-row lg:items-center">
|
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 lg:flex-row lg:items-center">
|
||||||
|
|
@ -207,9 +228,11 @@ export default function UsersPage() {
|
||||||
className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
>
|
>
|
||||||
<option value="all">Alle Rollen</option>
|
<option value="all">Alle Rollen</option>
|
||||||
<option value="admin">Admin</option>
|
{roles.map((role) => (
|
||||||
<option value="manager">Manager</option>
|
<option key={role.id} value={role.name}>
|
||||||
<option value="user">Benutzer</option>
|
{role.display_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
|
|
@ -232,6 +255,8 @@ export default function UsersPage() {
|
||||||
error={error}
|
error={error}
|
||||||
sortKey={sortKey}
|
sortKey={sortKey}
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
|
canUpdate={hasPermission(currentUser, "users.update")}
|
||||||
|
canDelete={hasPermission(currentUser, "users.delete")}
|
||||||
onSort={handleSort}
|
onSort={handleSort}
|
||||||
onEdit={openEditDialog}
|
onEdit={openEditDialog}
|
||||||
onDelete={setDeleteUser}
|
onDelete={setDeleteUser}
|
||||||
|
|
@ -266,6 +291,7 @@ export default function UsersPage() {
|
||||||
user={selectedUser}
|
user={selectedUser}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
serverError={formError}
|
serverError={formError}
|
||||||
|
roles={roles}
|
||||||
onOpenChange={setFormOpen}
|
onOpenChange={setFormOpen}
|
||||||
onSubmit={saveUser}
|
onSubmit={saveUser}
|
||||||
/>
|
/>
|
||||||
|
|
@ -295,7 +321,7 @@ export default function UsersPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-4">
|
<div className="flex justify-between gap-4">
|
||||||
<dt className="text-slate-500">Rolle</dt>
|
<dt className="text-slate-500">Rolle</dt>
|
||||||
<dd className="font-medium">{deleteUser.role}</dd>
|
<dd className="font-medium">{deleteUser.primary_role.display_name}</dd>
|
||||||
</div>
|
</div>
|
||||||
{deleteError && <p className="text-red-600">{deleteError}</p>}
|
{deleteError && <p className="text-red-600">{deleteError}</p>}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,22 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
|
FileText,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
Package,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
ShoppingCart,
|
||||||
Users,
|
Users,
|
||||||
Wrench,
|
Wrench,
|
||||||
Package,
|
|
||||||
FileText,
|
|
||||||
Settings,
|
|
||||||
ShoppingCart,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import type { CurrentUser, PermissionName } from "@/types/rbac";
|
||||||
|
|
||||||
const menu = [
|
const menu = [
|
||||||
{
|
{
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
|
|
@ -19,6 +27,13 @@ const menu = [
|
||||||
icon: Users,
|
icon: Users,
|
||||||
name: "Benutzer",
|
name: "Benutzer",
|
||||||
href: "/users",
|
href: "/users",
|
||||||
|
permission: "users.read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Shield,
|
||||||
|
name: "Rollen & Rechte",
|
||||||
|
href: "/roles",
|
||||||
|
permission: "roles.read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Users,
|
icon: Users,
|
||||||
|
|
@ -50,21 +65,45 @@ const menu = [
|
||||||
name: "Einstellungen",
|
name: "Einstellungen",
|
||||||
href: "/settings",
|
href: "/settings",
|
||||||
},
|
},
|
||||||
];
|
] satisfies Array<{
|
||||||
|
icon: typeof LayoutDashboard;
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
permission?: PermissionName;
|
||||||
|
}>;
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
|
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||||
|
|
||||||
|
const loadCurrentUser = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get<CurrentUser>("/me");
|
||||||
|
setCurrentUser(response.data);
|
||||||
|
} catch {
|
||||||
|
setCurrentUser(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void loadCurrentUser();
|
||||||
|
});
|
||||||
|
}, [loadCurrentUser]);
|
||||||
|
|
||||||
|
const visibleMenu = menu.filter((item) => (
|
||||||
|
!item.permission || hasPermission(currentUser, item.permission)
|
||||||
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-64 h-screen bg-slate-900 text-white flex flex-col">
|
<aside className="w-64 h-screen bg-slate-900 text-white flex flex-col">
|
||||||
|
|
||||||
<div className="p-6 border-b border-slate-800">
|
<div className="p-6 border-b border-slate-800">
|
||||||
<h1 className="text-3xl font-bold">
|
<h1 className="text-3xl font-bold">
|
||||||
🏛 Olympus
|
Olympus
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 p-4 space-y-2">
|
<nav className="flex-1 p-4 space-y-2">
|
||||||
|
{visibleMenu.map((item) => (
|
||||||
{menu.map((item) => (
|
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
|
|
@ -74,13 +113,11 @@ export default function Sidebar() {
|
||||||
<span>{item.name}</span>
|
<span>{item.name}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="border-t border-slate-800 p-4 text-sm text-slate-400">
|
<div className="border-t border-slate-800 p-4 text-sm text-slate-400">
|
||||||
Olympus ERP v0.1
|
Olympus CRM v0.1
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
frontend/athena/components/common/Can.tsx
Normal file
30
frontend/athena/components/common/Can.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { hasAllPermissions, hasAnyPermission, hasPermission } from "@/lib/permissions";
|
||||||
|
import type { CurrentUser, PermissionName } from "@/types/rbac";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
user: CurrentUser | null;
|
||||||
|
permission?: PermissionName;
|
||||||
|
any?: PermissionName[];
|
||||||
|
all?: PermissionName[];
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Can({ user, permission, any, all, children }: Props) {
|
||||||
|
if (permission && !hasPermission(user, permission)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (any && !hasAnyPermission(user, any)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (all && !hasAllPermissions(user, all)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
@ -14,20 +14,16 @@ import {
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { Role } from "@/types/rbac";
|
||||||
import type { User, UserPayload, UserRole } from "@/types/user";
|
import type { User, UserPayload, UserRole } from "@/types/user";
|
||||||
|
|
||||||
const roles: Array<{ value: UserRole; label: string }> = [
|
|
||||||
{ value: "admin", label: "Admin" },
|
|
||||||
{ value: "manager", label: "Manager" },
|
|
||||||
{ value: "user", label: "Benutzer" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const emptyForm: UserPayload = {
|
const emptyForm: UserPayload = {
|
||||||
first_name: "",
|
first_name: "",
|
||||||
last_name: "",
|
last_name: "",
|
||||||
username: "",
|
username: "",
|
||||||
email: "",
|
email: "",
|
||||||
role: "user",
|
role: "support",
|
||||||
|
role_id: undefined,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
password: "",
|
password: "",
|
||||||
};
|
};
|
||||||
|
|
@ -37,6 +33,7 @@ type Props = {
|
||||||
user?: User | null;
|
user?: User | null;
|
||||||
pending?: boolean;
|
pending?: boolean;
|
||||||
serverError?: string;
|
serverError?: string;
|
||||||
|
roles: Role[];
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onSubmit: (payload: UserPayload) => Promise<void>;
|
onSubmit: (payload: UserPayload) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
@ -46,6 +43,7 @@ export default function UserFormDialog({
|
||||||
user,
|
user,
|
||||||
pending = false,
|
pending = false,
|
||||||
serverError,
|
serverError,
|
||||||
|
roles,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
|
@ -57,10 +55,15 @@ export default function UserFormDialog({
|
||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
role_id: user.role_id,
|
||||||
is_active: user.is_active,
|
is_active: user.is_active,
|
||||||
password: "",
|
password: "",
|
||||||
}
|
}
|
||||||
: emptyForm;
|
: {
|
||||||
|
...emptyForm,
|
||||||
|
role: roles[0]?.name ?? "support",
|
||||||
|
role_id: roles[0]?.id,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
|
@ -72,6 +75,7 @@ export default function UserFormDialog({
|
||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
serverError={serverError}
|
serverError={serverError}
|
||||||
|
roles={roles}
|
||||||
onCancel={() => onOpenChange(false)}
|
onCancel={() => onOpenChange(false)}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
/>
|
/>
|
||||||
|
|
@ -86,6 +90,7 @@ function UserForm({
|
||||||
isEditing,
|
isEditing,
|
||||||
pending,
|
pending,
|
||||||
serverError,
|
serverError,
|
||||||
|
roles,
|
||||||
onCancel,
|
onCancel,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -93,6 +98,7 @@ function UserForm({
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
serverError?: string;
|
serverError?: string;
|
||||||
|
roles: Role[];
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSubmit: (payload: UserPayload) => Promise<void>;
|
onSubmit: (payload: UserPayload) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -179,13 +185,17 @@ function UserForm({
|
||||||
|
|
||||||
<Field label="Rolle">
|
<Field label="Rolle">
|
||||||
<select
|
<select
|
||||||
value={form.role}
|
value={form.role_id ?? ""}
|
||||||
onChange={(event) => updateField("role", event.target.value as UserRole)}
|
onChange={(event) => {
|
||||||
|
const role = roles.find((item) => item.id === Number(event.target.value));
|
||||||
|
updateField("role_id", role?.id);
|
||||||
|
updateField("role", (role?.name ?? "") as UserRole);
|
||||||
|
}}
|
||||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
>
|
>
|
||||||
{roles.map((role) => (
|
{roles.map((role) => (
|
||||||
<option key={role.value} value={role.value}>
|
<option key={role.id} value={role.id}>
|
||||||
{role.label}
|
{role.display_name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ type Props = {
|
||||||
error?: string;
|
error?: string;
|
||||||
sortKey: string;
|
sortKey: string;
|
||||||
sortDirection: "asc" | "desc";
|
sortDirection: "asc" | "desc";
|
||||||
|
canUpdate: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
onSort: (key: string) => void;
|
onSort: (key: string) => void;
|
||||||
onEdit: (user: User) => void;
|
onEdit: (user: User) => void;
|
||||||
onDelete: (user: User) => void;
|
onDelete: (user: User) => void;
|
||||||
|
|
@ -33,6 +35,8 @@ export default function UserTable({
|
||||||
error,
|
error,
|
||||||
sortKey,
|
sortKey,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
|
canUpdate,
|
||||||
|
canDelete,
|
||||||
onSort,
|
onSort,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
|
@ -66,7 +70,7 @@ export default function UserTable({
|
||||||
key: "role",
|
key: "role",
|
||||||
label: "Rolle",
|
label: "Rolle",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (user) => user.role,
|
render: (user) => user.primary_role.display_name,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "is_active",
|
key: "is_active",
|
||||||
|
|
@ -93,24 +97,28 @@ export default function UserTable({
|
||||||
>
|
>
|
||||||
<Eye size={16} />
|
<Eye size={16} />
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
{canUpdate && (
|
||||||
type="button"
|
<Button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="icon-sm"
|
variant="ghost"
|
||||||
title="Bearbeiten"
|
size="icon-sm"
|
||||||
onClick={() => onEdit(user)}
|
title="Bearbeiten"
|
||||||
>
|
onClick={() => onEdit(user)}
|
||||||
<Edit size={16} />
|
>
|
||||||
</Button>
|
<Edit size={16} />
|
||||||
<Button
|
</Button>
|
||||||
type="button"
|
)}
|
||||||
variant="destructive"
|
{canDelete && (
|
||||||
size="icon-sm"
|
<Button
|
||||||
title="Löschen"
|
type="button"
|
||||||
onClick={() => onDelete(user)}
|
variant="destructive"
|
||||||
>
|
size="icon-sm"
|
||||||
<Trash2 size={16} />
|
title="Löschen"
|
||||||
</Button>
|
onClick={() => onDelete(user)}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
19
frontend/athena/lib/permissions.ts
Normal file
19
frontend/athena/lib/permissions.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { CurrentUser, PermissionName } from "@/types/rbac";
|
||||||
|
|
||||||
|
export function hasPermission(user: CurrentUser | null | undefined, permission: PermissionName) {
|
||||||
|
return Boolean(user?.permissions.includes(permission));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAnyPermission(
|
||||||
|
user: CurrentUser | null | undefined,
|
||||||
|
permissions: PermissionName[],
|
||||||
|
) {
|
||||||
|
return permissions.some((permission) => hasPermission(user, permission));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAllPermissions(
|
||||||
|
user: CurrentUser | null | undefined,
|
||||||
|
permissions: PermissionName[],
|
||||||
|
) {
|
||||||
|
return permissions.every((permission) => hasPermission(user, permission));
|
||||||
|
}
|
||||||
55
frontend/athena/lib/server/hermes-proxy.ts
Normal file
55
frontend/athena/lib/server/hermes-proxy.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearAuthCookie,
|
||||||
|
getAccessToken,
|
||||||
|
getHermesUrl,
|
||||||
|
hermesJsonResponse,
|
||||||
|
unauthorizedResponse,
|
||||||
|
upstreamConfigurationErrorResponse,
|
||||||
|
upstreamUnavailableResponse,
|
||||||
|
} from "@/lib/server/hermes";
|
||||||
|
|
||||||
|
export async function proxyHermesRequest(
|
||||||
|
request: NextRequest,
|
||||||
|
path: string,
|
||||||
|
) {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return unauthorizedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hermesUrl = getHermesUrl();
|
||||||
|
|
||||||
|
if (!hermesUrl) {
|
||||||
|
return upstreamConfigurationErrorResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
let hermesResponse: Response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
hermesResponse = await fetch(`${hermesUrl}${path}`, {
|
||||||
|
method: request.method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: request.method === "GET" || request.method === "DELETE"
|
||||||
|
? undefined
|
||||||
|
: await request.text(),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return upstreamUnavailableResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hermesResponse.status === 401) {
|
||||||
|
const response = await hermesJsonResponse(hermesResponse);
|
||||||
|
clearAuthCookie(response);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hermesJsonResponse(hermesResponse);
|
||||||
|
}
|
||||||
|
|
@ -20,5 +20,6 @@ export const config = {
|
||||||
"/login",
|
"/login",
|
||||||
"/dashboard/:path*",
|
"/dashboard/:path*",
|
||||||
"/users/:path*",
|
"/users/:path*",
|
||||||
|
"/roles/:path*",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
37
frontend/athena/types/rbac.ts
Normal file
37
frontend/athena/types/rbac.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
export type PermissionName = string;
|
||||||
|
|
||||||
|
export interface Permission {
|
||||||
|
id: number;
|
||||||
|
name: PermissionName;
|
||||||
|
display_name: string;
|
||||||
|
description: string;
|
||||||
|
module: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Role {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
description: string;
|
||||||
|
is_system: boolean;
|
||||||
|
permissions: Permission[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RolePayload {
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
description: string;
|
||||||
|
permission_ids: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
permissions: PermissionName[];
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
export type UserRole = "admin" | "manager" | "user";
|
import type { Role } from "@/types/rbac";
|
||||||
|
|
||||||
|
export type UserRole = string;
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -7,6 +9,8 @@ export interface User {
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
role_id: number;
|
||||||
|
primary_role: Role;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|
@ -18,6 +22,7 @@ export type UserPayload = {
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
role_id?: number;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
password?: string;
|
password?: string;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue