feat(platform): add audit logs and activity feed

This commit is contained in:
Schubert Ferenc 2026-07-03 00:04:44 +02:00
parent 92cb8d1286
commit c816e9869d
34 changed files with 1592 additions and 43 deletions

View file

@ -58,6 +58,8 @@ Sie verbindet:
- das konfigurierte Docker-Netzwerk - das konfigurierte Docker-Netzwerk
- die notwendigen Runtime-Umgebungsvariablen - die notwendigen Runtime-Umgebungsvariablen
Im gemeinsamen Compose-Stack wird Athena am Host veroeffentlicht. Hermes wird nur intern im Docker-Netzwerk exponiert und von Athena ueber `HERMES_INTERNAL_URL` erreicht.
## BFF-Architektur ## BFF-Architektur
Olympus nutzt eine Backend-for-Frontend-Architektur. Olympus nutzt eine Backend-for-Frontend-Architektur.
@ -172,6 +174,14 @@ Beispiele:
Nicht vorhandene Module wie Aufgaben, Tickets oder Projekte werden nicht mit Fake-Daten gefuellt. Stattdessen liefert das Dashboard leere Widgets mit klarer Meldung. Nicht vorhandene Module wie Aufgaben, Tickets oder Projekte werden nicht mit Fake-Daten gefuellt. Stattdessen liefert das Dashboard leere Widgets mit klarer Meldung.
### Activity Feed
Der Activity Feed ist Teil der v0.5.0-Qualitaetsplattform.
Athena ruft `GET /api/activity-feed` auf. Die BFF-Route ruft serverseitig Hermes `GET /activity-feed` auf.
Der Feed basiert auf persistenten Audit Logs und zeigt die letzten relevanten Aktivitaeten. Hermes filtert die Eintraege anhand der Berechtigungen des aktuellen Benutzers. Ein Benutzer sieht dadurch nur Aktivitaeten zu Bereichen, fuer die er Leserechte besitzt.
## RBAC: Rollen und Berechtigungen ## RBAC: Rollen und Berechtigungen
Olympus verwendet ein serverseitiges RBAC-System als Grundlage fuer alle CRM-Module. Olympus verwendet ein serverseitiges RBAC-System als Grundlage fuer alle CRM-Module.
@ -250,6 +260,10 @@ Dashboard und System:
- `system.settings.read` - `system.settings.read`
- `system.settings.update` - `system.settings.update`
Audit:
- `audit_logs.read`
### Berechtigungspruefung ### Berechtigungspruefung
Zentrale Hermes-Dependencies: Zentrale Hermes-Dependencies:
@ -374,12 +388,80 @@ Kontakte und Adressen gehoeren fachlich zum Kunden und werden aktuell ueber `cus
Ein Kunden-Delete entfernt aktuell den Kunden inklusive Adressen und Ansprechpartnern. Fuer spaetere Projekte oder Tickets ist ein fachlicher Loeschschutz vorzubereiten, sobald diese Module existieren. Ein Kunden-Delete entfernt aktuell den Kunden inklusive Adressen und Ansprechpartnern. Fuer spaetere Projekte oder Tickets ist ein fachlicher Loeschschutz vorzubereiten, sobald diese Module existieren.
## Audit Logs
Audit Logs sind die zentrale Nachvollziehbarkeitsschicht fuer v0.5.0.
Hermes persistiert relevante Ereignisse in der Tabelle `audit_logs`.
Erfasste Informationen:
- handelnder Benutzer
- Aktion, z. B. `users.update` oder `customers.delete`
- Objekttyp und Objekt-ID
- lesbares Objektlabel
- IP-Adresse und User-Agent
- Vorher-/Nachher-Daten fuer Aenderungen
- Metadaten fuer Kontextinformationen
- Erstellungszeitpunkt
Sensible Felder wie Passwoerter, Tokens und Secrets werden vor dem Schreiben maskiert.
Audit Logs werden aktuell fuer diese Bereiche geschrieben:
- Login erfolgreich/fehlgeschlagen
- Benutzer erstellen, bearbeiten, loeschen, Passwort aendern
- Rollen erstellen, bearbeiten, loeschen, Berechtigungen aendern
- Kunden erstellen, bearbeiten, loeschen
- Ansprechpartner erstellen, bearbeiten, loeschen
Hermes-Endpunkte:
- `GET /audit-logs`
- `GET /activity-feed`
Athena-BFF-Routen:
- `GET /api/audit-logs`
- `GET /api/activity-feed`
Die Audit-Log-UI liegt unter `frontend/athena/app/audit-logs` und ist ueber `audit_logs.read` sichtbar.
## API-Response-Standard
Neue Plattform-Endpunkte verwenden eine einheitliche Response-Huelle:
```json
{
"success": true,
"data": {},
"message": "Daten geladen"
}
```
Fehlerantworten aus Hermes verwenden ein konsistentes Format mit `success`, `message`, `error_code` und `details`. Fuer bestehende Athena-Fehlerbehandlung bleibt `detail` zusaetzlich erhalten.
## Logging
Hermes konfiguriert Logging zentral ueber `backend/hermes/app/core/logging.py`.
HTTP-Requests werden strukturiert mit Methode, Pfad, Statuscode und Dauer geloggt. Fachliche Ereignisse bleiben in den API-Modulen als strukturierte Logeintraege erhalten.
Das Runtime-Loglevel wird ueber `LOG_LEVEL` gesteuert.
## Frontend-Feedback
Athena nutzt einen globalen Toast-Provider fuer konsistente Erfolgs- und Fehlermeldungen.
Aktuell werden Toasts in den CRUD-Flows fuer Benutzer, Rollen, Kunden und Ansprechpartner genutzt. Inline-Fehler bleiben dort erhalten, wo sie fuer Formulare und Dialoge hilfreich sind.
## Verzeichnisstruktur ## Verzeichnisstruktur
```text ```text
backend/hermes backend/hermes
app/ app/
api/ api/
audit/
core/ core/
db/ db/
models/ models/
@ -393,6 +475,7 @@ backend/hermes
frontend/athena frontend/athena
app/ app/
api/ api/
audit-logs/
customers/ customers/
roles/ roles/
users/ users/
@ -422,6 +505,9 @@ docker-compose.yml
`ACCESS_TOKEN_EXPIRE_MINUTES` `ACCESS_TOKEN_EXPIRE_MINUTES`
: Ablaufzeit fuer Access Tokens in Minuten. : Ablaufzeit fuer Access Tokens in Minuten.
`LOG_LEVEL`
: Runtime-Loglevel fuer Hermes, z. B. `INFO`, `WARNING` oder `ERROR`.
### Athena ### Athena
`HERMES_INTERNAL_URL` `HERMES_INTERNAL_URL`

View file

@ -64,6 +64,8 @@ Eine Aenderung gilt erst als fertig, wenn diese Punkte erfuellt sind:
- `npx next build --webpack` erfolgreich in `frontend/athena` - `npx next build --webpack` erfolgreich in `frontend/athena`
- `python3 -m compileall backend/hermes/app` erfolgreich im Projektroot - `python3 -m compileall backend/hermes/app` erfolgreich im Projektroot
- Alembic-Migrationen geprueft, falls Datenbankschema betroffen ist - Alembic-Migrationen geprueft, falls Datenbankschema betroffen ist
- `uv run alembic history` erfolgreich in `backend/hermes`
- `uv run alembic upgrade head` erfolgreich in `backend/hermes`, wenn Migrationen betroffen sind
- Docker Compose startet - Docker Compose startet
- keine `.env` im Git - keine `.env` im Git
- keine `node_modules` im Git - keine `node_modules` im Git
@ -85,6 +87,12 @@ npx next build --webpack
python3 -m compileall backend/hermes/app python3 -m compileall backend/hermes/app
``` ```
```bash
cd backend/hermes
uv run alembic history
uv run alembic upgrade head
```
## Coding Standards ## Coding Standards
### Frontend ### Frontend
@ -94,6 +102,7 @@ python3 -m compileall backend/hermes/app
- Browser-API-Zugriffe nur ueber Athena `/api/...`. - Browser-API-Zugriffe nur ueber Athena `/api/...`.
- Wiederverwendbare Komponenten bevorzugen. - Wiederverwendbare Komponenten bevorzugen.
- UI-Zustaende immer abbilden: Loading, Error, Empty State. - UI-Zustaende immer abbilden: Loading, Error, Empty State.
- Erfolg und Fehler in mutierenden CRUD-Flows ueber den Toast-Provider melden.
- Keine Browser-Dialoge wie `alert()` oder `confirm()`. - Keine Browser-Dialoge wie `alert()` oder `confirm()`.
- Keine Tokens in Browser-JavaScript speichern. - Keine Tokens in Browser-JavaScript speichern.
@ -107,6 +116,8 @@ python3 -m compileall backend/hermes/app
- Nicht gefundene Ressourcen als `404 Not Found`. - Nicht gefundene Ressourcen als `404 Not Found`.
- Authentifizierung serverseitig pruefen. - Authentifizierung serverseitig pruefen.
- Berechtigungen mit `require_permission`, `require_any_permission` oder `require_all_permissions` pruefen. - Berechtigungen mit `require_permission`, `require_any_permission` oder `require_all_permissions` pruefen.
- Mutierende Kernaktionen mit Audit Logs erfassen, sofern fachlich relevant.
- Sensible Felder vor Persistenz in Logs oder Audit-Daten maskieren.
### Allgemein ### Allgemein
@ -130,6 +141,8 @@ Diese Regeln sind verbindlich:
- Keine Cross-Origin-Cookie-Loesungen. - Keine Cross-Origin-Cookie-Loesungen.
- RBAC wird serverseitig in Hermes durchgesetzt. - RBAC wird serverseitig in Hermes durchgesetzt.
- Frontend-Permissions dienen nur der UI und ersetzen keine Backend-Pruefung. - Frontend-Permissions dienen nur der UI und ersetzen keine Backend-Pruefung.
- Audit Logs werden serverseitig in Hermes geschrieben.
- Neue Plattform-Endpunkte sollen das einheitliche API-Response-Format nutzen.
Wenn eine Aufgabe diese Regeln zu verletzen scheint, muss zuerst die Architekturentscheidung geklaert werden. Wenn eine Aufgabe diese Regeln zu verletzen scheint, muss zuerst die Architekturentscheidung geklaert werden.
@ -151,6 +164,7 @@ Verbindliche Wiederverwendung:
- `ConfirmDialog` fuer destruktive Aktionen - `ConfirmDialog` fuer destruktive Aktionen
- `SearchInput` fuer Suchfelder - `SearchInput` fuer Suchfelder
- `StatusBadge` oder aehnliche Badge-Komponenten fuer Statusanzeigen - `StatusBadge` oder aehnliche Badge-Komponenten fuer Statusanzeigen
- `ToastProvider` fuer Erfolg und Fehler bei mutierenden Aktionen
- `api` Client aus `frontend/athena/lib/api.ts` - `api` Client aus `frontend/athena/lib/api.ts`
- Athena API-Routes als BFF-Schicht - Athena API-Routes als BFF-Schicht
- konsistente Fehlerbehandlung - konsistente Fehlerbehandlung
@ -177,6 +191,7 @@ Ein neues CRM-Modul soll sich am Kundenmodul orientieren:
- Frontend-Seiten unter `frontend/athena/app/<module>` - Frontend-Seiten unter `frontend/athena/app/<module>`
- wiederverwendbare UI-Komponenten unter `frontend/athena/components` - wiederverwendbare UI-Komponenten unter `frontend/athena/components`
- RBAC-Permissions vor der UI-Integration definieren - RBAC-Permissions vor der UI-Integration definieren
- Audit-Log-Ereignisse fuer relevante Create/Update/Delete-Aktionen definieren
- keine Fake-Daten fuer noch nicht existierende Unterbereiche - keine Fake-Daten fuer noch nicht existierende Unterbereiche
Dashboard-Widgets fuer noch nicht implementierte Module muessen Empty States anzeigen statt hart codierter Beispieldaten. Dashboard-Widgets fuer noch nicht implementierte Module muessen Empty States anzeigen statt hart codierter Beispieldaten.
@ -228,6 +243,9 @@ Vor Merge pruefen:
- keine neuen CORS- oder Cookie-Workarounds - keine neuen CORS- oder Cookie-Workarounds
- Permission-Pruefung fuer neue Endpunkte vorhanden - Permission-Pruefung fuer neue Endpunkte vorhanden
- Permission-UI nur als Komfort, nicht als Sicherheitsgrenze - Permission-UI nur als Komfort, nicht als Sicherheitsgrenze
- Audit Logs fuer relevante Aenderungen vorhanden
- API-Fehlerantworten konsistent
- Toasts fuer mutierende UI-Aktionen vorhanden
## Migrationsregeln ## Migrationsregeln

66
ROADMAP.md Normal file
View file

@ -0,0 +1,66 @@
# Olympus CRM Roadmap
Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Architekturregeln aus `ARCHITECTURE.md` und Entwicklungsregeln aus `CONTRIBUTING.md` bleiben fuer alle Versionen verbindlich.
## v0.2.0 - Auth und Benutzerverwaltung
- Serverseitige Authentifizierung ueber Athena BFF und Hermes JWT
- HttpOnly-Cookie in Athena
- Benutzerverwaltung mit CRUD
- Passwortaenderung
- Self-Delete-Schutz
## v0.3.0 - RBAC
- Rollenmodell
- Berechtigungen
- Serverseitige Permission-Pruefung in Hermes
- Permission-basierte Navigation und UI-Aktionen in Athena
## v0.4.0 - Dashboard und Kundenmodul
- Dashboard mit berechtigungsabhaengigen Kennzahlen
- Kundenverwaltung mit Adressen und Ansprechpartnern
- Kunden-BFF-Routen in Athena
- Keine Fake-Daten fuer nicht implementierte Module
## v0.5.0 - Qualitaetsplattform
- Persistente Audit Logs
- Activity Feed im Dashboard
- Audit-Log-UI
- Einheitliches Fehler- und Response-Format fuer neue Plattform-Endpunkte
- Zentrales Hermes-Logging mit `LOG_LEVEL`
- Globale Toasts fuer CRUD-Erfolg und Fehler
- Docker-Konfiguration fuer Logging-Level
## v0.6.0 - Projektmodul, geplant
- Projektstammdaten
- Projektstatus und Verantwortliche
- Verknuepfung mit Kunden
- RBAC-Permissions fuer Projekte
- Audit Logs fuer Projektaktionen
## v0.7.0 - Tickets, geplant
- Ticketverwaltung
- Status- und Prioritaetsmodell
- Zuordnung zu Kunden und Benutzern
- RBAC-Permissions fuer Tickets
- Audit Logs fuer Ticketaktionen
## v0.8.0 - Kalender und Aufgaben, geplant
- Aufgabenverwaltung
- Kalenderansicht
- Faelligkeiten und Verantwortlichkeiten
- Verknuepfung mit Kunden, Projekten und Tickets
## v1.0.0 - Produktiver Beta-Release
- Gehaertete Produktionskonfiguration
- Vollstaendige Migrationsstrategie
- Deployment-Dokumentation
- Betriebs- und Backup-Konzept
- Review aller Sicherheitsregeln

View file

@ -0,0 +1,86 @@
"""add audit logs
Revision ID: a5e4b7c9d012
Revises: 9c1d8a2f6b44
Create Date: 2026-07-02 15:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "a5e4b7c9d012"
down_revision: Union[str, Sequence[str], None] = "9c1d8a2f6b44"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"audit_logs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("actor_user_id", sa.Integer(), nullable=True),
sa.Column("actor_username", sa.String(length=120), server_default="", nullable=False),
sa.Column("action", sa.String(length=120), nullable=False),
sa.Column("entity_type", sa.String(length=80), nullable=False),
sa.Column("entity_id", sa.Integer(), nullable=True),
sa.Column("entity_label", sa.String(length=255), server_default="", nullable=False),
sa.Column("ip_address", sa.String(length=80), server_default="", nullable=False),
sa.Column("user_agent", sa.Text(), server_default="", nullable=False),
sa.Column("before_data", sa.JSON(), nullable=True),
sa.Column("after_data", sa.JSON(), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_audit_logs_action"), "audit_logs", ["action"], unique=False)
op.create_index(op.f("ix_audit_logs_actor_user_id"), "audit_logs", ["actor_user_id"], unique=False)
op.create_index(op.f("ix_audit_logs_entity_id"), "audit_logs", ["entity_id"], unique=False)
op.create_index(op.f("ix_audit_logs_entity_type"), "audit_logs", ["entity_type"], unique=False)
op.create_index(op.f("ix_audit_logs_created_at"), "audit_logs", ["created_at"], unique=False)
op.execute(
"""
INSERT INTO permissions (name, display_name, description, module)
SELECT 'audit_logs.read', 'Audit Logs lesen', 'Audit Logs anzeigen', 'audit_logs'
WHERE NOT EXISTS (
SELECT 1 FROM permissions WHERE name = 'audit_logs.read'
)
"""
)
op.execute(
"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT roles.id, permissions.id
FROM roles
JOIN permissions ON permissions.name = 'audit_logs.read'
WHERE roles.name = 'administrator'
AND NOT EXISTS (
SELECT 1
FROM role_permissions
WHERE role_permissions.role_id = roles.id
AND role_permissions.permission_id = permissions.id
)
"""
)
def downgrade() -> None:
op.execute(
"""
DELETE FROM role_permissions
USING permissions
WHERE role_permissions.permission_id = permissions.id
AND permissions.name = 'audit_logs.read'
"""
)
op.execute("DELETE FROM permissions WHERE name = 'audit_logs.read'")
op.drop_index(op.f("ix_audit_logs_created_at"), table_name="audit_logs")
op.drop_index(op.f("ix_audit_logs_entity_type"), table_name="audit_logs")
op.drop_index(op.f("ix_audit_logs_entity_id"), table_name="audit_logs")
op.drop_index(op.f("ix_audit_logs_actor_user_id"), table_name="audit_logs")
op.drop_index(op.f("ix_audit_logs_action"), table_name="audit_logs")
op.drop_table("audit_logs")

View file

@ -0,0 +1,80 @@
from datetime import datetime
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.rbac import get_user_permission_names, require_permission
from app.db.database import get_db
from app.models.user import User
from app.repositories.audit_repository import AuditRepository
from app.schemas.api_response import ApiSuccess
from app.schemas.audit import ActivityFeedItem, AuditLogListResponse
from app.services.audit_service import audit_to_activity
router = APIRouter(tags=["Audit"])
@router.get("/audit-logs", response_model=ApiSuccess)
def get_audit_logs(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1, le=100),
entity_type: str | None = None,
actor_user_id: int | None = None,
action: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("audit_logs.read")),
):
items, total = AuditRepository.list(
db,
page=page,
page_size=page_size,
entity_type=entity_type,
actor_user_id=actor_user_id,
action=action,
date_from=date_from,
date_to=date_to,
)
return ApiSuccess(
data=AuditLogListResponse(
items=items,
total=total,
page=page,
page_size=page_size,
),
message="Audit Logs geladen",
)
def can_read_activity(action: str, permissions: set[str]) -> bool:
if action.startswith("users."):
return "users.read" in permissions
if action.startswith("customers.") or action.startswith("customer_contacts."):
return "customers.read" in permissions
if action.startswith("roles."):
return "roles.read" in permissions
if action.startswith("audit_logs."):
return "audit_logs.read" in permissions
if action.startswith("auth."):
return "audit_logs.read" in permissions
return False
@router.get("/activity-feed", response_model=ApiSuccess)
def get_activity_feed(
limit: int = Query(default=10, ge=1, le=50),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("dashboard.read")),
):
permissions = get_user_permission_names(current_user)
activities: list[ActivityFeedItem] = []
for audit_log in AuditRepository.latest(db, limit=100):
if not can_read_activity(audit_log.action, permissions):
continue
activities.append(ActivityFeedItem(**audit_to_activity(audit_log)))
if len(activities) >= limit:
break
return ApiSuccess(data=activities, message="Aktivitäten geladen")

View file

@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.security import ( from app.core.security import (
create_access_token, create_access_token,
@ -12,6 +13,7 @@ from app.models.user import User
from app.repositories.user_repository import UserRepository from app.repositories.user_repository import UserRepository
from app.schemas.rbac import CurrentUserResponse from app.schemas.rbac import CurrentUserResponse
from app.schemas.user import LoginRequest, LoginResponse from app.schemas.user import LoginRequest, LoginResponse
from app.services.audit_service import write_audit_log
router = APIRouter( router = APIRouter(
prefix="/auth", prefix="/auth",
@ -22,23 +24,50 @@ router = APIRouter(
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
def login( def login(
login: LoginRequest, login: LoginRequest,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
user = UserRepository.get_by_username(db, login.username) user = UserRepository.get_by_username(db, login.username)
if not user: if not user:
write_audit_log(
db,
action="auth.login.failed",
entity_type="auth",
entity_label=login.username,
actor_username=login.username,
request=request,
)
raise HTTPException( raise HTTPException(
status_code=401, status_code=401,
detail="Ungültige Anmeldedaten", detail="Ungültige Anmeldedaten",
) )
if not verify_password(login.password, user.password_hash): if not verify_password(login.password, user.password_hash):
write_audit_log(
db,
action="auth.login.failed",
entity_type="auth",
entity_label=login.username,
actor_user_id=user.id,
actor_username=user.username,
request=request,
)
raise HTTPException( raise HTTPException(
status_code=401, status_code=401,
detail="Ungültige Anmeldedaten", detail="Ungültige Anmeldedaten",
) )
token = create_access_token(user.username) token = create_access_token(user.username)
write_audit_log(
db,
action="auth.login.success",
entity_type="auth",
entity_id=user.id,
entity_label=user.username,
actor=user,
request=request,
)
return { return {
"access_token": token, "access_token": token,

View file

@ -2,6 +2,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.rbac import require_permission from app.core.rbac import require_permission
from app.db.database import get_db from app.db.database import get_db
@ -16,6 +17,7 @@ from app.schemas.customer import (
CustomerResponse, CustomerResponse,
CustomerUpdate, CustomerUpdate,
) )
from app.services.audit_service import sanitize, write_audit_log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -94,42 +96,81 @@ def get_customer(
) )
def create_customer( def create_customer(
customer: CustomerCreate, customer: CustomerCreate,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("customers.create")), current_user: User = Depends(require_permission("customers.create")),
): ):
ensure_unique_customer_number(db, customer.customer_number) ensure_unique_customer_number(db, customer.customer_number)
logger.info("customers.create", extra={"actor_user_id": current_user.id}) logger.info("customers.create", extra={"actor_user_id": current_user.id})
return CustomerRepository.create(db, customer) created_customer = CustomerRepository.create(db, customer)
write_audit_log(
db,
action="customers.create",
entity_type="customers",
entity_id=created_customer.id,
entity_label=created_customer.company_name,
actor=current_user,
request=request,
after_data=created_customer,
)
return created_customer
@router.put("/{customer_id}", response_model=CustomerResponse) @router.put("/{customer_id}", response_model=CustomerResponse)
def update_customer( def update_customer(
customer_id: int, customer_id: int,
customer: CustomerUpdate, customer: CustomerUpdate,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("customers.update")), current_user: User = Depends(require_permission("customers.update")),
): ):
db_customer = get_customer_or_404(db, customer_id) db_customer = get_customer_or_404(db, customer_id)
before_data = sanitize(db_customer)
ensure_unique_customer_number(db, customer.customer_number, customer_id) ensure_unique_customer_number(db, customer.customer_number, customer_id)
logger.info( logger.info(
"customers.update", "customers.update",
extra={"actor_user_id": current_user.id, "target_customer_id": customer_id}, extra={"actor_user_id": current_user.id, "target_customer_id": customer_id},
) )
return CustomerRepository.update(db, db_customer, customer) updated_customer = CustomerRepository.update(db, db_customer, customer)
write_audit_log(
db,
action="customers.update",
entity_type="customers",
entity_id=updated_customer.id,
entity_label=updated_customer.company_name,
actor=current_user,
request=request,
before_data=before_data,
after_data=updated_customer,
)
return updated_customer
@router.delete("/{customer_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{customer_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_customer( def delete_customer(
customer_id: int, customer_id: int,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("customers.delete")), current_user: User = Depends(require_permission("customers.delete")),
): ):
db_customer = get_customer_or_404(db, customer_id) db_customer = get_customer_or_404(db, customer_id)
before_data = sanitize(db_customer)
entity_label = db_customer.company_name
logger.info( logger.info(
"customers.delete", "customers.delete",
extra={"actor_user_id": current_user.id, "target_customer_id": customer_id}, extra={"actor_user_id": current_user.id, "target_customer_id": customer_id},
) )
CustomerRepository.delete(db, db_customer) CustomerRepository.delete(db, db_customer)
write_audit_log(
db,
action="customers.delete",
entity_type="customers",
entity_id=customer_id,
entity_label=entity_label,
actor=current_user,
request=request,
before_data=before_data,
)
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@ -155,15 +196,28 @@ def get_customer_contacts(
def create_customer_contact( def create_customer_contact(
customer_id: int, customer_id: int,
contact: CustomerContactCreate, contact: CustomerContactCreate,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("customers.update")), current_user: User = Depends(require_permission("customers.update")),
): ):
get_customer_or_404(db, customer_id) customer = get_customer_or_404(db, customer_id)
logger.info( logger.info(
"customers.contacts.create", "customers.contacts.create",
extra={"actor_user_id": current_user.id, "target_customer_id": customer_id}, extra={"actor_user_id": current_user.id, "target_customer_id": customer_id},
) )
return CustomerRepository.create_contact(db, customer_id, contact) created_contact = CustomerRepository.create_contact(db, customer_id, contact)
write_audit_log(
db,
action="customer_contacts.create",
entity_type="customer_contacts",
entity_id=created_contact.id,
entity_label=f"{created_contact.first_name} {created_contact.last_name}".strip(),
actor=current_user,
request=request,
after_data=created_contact,
metadata={"customer_id": customer_id, "customer_label": customer.company_name},
)
return created_contact
@router.put("/{customer_id}/contacts/{contact_id}", response_model=CustomerContactResponse) @router.put("/{customer_id}/contacts/{contact_id}", response_model=CustomerContactResponse)
@ -171,11 +225,13 @@ def update_customer_contact(
customer_id: int, customer_id: int,
contact_id: int, contact_id: int,
contact: CustomerContactUpdate, contact: CustomerContactUpdate,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("customers.update")), current_user: User = Depends(require_permission("customers.update")),
): ):
get_customer_or_404(db, customer_id) customer = get_customer_or_404(db, customer_id)
db_contact = get_contact_or_404(db, customer_id, contact_id) db_contact = get_contact_or_404(db, customer_id, contact_id)
before_data = sanitize(db_contact)
logger.info( logger.info(
"customers.contacts.update", "customers.contacts.update",
extra={ extra={
@ -184,18 +240,34 @@ def update_customer_contact(
"target_contact_id": contact_id, "target_contact_id": contact_id,
}, },
) )
return CustomerRepository.update_contact(db, db_contact, contact) updated_contact = CustomerRepository.update_contact(db, db_contact, contact)
write_audit_log(
db,
action="customer_contacts.update",
entity_type="customer_contacts",
entity_id=updated_contact.id,
entity_label=f"{updated_contact.first_name} {updated_contact.last_name}".strip(),
actor=current_user,
request=request,
before_data=before_data,
after_data=updated_contact,
metadata={"customer_id": customer_id, "customer_label": customer.company_name},
)
return updated_contact
@router.delete("/{customer_id}/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{customer_id}/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_customer_contact( def delete_customer_contact(
customer_id: int, customer_id: int,
contact_id: int, contact_id: int,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("customers.update")), current_user: User = Depends(require_permission("customers.update")),
): ):
get_customer_or_404(db, customer_id) customer = get_customer_or_404(db, customer_id)
db_contact = get_contact_or_404(db, customer_id, contact_id) db_contact = get_contact_or_404(db, customer_id, contact_id)
before_data = sanitize(db_contact)
entity_label = f"{db_contact.first_name} {db_contact.last_name}".strip()
logger.info( logger.info(
"customers.contacts.delete", "customers.contacts.delete",
extra={ extra={
@ -205,4 +277,15 @@ def delete_customer_contact(
}, },
) )
CustomerRepository.delete_contact(db, db_contact) CustomerRepository.delete_contact(db, db_contact)
write_audit_log(
db,
action="customer_contacts.delete",
entity_type="customer_contacts",
entity_id=contact_id,
entity_label=entity_label,
actor=current_user,
request=request,
before_data=before_data,
metadata={"customer_id": customer_id, "customer_label": customer.company_name},
)
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)

View file

@ -3,12 +3,14 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.rbac import require_permission from app.core.rbac import 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.rbac_repository import RbacRepository
from app.schemas.rbac import RoleCreate, RolePermissionUpdate, RoleResponse, RoleUpdate from app.schemas.rbac import RoleCreate, RolePermissionUpdate, RoleResponse, RoleUpdate
from app.services.audit_service import sanitize, write_audit_log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -80,6 +82,7 @@ def get_role(
) )
def create_role( def create_role(
role: RoleCreate, role: RoleCreate,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("roles.create")), current_user: User = Depends(require_permission("roles.create")),
): ):
@ -87,17 +90,30 @@ def create_role(
ensure_permission_ids_exist(db, role.permission_ids) ensure_permission_ids_exist(db, role.permission_ids)
logger.info("roles.create", extra={"actor_user_id": current_user.id}) logger.info("roles.create", extra={"actor_user_id": current_user.id})
return RbacRepository.create_role(db, role) created_role = RbacRepository.create_role(db, role)
write_audit_log(
db,
action="roles.create",
entity_type="roles",
entity_id=created_role.id,
entity_label=created_role.name,
actor=current_user,
request=request,
after_data=created_role,
)
return created_role
@router.put("/{role_id}", response_model=RoleResponse) @router.put("/{role_id}", response_model=RoleResponse)
def update_role( def update_role(
role_id: int, role_id: int,
role: RoleUpdate, role: RoleUpdate,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("roles.update")), current_user: User = Depends(require_permission("roles.update")),
): ):
db_role = get_role_or_404(db, role_id) db_role = get_role_or_404(db, role_id)
before_data = sanitize(db_role)
ensure_unique_role_name(db, role.name, role_id) ensure_unique_role_name(db, role.name, role_id)
ensure_permission_ids_exist(db, role.permission_ids) ensure_permission_ids_exist(db, role.permission_ids)
@ -105,16 +121,31 @@ def update_role(
"roles.update", "roles.update",
extra={"actor_user_id": current_user.id, "target_role_id": role_id}, extra={"actor_user_id": current_user.id, "target_role_id": role_id},
) )
return RbacRepository.update_role(db, db_role, role) updated_role = RbacRepository.update_role(db, db_role, role)
write_audit_log(
db,
action="roles.update",
entity_type="roles",
entity_id=updated_role.id,
entity_label=updated_role.name,
actor=current_user,
request=request,
before_data=before_data,
after_data=updated_role,
)
return updated_role
@router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_role( def delete_role(
role_id: int, role_id: int,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("roles.delete")), current_user: User = Depends(require_permission("roles.delete")),
): ):
db_role = get_role_or_404(db, role_id) db_role = get_role_or_404(db, role_id)
before_data = sanitize(db_role)
entity_label = db_role.name
if db_role.is_system: if db_role.is_system:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@ -133,6 +164,16 @@ def delete_role(
extra={"actor_user_id": current_user.id, "target_role_id": role_id}, extra={"actor_user_id": current_user.id, "target_role_id": role_id},
) )
RbacRepository.delete_role(db, db_role) RbacRepository.delete_role(db, db_role)
write_audit_log(
db,
action="roles.delete",
entity_type="roles",
entity_id=role_id,
entity_label=entity_label,
actor=current_user,
request=request,
before_data=before_data,
)
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@ -140,14 +181,28 @@ def delete_role(
def update_role_permissions( def update_role_permissions(
role_id: int, role_id: int,
permission_update: RolePermissionUpdate, permission_update: RolePermissionUpdate,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("roles.assign")), current_user: User = Depends(require_permission("roles.assign")),
): ):
db_role = get_role_or_404(db, role_id) db_role = get_role_or_404(db, role_id)
before_data = sanitize(db_role)
ensure_permission_ids_exist(db, permission_update.permission_ids) ensure_permission_ids_exist(db, permission_update.permission_ids)
logger.info( logger.info(
"roles.permissions_update", "roles.permissions_update",
extra={"actor_user_id": current_user.id, "target_role_id": role_id}, extra={"actor_user_id": current_user.id, "target_role_id": role_id},
) )
return RbacRepository.update_role_permissions(db, db_role, permission_update) updated_role = RbacRepository.update_role_permissions(db, db_role, permission_update)
write_audit_log(
db,
action="roles.permissions_update",
entity_type="roles",
entity_id=updated_role.id,
entity_label=updated_role.name,
actor=current_user,
request=request,
before_data=before_data,
after_data=updated_role,
)
return updated_role

View file

@ -2,6 +2,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.rbac import get_user_permission_names, require_permission 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
@ -9,6 +10,7 @@ from app.models.user import User
from app.repositories.rbac_repository import RbacRepository 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
from app.services.audit_service import sanitize, write_audit_log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -98,6 +100,7 @@ def get_user(
) )
def create_user( def create_user(
user: UserCreate, user: UserCreate,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.create")), current_user: User = Depends(require_permission("users.create")),
): ):
@ -113,17 +116,30 @@ def create_user(
raise_conflict(conflict[0]) raise_conflict(conflict[0])
logger.info("users.create", extra={"actor_user_id": current_user.id}) logger.info("users.create", extra={"actor_user_id": current_user.id})
return UserRepository.create(db, user) created_user = UserRepository.create(db, user)
write_audit_log(
db,
action="users.create",
entity_type="users",
entity_id=created_user.id,
entity_label=created_user.username,
actor=current_user,
request=request,
after_data=created_user,
)
return created_user
@router.put("/{user_id}", response_model=UserResponse) @router.put("/{user_id}", response_model=UserResponse)
def update_user( def update_user(
user_id: int, user_id: int,
user: UserUpdate, user: UserUpdate,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.update")), 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)
before_data = sanitize(db_user)
apply_role(db, user) apply_role(db, user)
if user.role_id != db_user.role_id: if user.role_id != db_user.role_id:
require_actor_permission(current_user, "roles.assign") require_actor_permission(current_user, "roles.assign")
@ -141,28 +157,56 @@ def update_user(
"users.update", "users.update",
extra={"actor_user_id": current_user.id, "target_user_id": user_id}, extra={"actor_user_id": current_user.id, "target_user_id": user_id},
) )
return UserRepository.update(db, db_user, user) updated_user = UserRepository.update(db, db_user, user)
write_audit_log(
db,
action="users.update",
entity_type="users",
entity_id=updated_user.id,
entity_label=updated_user.username,
actor=current_user,
request=request,
before_data=before_data,
after_data=updated_user,
)
return updated_user
@router.put("/{user_id}/password", response_model=UserResponse) @router.put("/{user_id}/password", response_model=UserResponse)
def update_user_password( def update_user_password(
user_id: int, user_id: int,
password_update: UserPasswordUpdate, password_update: UserPasswordUpdate,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.password.update")), 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)
before_data = sanitize(db_user)
logger.info( logger.info(
"users.password_update", "users.password_update",
extra={"actor_user_id": current_user.id, "target_user_id": user_id}, extra={"actor_user_id": current_user.id, "target_user_id": user_id},
) )
return UserRepository.update_password(db, db_user, password_update) updated_user = UserRepository.update_password(db, db_user, password_update)
write_audit_log(
db,
action="users.password_update",
entity_type="users",
entity_id=updated_user.id,
entity_label=updated_user.username,
actor=current_user,
request=request,
before_data=before_data,
after_data=updated_user,
metadata={"password_changed": True},
)
return updated_user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user( def delete_user(
user_id: int, user_id: int,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.delete")), current_user: User = Depends(require_permission("users.delete")),
): ):
@ -173,10 +217,22 @@ def delete_user(
) )
db_user = get_user_or_404(db, user_id) db_user = get_user_or_404(db, user_id)
before_data = sanitize(db_user)
entity_label = db_user.username
logger.info( logger.info(
"users.delete", "users.delete",
extra={"actor_user_id": current_user.id, "target_user_id": user_id}, extra={"actor_user_id": current_user.id, "target_user_id": user_id},
) )
UserRepository.delete(db, db_user) UserRepository.delete(db, db_user)
write_audit_log(
db,
action="users.delete",
entity_type="users",
entity_id=user_id,
entity_label=entity_label,
actor=current_user,
request=request,
before_data=before_data,
)
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)

View file

@ -9,6 +9,7 @@ class Settings(BaseSettings):
app_version: str = "0.1.0" app_version: str = "0.1.0"
access_token_expire_minutes: int = 60 access_token_expire_minutes: int = 60
jwt_issuer: str = "hermes" jwt_issuer: str = "hermes"
log_level: str = "INFO"
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",

View file

@ -0,0 +1,14 @@
import logging
import sys
from app.core.config import settings
def configure_logging() -> None:
level = getattr(logging, settings.log_level.upper(), logging.INFO)
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
stream=sys.stdout,
force=True,
)

View file

@ -22,6 +22,7 @@ class Base(DeclarativeBase):
# <<< HIER IMPORTIEREN >>> # <<< HIER IMPORTIEREN >>>
import app.models.rbac import app.models.rbac
import app.models.customer import app.models.customer
import app.models.audit
import app.models.user import app.models.user

View file

@ -1,4 +1,5 @@
import logging import logging
import time
from fastapi import FastAPI from fastapi import FastAPI
from fastapi import HTTPException from fastapi import HTTPException
@ -9,6 +10,7 @@ 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.audit import router as audit_router
from app.api.customers import router as customers_router from app.api.customers import router as customers_router
from app.api.dashboard import router as dashboard_router from app.api.dashboard import router as dashboard_router
from app.api.permissions import router as permissions_router from app.api.permissions import router as permissions_router
@ -16,8 +18,11 @@ 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.database import SessionLocal
from app.db.health import check_database from app.db.health import check_database
from app.core.logging import configure_logging
from app.rbac.seed import seed_rbac from app.rbac.seed import seed_rbac
configure_logging()
app = FastAPI( app = FastAPI(
title="Hermes API", title="Hermes API",
version="0.1.0", version="0.1.0",
@ -25,6 +30,7 @@ app = FastAPI(
) )
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(audit_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(roles_router) app.include_router(roles_router)
app.include_router(permissions_router) app.include_router(permissions_router)
@ -34,6 +40,34 @@ app.include_router(dashboard_router)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@app.middleware("http")
async def request_logging_middleware(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
duration_ms = round((time.perf_counter() - start) * 1000, 2)
logger.info(
"request.completed",
extra={
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": duration_ms,
},
)
return response
def error_code_for_status(status_code: int) -> str:
return {
400: "BAD_REQUEST",
401: "UNAUTHORIZED",
403: "FORBIDDEN",
404: "NOT_FOUND",
409: "CONFLICT",
422: "VALIDATION_ERROR",
}.get(status_code, "HTTP_ERROR")
@app.on_event("startup") @app.on_event("startup")
def startup_seed_rbac(): def startup_seed_rbac():
db = SessionLocal() db = SessionLocal()
@ -55,7 +89,13 @@ async def http_exception_handler(request: Request, exc: HTTPException):
) )
return JSONResponse( return JSONResponse(
status_code=exc.status_code, status_code=exc.status_code,
content={"detail": exc.detail}, content={
"success": False,
"message": str(exc.detail),
"error_code": error_code_for_status(exc.status_code),
"details": [],
"detail": exc.detail,
},
headers=exc.headers, headers=exc.headers,
) )
@ -68,7 +108,13 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
) )
return JSONResponse( return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"detail": jsonable_encoder(exc.errors())}, content={
"success": False,
"message": "Validierung fehlgeschlagen",
"error_code": "VALIDATION_ERROR",
"details": jsonable_encoder(exc.errors()),
"detail": jsonable_encoder(exc.errors()),
},
) )
@ -80,7 +126,13 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
) )
return JSONResponse( return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Interner Serverfehler"}, content={
"success": False,
"message": "Interner Serverfehler",
"error_code": "INTERNAL_SERVER_ERROR",
"details": [],
"detail": "Interner Serverfehler",
},
) )

View file

@ -0,0 +1,28 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(primary_key=True)
actor_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
actor_username: Mapped[str] = mapped_column(String(120), default="", server_default="")
action: Mapped[str] = mapped_column(String(120), index=True)
entity_type: Mapped[str] = mapped_column(String(80), index=True)
entity_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
entity_label: Mapped[str] = mapped_column(String(255), default="", server_default="")
ip_address: Mapped[str] = mapped_column(String(80), default="", server_default="")
user_agent: Mapped[str] = mapped_column(Text, default="", server_default="")
before_data: Mapped[dict | list | None] = mapped_column(JSON, nullable=True)
after_data: Mapped[dict | list | None] = mapped_column(JSON, nullable=True)
metadata_data: Mapped[dict | list | None] = mapped_column("metadata", JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View file

@ -69,6 +69,7 @@ STANDARD_PERMISSIONS = [
("dashboard.read", "Dashboard lesen", "Dashboard anzeigen", "dashboard"), ("dashboard.read", "Dashboard lesen", "Dashboard anzeigen", "dashboard"),
("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"), ("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"),
("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "system"), ("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "system"),
("audit_logs.read", "Audit Logs lesen", "Audit Logs anzeigen", "audit_logs"),
] ]
ROLE_PERMISSION_NAMES = { ROLE_PERMISSION_NAMES = {

View file

@ -0,0 +1,104 @@
from datetime import datetime
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
from app.models.audit import AuditLog
class AuditRepository:
@staticmethod
def create(
db: Session,
*,
actor_user_id: int | None,
actor_username: str,
action: str,
entity_type: str,
entity_id: int | None,
entity_label: str,
ip_address: str,
user_agent: str,
before_data: dict | list | None = None,
after_data: dict | list | None = None,
metadata: dict | list | None = None,
) -> AuditLog:
audit_log = AuditLog(
actor_user_id=actor_user_id,
actor_username=actor_username,
action=action,
entity_type=entity_type,
entity_id=entity_id,
entity_label=entity_label,
ip_address=ip_address,
user_agent=user_agent,
before_data=before_data,
after_data=after_data,
metadata_data=metadata,
)
db.add(audit_log)
db.commit()
db.refresh(audit_log)
return audit_log
@staticmethod
def query(
*,
entity_type: str | None = None,
actor_user_id: int | None = None,
action: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> Select[tuple[AuditLog]]:
query = select(AuditLog)
if entity_type:
query = query.where(AuditLog.entity_type == entity_type)
if actor_user_id is not None:
query = query.where(AuditLog.actor_user_id == actor_user_id)
if action:
query = query.where(AuditLog.action == action)
if date_from:
query = query.where(AuditLog.created_at >= date_from)
if date_to:
query = query.where(AuditLog.created_at <= date_to)
return query
@staticmethod
def list(
db: Session,
*,
page: int,
page_size: int,
entity_type: str | None = None,
actor_user_id: int | None = None,
action: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> tuple[list[AuditLog], int]:
base_query = AuditRepository.query(
entity_type=entity_type,
actor_user_id=actor_user_id,
action=action,
date_from=date_from,
date_to=date_to,
)
total = db.scalar(select(func.count()).select_from(base_query.subquery())) or 0
items = list(
db.scalars(
base_query
.order_by(AuditLog.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
)
return items, total
@staticmethod
def latest(db: Session, limit: int = 10) -> list[AuditLog]:
return list(
db.scalars(
select(AuditLog)
.order_by(AuditLog.created_at.desc())
.limit(limit)
)
)

View file

@ -0,0 +1,16 @@
from typing import Any
from pydantic import BaseModel, Field
class ApiSuccess(BaseModel):
success: bool = True
data: Any = None
message: str = ""
class ApiError(BaseModel):
success: bool = False
message: str
error_code: str
details: list[Any] = Field(default_factory=list)

View file

@ -0,0 +1,39 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict
class AuditLogResponse(BaseModel):
id: int
actor_user_id: int | None
actor_username: str
action: str
entity_type: str
entity_id: int | None
entity_label: str
ip_address: str
user_agent: str
before_data: Any = None
after_data: Any = None
metadata_data: Any = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class AuditLogListResponse(BaseModel):
items: list[AuditLogResponse]
total: int
page: int
page_size: int
class ActivityFeedItem(BaseModel):
id: int
title: str
description: str
actor: str
entity_type: str
entity_id: int | None
created_at: datetime

View file

@ -0,0 +1,148 @@
import logging
from typing import Any
from fastapi.encoders import jsonable_encoder
from sqlalchemy import inspect
from sqlalchemy.exc import NoInspectionAvailable
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.models.audit import AuditLog
from app.models.user import User
from app.repositories.audit_repository import AuditRepository
logger = logging.getLogger(__name__)
SENSITIVE_KEYS = {"password", "password_hash", "token", "access_token", "secret", "secret_key"}
def to_audit_data(value: Any, seen: set[int] | None = None) -> Any:
seen = seen or set()
if value is None or isinstance(value, str | int | float | bool):
return value
value_id = id(value)
if value_id in seen:
return None
if isinstance(value, list | tuple | set):
seen.add(value_id)
return [to_audit_data(item, seen) for item in value]
if isinstance(value, dict):
seen.add(value_id)
return {key: to_audit_data(item, seen) for key, item in value.items()}
try:
state = inspect(value)
except NoInspectionAvailable:
return jsonable_encoder(value)
seen.add(value_id)
data: dict[str, Any] = {}
for column in state.mapper.column_attrs:
data[column.key] = getattr(value, column.key)
for relationship in state.mapper.relationships:
if relationship.key in state.unloaded:
continue
related_value = getattr(value, relationship.key)
data[relationship.key] = to_audit_data(related_value, seen)
return data
def sanitize(value: Any) -> Any:
encoded = jsonable_encoder(to_audit_data(value))
if isinstance(encoded, dict):
return {
key: "***" if key in SENSITIVE_KEYS else sanitize(item)
for key, item in encoded.items()
}
if isinstance(encoded, list):
return [sanitize(item) for item in encoded]
return encoded
def request_ip(request: Request | None) -> str:
if request is None:
return ""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else ""
def write_audit_log(
db: Session,
*,
action: str,
entity_type: str,
entity_id: int | None = None,
entity_label: str = "",
actor: User | None = None,
actor_user_id: int | None = None,
actor_username: str = "",
request: Request | None = None,
before_data: Any = None,
after_data: Any = None,
metadata: Any = None,
) -> None:
try:
AuditRepository.create(
db,
actor_user_id=actor.id if actor is not None else actor_user_id,
actor_username=actor.username if actor is not None else actor_username,
action=action,
entity_type=entity_type,
entity_id=entity_id,
entity_label=entity_label,
ip_address=request_ip(request),
user_agent=request.headers.get("user-agent", "") if request is not None else "",
before_data=sanitize(before_data),
after_data=sanitize(after_data),
metadata=sanitize(metadata),
)
except Exception:
db.rollback()
logger.exception("audit.write_failed", extra={"action": action, "entity_type": entity_type})
def action_title(action: str) -> str:
labels = {
"auth.login.success": "Login erfolgreich",
"auth.login.failed": "Login fehlgeschlagen",
"auth.logout": "Logout",
"users.create": "Benutzer erstellt",
"users.update": "Benutzer bearbeitet",
"users.delete": "Benutzer gelöscht",
"users.password_update": "Passwort geändert",
"roles.create": "Rolle erstellt",
"roles.update": "Rolle bearbeitet",
"roles.delete": "Rolle gelöscht",
"roles.permissions_update": "Rollenberechtigungen geändert",
"customers.create": "Kunde erstellt",
"customers.update": "Kunde bearbeitet",
"customers.delete": "Kunde gelöscht",
"customer_contacts.create": "Ansprechpartner erstellt",
"customer_contacts.update": "Ansprechpartner bearbeitet",
"customer_contacts.delete": "Ansprechpartner gelöscht",
}
return labels.get(action, action)
def audit_to_activity(audit_log: AuditLog) -> dict[str, Any]:
title = action_title(audit_log.action)
actor = audit_log.actor_username or "System"
label = audit_log.entity_label or audit_log.entity_type
return {
"id": audit_log.id,
"title": title,
"description": f"{actor} · {label}",
"actor": actor,
"entity_type": audit_log.entity_type,
"entity_id": audit_log.entity_id,
"created_at": audit_log.created_at,
}

View file

@ -11,6 +11,7 @@ services:
SECRET_KEY: ${SECRET_KEY} SECRET_KEY: ${SECRET_KEY}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
JWT_ISSUER: ${JWT_ISSUER:-hermes} JWT_ISSUER: ${JWT_ISSUER:-hermes}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
ports: ports:
- "8000:8000" - "8000:8000"

View file

@ -14,9 +14,10 @@ services:
SECRET_KEY: ${SECRET_KEY} SECRET_KEY: ${SECRET_KEY}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
JWT_ISSUER: ${JWT_ISSUER:-hermes} JWT_ISSUER: ${JWT_ISSUER:-hermes}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
ports: expose:
- "8000:8000" - "8000"
networks: networks:
- olympus-network - olympus-network

View 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, `/activity-feed${request.nextUrl.search}`);
}

View 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, `/audit-logs${request.nextUrl.search}`);
}

View file

@ -0,0 +1,294 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Eye, Filter, RotateCcw } from "lucide-react";
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
import SearchInput from "@/components/common/SearchInput";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/lib/api";
import type { ApiSuccess, AuditLog, AuditLogListResponse } from "@/types/audit";
const pageSize = 20;
function getErrorMessage(error: unknown) {
if (typeof error === "object" && error !== null && "response" in error) {
const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response;
return response?.data?.message ?? response?.data?.detail ?? "Audit Logs konnten nicht geladen werden";
}
return "Audit Logs konnten nicht geladen werden";
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
timeStyle: "medium",
}).format(new Date(value));
}
function formatJson(value: unknown) {
if (value === null || value === undefined) {
return "-";
}
return JSON.stringify(value, null, 2);
}
export default function AuditLogsPage() {
const [items, setItems] = useState<AuditLog[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [entityType, setEntityType] = useState("");
const [action, setAction] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
const loadAuditLogs = useCallback(async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
});
if (entityType.trim()) {
params.set("entity_type", entityType.trim());
}
if (action.trim()) {
params.set("action", action.trim());
}
const response = await api.get<ApiSuccess<AuditLogListResponse>>(`/audit-logs?${params.toString()}`);
setItems(response.data.data.items);
setTotal(response.data.data.total);
} catch (err) {
setError(getErrorMessage(err));
} finally {
setLoading(false);
}
}, [action, entityType, page]);
useEffect(() => {
queueMicrotask(() => {
void loadAuditLogs();
});
}, [loadAuditLogs]);
const filteredItems = useMemo(() => {
const normalized = search.trim().toLowerCase();
if (!normalized) {
return items;
}
return items.filter((item) => (
item.actor_username.toLowerCase().includes(normalized)
|| item.action.toLowerCase().includes(normalized)
|| item.entity_type.toLowerCase().includes(normalized)
|| item.entity_label.toLowerCase().includes(normalized)
));
}, [items, search]);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const columns: DataTableColumn<AuditLog>[] = [
{
key: "created_at",
label: "Zeitpunkt",
render: (log) => <span className="whitespace-nowrap">{formatDate(log.created_at)}</span>,
},
{
key: "actor_username",
label: "Benutzer",
render: (log) => log.actor_username || "System",
},
{
key: "action",
label: "Aktion",
render: (log) => <span className="font-medium text-slate-950">{log.action}</span>,
},
{
key: "entity",
label: "Objekt",
render: (log) => (
<div>
<p className="font-medium text-slate-900">{log.entity_label || "-"}</p>
<p className="text-xs text-slate-500">{log.entity_type}{log.entity_id ? ` #${log.entity_id}` : ""}</p>
</div>
),
},
{
key: "ip_address",
label: "IP",
render: (log) => log.ip_address || "-",
},
{
key: "details",
label: "",
className: "px-4 py-3 text-right",
render: (log) => (
<Button type="button" variant="outline" size="sm" onClick={() => setSelectedLog(log)}>
<Eye size={16} />
Details
</Button>
),
},
];
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-950">Audit Logs</h1>
<p className="mt-1 text-sm text-slate-500">Nachvollziehbare Systemereignisse und Änderungen</p>
</div>
<Button type="button" variant="outline" onClick={() => void loadAuditLogs()}>
<RotateCcw size={16} />
Aktualisieren
</Button>
</div>
<section className="rounded-lg border bg-white p-4">
<div className="grid gap-4 lg:grid-cols-[1fr_220px_220px_auto] lg:items-end">
<SearchInput value={search} onChange={setSearch} placeholder="Audit Logs durchsuchen..." />
<div className="space-y-2">
<Label htmlFor="entity-type">Objekttyp</Label>
<Input
id="entity-type"
value={entityType}
onChange={(event) => {
setEntityType(event.target.value);
setPage(1);
}}
placeholder="customers"
/>
</div>
<div className="space-y-2">
<Label htmlFor="action">Aktion</Label>
<Input
id="action"
value={action}
onChange={(event) => {
setAction(event.target.value);
setPage(1);
}}
placeholder="users.update"
/>
</div>
<Button
type="button"
variant="outline"
onClick={() => {
setSearch("");
setEntityType("");
setAction("");
setPage(1);
}}
>
<Filter size={16} />
Zurücksetzen
</Button>
</div>
</section>
<DataTable
columns={columns}
rows={filteredItems}
rowKey={(row) => row.id}
sortKey="created_at"
sortDirection="desc"
loading={loading}
error={error}
emptyTitle="Keine Audit Logs gefunden"
emptyDescription="Für die aktuellen Filter sind keine Einträge vorhanden."
onSort={() => undefined}
/>
<div className="flex items-center justify-between text-sm text-slate-600">
<span>{total} Einträge</span>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
Zurück
</Button>
<span>Seite {page} von {totalPages}</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
>
Weiter
</Button>
</div>
</div>
<Dialog open={selectedLog !== null} onOpenChange={(open) => !open && setSelectedLog(null)}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>Audit Log Details</DialogTitle>
</DialogHeader>
{selectedLog && (
<div className="space-y-4">
<div className="grid gap-3 text-sm md:grid-cols-3">
<Detail label="Aktion" value={selectedLog.action} />
<Detail label="Benutzer" value={selectedLog.actor_username || "System"} />
<Detail label="Zeitpunkt" value={formatDate(selectedLog.created_at)} />
<Detail label="Objekt" value={`${selectedLog.entity_type}${selectedLog.entity_id ? ` #${selectedLog.entity_id}` : ""}`} />
<Detail label="Label" value={selectedLog.entity_label || "-"} />
<Detail label="IP" value={selectedLog.ip_address || "-"} />
</div>
<div className="grid gap-4 lg:grid-cols-3">
<JsonPanel title="Vorher" value={selectedLog.before_data} />
<JsonPanel title="Nachher" value={selectedLog.after_data} />
<JsonPanel title="Metadaten" value={selectedLog.metadata_data} />
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}
function Detail({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border bg-slate-50 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">{label}</p>
<p className="mt-1 break-words text-slate-950">{value}</p>
</div>
);
}
function JsonPanel({ title, value }: { title: string; value: unknown }) {
return (
<div>
<h2 className="mb-2 text-sm font-semibold text-slate-950">{title}</h2>
<pre className="max-h-96 overflow-auto rounded-lg border bg-slate-950 p-3 text-xs text-slate-100">
{formatJson(value)}
</pre>
</div>
);
}

View file

@ -7,6 +7,7 @@ import { ArrowLeft, Edit, Plus, Trash2 } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog"; import ConfirmDialog from "@/components/common/ConfirmDialog";
import DetailSection from "@/components/common/DetailSection"; import DetailSection from "@/components/common/DetailSection";
import { useToast } from "@/components/common/ToastProvider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import CustomerContactFormDialog from "@/components/customers/CustomerContactFormDialog"; import CustomerContactFormDialog from "@/components/customers/CustomerContactFormDialog";
import CustomerFormDialog from "@/components/customers/CustomerFormDialog"; import CustomerFormDialog from "@/components/customers/CustomerFormDialog";
@ -32,6 +33,7 @@ function formatDateTime(value: string) {
} }
export default function CustomerDetailPage() { export default function CustomerDetailPage() {
const { showToast } = useToast();
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const router = useRouter(); const router = useRouter();
const [customer, setCustomer] = useState<Customer | null>(null); const [customer, setCustomer] = useState<Customer | null>(null);
@ -79,8 +81,11 @@ export default function CustomerDetailPage() {
const response = await api.put<Customer>(`/customers/${customer.id}`, payload); const response = await api.put<Customer>(`/customers/${customer.id}`, payload);
setCustomer(response.data); setCustomer(response.data);
setFormOpen(false); setFormOpen(false);
showToast({ type: "success", title: "Kunde aktualisiert", description: response.data.company_name });
} catch (err) { } catch (err) {
setFormError(getErrorMessage(err)); const message = getErrorMessage(err);
setFormError(message);
showToast({ type: "error", title: "Kunde konnte nicht gespeichert werden", description: message });
} finally { } finally {
setPending(false); setPending(false);
} }
@ -101,14 +106,18 @@ export default function CustomerDetailPage() {
try { try {
if (selectedContact) { if (selectedContact) {
await api.put<CustomerContact>(`/customers/${customer.id}/contacts/${selectedContact.id}`, payload); await api.put<CustomerContact>(`/customers/${customer.id}/contacts/${selectedContact.id}`, payload);
showToast({ type: "success", title: "Ansprechpartner aktualisiert" });
} else { } else {
await api.post<CustomerContact>(`/customers/${customer.id}/contacts`, payload); await api.post<CustomerContact>(`/customers/${customer.id}/contacts`, payload);
showToast({ type: "success", title: "Ansprechpartner erstellt" });
} }
const response = await api.get<Customer>(`/customers/${customer.id}`); const response = await api.get<Customer>(`/customers/${customer.id}`);
setCustomer(response.data); setCustomer(response.data);
setContactOpen(false); setContactOpen(false);
} catch (err) { } catch (err) {
setContactError(getErrorMessage(err)); const message = getErrorMessage(err);
setContactError(message);
showToast({ type: "error", title: "Ansprechpartner konnte nicht gespeichert werden", description: message });
} finally { } finally {
setPending(false); setPending(false);
} }
@ -124,9 +133,12 @@ export default function CustomerDetailPage() {
await api.delete(`/customers/${customer.id}/contacts/${deleteContact.id}`); await api.delete(`/customers/${customer.id}/contacts/${deleteContact.id}`);
const response = await api.get<Customer>(`/customers/${customer.id}`); const response = await api.get<Customer>(`/customers/${customer.id}`);
setCustomer(response.data); setCustomer(response.data);
showToast({ type: "success", title: "Ansprechpartner gelöscht", description: `${deleteContact.first_name} ${deleteContact.last_name}` });
setDeleteContact(null); setDeleteContact(null);
} catch (err) { } catch (err) {
setDeleteError(getErrorMessage(err)); const message = getErrorMessage(err);
setDeleteError(message);
showToast({ type: "error", title: "Ansprechpartner konnte nicht gelöscht werden", description: message });
} finally { } finally {
setPending(false); setPending(false);
} }
@ -139,10 +151,14 @@ export default function CustomerDetailPage() {
setPending(true); setPending(true);
setDeleteError(""); setDeleteError("");
try { try {
const deletedCustomerName = customer.company_name;
await api.delete(`/customers/${customer.id}`); await api.delete(`/customers/${customer.id}`);
showToast({ type: "success", title: "Kunde gelöscht", description: deletedCustomerName });
router.push("/customers"); router.push("/customers");
} catch (err) { } catch (err) {
setDeleteError(getErrorMessage(err)); const message = getErrorMessage(err);
setDeleteError(message);
showToast({ type: "error", title: "Kunde konnte nicht gelöscht werden", description: message });
} finally { } finally {
setPending(false); setPending(false);
} }

View file

@ -7,6 +7,7 @@ import { Edit, Eye, Plus, Trash2 } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog"; import ConfirmDialog from "@/components/common/ConfirmDialog";
import DataTable, { type DataTableColumn } from "@/components/common/DataTable"; import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
import SearchInput from "@/components/common/SearchInput"; import SearchInput from "@/components/common/SearchInput";
import { useToast } from "@/components/common/ToastProvider";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import CustomerFormDialog from "@/components/customers/CustomerFormDialog"; import CustomerFormDialog from "@/components/customers/CustomerFormDialog";
import CustomerStatusBadge from "@/components/customers/CustomerStatusBadge"; import CustomerStatusBadge from "@/components/customers/CustomerStatusBadge";
@ -48,6 +49,7 @@ function getSortableValue(customer: Customer, key: string) {
} }
export default function CustomersPage() { export default function CustomersPage() {
const { showToast } = useToast();
const [customers, setCustomers] = useState<Customer[]>([]); const [customers, setCustomers] = useState<Customer[]>([]);
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null); const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -183,13 +185,17 @@ export default function CustomersPage() {
if (selectedCustomer) { if (selectedCustomer) {
const response = await api.put<Customer>(`/customers/${selectedCustomer.id}`, payload); const response = await api.put<Customer>(`/customers/${selectedCustomer.id}`, payload);
setCustomers((current) => current.map((customer) => customer.id === selectedCustomer.id ? response.data : customer)); setCustomers((current) => current.map((customer) => customer.id === selectedCustomer.id ? response.data : customer));
showToast({ type: "success", title: "Kunde aktualisiert", description: response.data.company_name });
} else { } else {
const response = await api.post<Customer>("/customers", payload); const response = await api.post<Customer>("/customers", payload);
setCustomers((current) => [response.data, ...current]); setCustomers((current) => [response.data, ...current]);
showToast({ type: "success", title: "Kunde erstellt", description: response.data.company_name });
} }
setFormOpen(false); setFormOpen(false);
} catch (err) { } catch (err) {
setFormError(getErrorMessage(err)); const message = getErrorMessage(err);
setFormError(message);
showToast({ type: "error", title: "Kunde konnte nicht gespeichert werden", description: message });
} finally { } finally {
setPending(false); setPending(false);
} }
@ -204,9 +210,12 @@ export default function CustomersPage() {
try { try {
await api.delete(`/customers/${deleteCustomer.id}`); await api.delete(`/customers/${deleteCustomer.id}`);
setCustomers((current) => current.filter((customer) => customer.id !== deleteCustomer.id)); setCustomers((current) => current.filter((customer) => customer.id !== deleteCustomer.id));
showToast({ type: "success", title: "Kunde gelöscht", description: deleteCustomer.company_name });
setDeleteCustomer(null); setDeleteCustomer(null);
} catch (err) { } catch (err) {
setDeleteError(getErrorMessage(err)); const message = getErrorMessage(err);
setDeleteError(message);
showToast({ type: "error", title: "Kunde konnte nicht gelöscht werden", description: message });
} finally { } finally {
setPending(false); setPending(false);
} }

View file

@ -5,6 +5,7 @@ import Link from "next/link";
import SummaryCard from "@/components/common/SummaryCard"; import SummaryCard from "@/components/common/SummaryCard";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { ActivityFeedItem, ApiSuccess } from "@/types/audit";
import type { DashboardSummary, EmptyWidget } from "@/types/dashboard"; import type { DashboardSummary, EmptyWidget } from "@/types/dashboard";
function getErrorMessage(error: unknown) { function getErrorMessage(error: unknown) {
@ -17,13 +18,19 @@ function getErrorMessage(error: unknown) {
export default function DashboardPage() { export default function DashboardPage() {
const [summary, setSummary] = useState<DashboardSummary | null>(null); const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [activities, setActivities] = useState<ActivityFeedItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [activityError, setActivityError] = useState("");
const loadSummary = useCallback(async () => { const loadSummary = useCallback(async () => {
try { try {
const response = await api.get<DashboardSummary>("/dashboard/summary"); const [summaryResponse, activityResponse] = await Promise.all([
setSummary(response.data); api.get<DashboardSummary>("/dashboard/summary"),
api.get<ApiSuccess<ActivityFeedItem[]>>("/activity-feed?limit=10"),
]);
setSummary(summaryResponse.data);
setActivities(activityResponse.data.data);
} catch (err) { } catch (err) {
setError(getErrorMessage(err)); setError(getErrorMessage(err));
} finally { } finally {
@ -31,6 +38,16 @@ export default function DashboardPage() {
} }
}, []); }, []);
const loadActivities = useCallback(async () => {
setActivityError("");
try {
const response = await api.get<ApiSuccess<ActivityFeedItem[]>>("/activity-feed?limit=10");
setActivities(response.data.data);
} catch (err) {
setActivityError(getErrorMessage(err));
}
}, []);
useEffect(() => { useEffect(() => {
queueMicrotask(() => { queueMicrotask(() => {
void loadSummary(); void loadSummary();
@ -92,7 +109,12 @@ export default function DashboardPage() {
</section> </section>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<EmptyPanel widget={summary.activities} /> <ActivityPanel
widget={summary.activities}
activities={activities}
error={activityError}
onReload={() => void loadActivities()}
/>
<EmptyPanel widget={summary.tasks} /> <EmptyPanel widget={summary.tasks} />
<EmptyPanel widget={summary.tickets} /> <EmptyPanel widget={summary.tickets} />
<EmptyPanel widget={summary.projects} /> <EmptyPanel widget={summary.projects} />
@ -101,6 +123,54 @@ export default function DashboardPage() {
); );
} }
function ActivityPanel({
widget,
activities,
error,
onReload,
}: {
widget: EmptyWidget;
activities: ActivityFeedItem[];
error: string;
onReload: () => void;
}) {
return (
<div className="rounded-lg border bg-white p-5 md:col-span-2 xl:col-span-4">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-slate-950">{widget.title}</h2>
<button type="button" className="text-sm font-medium text-slate-600 hover:text-slate-950" onClick={onReload}>
Aktualisieren
</button>
</div>
{error ? (
<p className="text-sm text-red-600">{error}</p>
) : activities.length === 0 ? (
<p className="text-sm text-slate-500">{widget.message}</p>
) : (
<div className="divide-y">
{activities.map((activity) => (
<div key={activity.id} className="py-3">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<p className="font-medium text-slate-950">{activity.title}</p>
<time className="text-xs text-slate-500" dateTime={activity.created_at}>
{new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(activity.created_at))}
</time>
</div>
<p className="mt-1 text-sm text-slate-500">
{activity.description} · {activity.actor || "System"}
</p>
</div>
))}
</div>
)}
</div>
);
}
function EmptyPanel({ widget }: { widget: EmptyWidget }) { function EmptyPanel({ widget }: { widget: EmptyWidget }) {
return ( return (
<div className="rounded-lg border bg-white p-5"> <div className="rounded-lg border bg-white p-5">

View file

@ -3,6 +3,7 @@ import "./globals.css";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import Header from "@/components/Header"; import Header from "@/components/Header";
import { ToastProvider } from "@/components/common/ToastProvider";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Olympus ERP", title: "Olympus ERP",
@ -18,23 +19,25 @@ export default function RootLayout({
<html lang="de"> <html lang="de">
<body className="bg-slate-100"> <body className="bg-slate-100">
<div className="flex h-screen"> <ToastProvider>
<div className="flex h-screen">
<Sidebar /> <Sidebar />
<div className="flex flex-col flex-1"> <div className="flex flex-col flex-1">
<Header /> <Header />
<main className="flex-1 overflow-auto p-8"> <main className="flex-1 overflow-auto p-8">
{children} {children}
</main> </main>
</div>
</div> </div>
</ToastProvider>
</div>
</body> </body>
</html> </html>

View file

@ -7,6 +7,7 @@ import { Edit, Eye, Shield, Trash2 } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog"; import ConfirmDialog from "@/components/common/ConfirmDialog";
import DataTable, { type DataTableColumn } from "@/components/common/DataTable"; import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
import SearchInput from "@/components/common/SearchInput"; import SearchInput from "@/components/common/SearchInput";
import { useToast } from "@/components/common/ToastProvider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -47,6 +48,7 @@ function getSortableValue(role: Role, key: string) {
} }
export default function RolesPage() { export default function RolesPage() {
const { showToast } = useToast();
const [roles, setRoles] = useState<Role[]>([]); const [roles, setRoles] = useState<Role[]>([]);
const [permissions, setPermissions] = useState<Permission[]>([]); const [permissions, setPermissions] = useState<Permission[]>([]);
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null); const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
@ -217,14 +219,18 @@ export default function RolesPage() {
setRoles((current) => current.map((role) => ( setRoles((current) => current.map((role) => (
role.id === selectedRole.id ? response.data : role role.id === selectedRole.id ? response.data : role
))); )));
showToast({ type: "success", title: "Rolle aktualisiert", description: response.data.display_name });
} else { } else {
const response = await api.post<Role>("/roles", payload); const response = await api.post<Role>("/roles", payload);
setRoles((current) => [response.data, ...current]); setRoles((current) => [response.data, ...current]);
showToast({ type: "success", title: "Rolle erstellt", description: response.data.display_name });
} }
setFormOpen(false); setFormOpen(false);
} catch (err) { } catch (err) {
setFormError(getErrorMessage(err)); const message = getErrorMessage(err);
setFormError(message);
showToast({ type: "error", title: "Rolle konnte nicht gespeichert werden", description: message });
} finally { } finally {
setPending(false); setPending(false);
} }
@ -241,9 +247,12 @@ export default function RolesPage() {
try { try {
await api.delete(`/roles/${deleteRole.id}`); await api.delete(`/roles/${deleteRole.id}`);
setRoles((current) => current.filter((role) => role.id !== deleteRole.id)); setRoles((current) => current.filter((role) => role.id !== deleteRole.id));
showToast({ type: "success", title: "Rolle gelöscht", description: deleteRole.display_name });
setDeleteRole(null); setDeleteRole(null);
} catch (err) { } catch (err) {
setDeleteError(getErrorMessage(err)); const message = getErrorMessage(err);
setDeleteError(message);
showToast({ type: "error", title: "Rolle konnte nicht gelöscht werden", description: message });
} finally { } finally {
setPending(false); setPending(false);
} }

View file

@ -7,6 +7,7 @@ import { useParams } from "next/navigation";
import { ArrowLeft, KeyRound, Pencil } from "lucide-react"; import { ArrowLeft, KeyRound, Pencil } from "lucide-react";
import StatusBadge from "@/components/common/StatusBadge"; import StatusBadge from "@/components/common/StatusBadge";
import { useToast } from "@/components/common/ToastProvider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -41,6 +42,7 @@ function formatDateTime(value: string) {
} }
export default function UserDetailPage() { export default function UserDetailPage() {
const { showToast } = useToast();
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 [roles, setRoles] = useState<Role[]>([]);
@ -94,8 +96,11 @@ export default function UserDetailPage() {
const response = await api.put<User>(`/users/${user.id}`, payload); const response = await api.put<User>(`/users/${user.id}`, payload);
setUser(response.data); setUser(response.data);
setFormOpen(false); setFormOpen(false);
showToast({ type: "success", title: "Benutzer aktualisiert", description: response.data.username });
} catch (err) { } catch (err) {
setFormError(getErrorMessage(err)); const message = getErrorMessage(err);
setFormError(message);
showToast({ type: "error", title: "Benutzer konnte nicht gespeichert werden", description: message });
} finally { } finally {
setPending(false); setPending(false);
} }
@ -119,8 +124,11 @@ export default function UserDetailPage() {
setUser(response.data); setUser(response.data);
setPassword(""); setPassword("");
setPasswordOpen(false); setPasswordOpen(false);
showToast({ type: "success", title: "Passwort geändert", description: response.data.username });
} catch (err) { } catch (err) {
setPasswordError(getErrorMessage(err)); const message = getErrorMessage(err);
setPasswordError(message);
showToast({ type: "error", title: "Passwort konnte nicht geändert werden", description: message });
} finally { } finally {
setPending(false); setPending(false);
} }

View file

@ -5,6 +5,7 @@ import { Plus } from "lucide-react";
import ConfirmDialog from "@/components/common/ConfirmDialog"; import ConfirmDialog from "@/components/common/ConfirmDialog";
import SearchInput from "@/components/common/SearchInput"; import SearchInput from "@/components/common/SearchInput";
import { useToast } from "@/components/common/ToastProvider";
import { Button } from "@/components/ui/button"; 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";
@ -32,6 +33,7 @@ function getSortableValue(user: User, key: string) {
} }
export default function UsersPage() { export default function UsersPage() {
const { showToast } = useToast();
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [roles, setRoles] = useState<Role[]>([]); const [roles, setRoles] = useState<Role[]>([]);
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null); const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
@ -159,14 +161,18 @@ export default function UsersPage() {
setUsers((current) => current.map((user) => ( setUsers((current) => current.map((user) => (
user.id === selectedUser.id ? response.data : user user.id === selectedUser.id ? response.data : user
))); )));
showToast({ type: "success", title: "Benutzer aktualisiert", description: response.data.username });
} else { } else {
const response = await api.post<User>("/users", payload); const response = await api.post<User>("/users", payload);
setUsers((current) => [response.data, ...current]); setUsers((current) => [response.data, ...current]);
showToast({ type: "success", title: "Benutzer erstellt", description: response.data.username });
} }
setFormOpen(false); setFormOpen(false);
} catch (err) { } catch (err) {
setFormError(getErrorMessage(err)); const message = getErrorMessage(err);
setFormError(message);
showToast({ type: "error", title: "Benutzer konnte nicht gespeichert werden", description: message });
} finally { } finally {
setPending(false); setPending(false);
} }
@ -183,9 +189,12 @@ export default function UsersPage() {
try { try {
await api.delete(`/users/${deleteUser.id}`); await api.delete(`/users/${deleteUser.id}`);
setUsers((current) => current.filter((user) => user.id !== deleteUser.id)); setUsers((current) => current.filter((user) => user.id !== deleteUser.id));
showToast({ type: "success", title: "Benutzer gelöscht", description: deleteUser.username });
setDeleteUser(null); setDeleteUser(null);
} catch (err) { } catch (err) {
setDeleteError(getErrorMessage(err)); const message = getErrorMessage(err);
setDeleteError(message);
showToast({ type: "error", title: "Benutzer konnte nicht gelöscht werden", description: message });
} finally { } finally {
setPending(false); setPending(false);
} }

View file

@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { import {
ClipboardList,
FileText, FileText,
LayoutDashboard, LayoutDashboard,
Package, Package,
@ -24,6 +25,12 @@ const menu = [
href: "/dashboard", href: "/dashboard",
permission: "dashboard.read", permission: "dashboard.read",
}, },
{
icon: ClipboardList,
name: "Audit Logs",
href: "/audit-logs",
permission: "audit_logs.read",
},
{ {
icon: Users, icon: Users,
name: "Benutzer", name: "Benutzer",

View file

@ -0,0 +1,107 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import { CheckCircle2, Info, X, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
type ToastType = "success" | "error" | "info";
type Toast = {
id: number;
type: ToastType;
title: string;
description?: string;
};
type ToastInput = Omit<Toast, "id">;
type ToastContextValue = {
showToast: (toast: ToastInput) => void;
};
const ToastContext = createContext<ToastContextValue | null>(null);
const iconByType = {
success: CheckCircle2,
error: XCircle,
info: Info,
};
const colorByType = {
success: "border-emerald-200 bg-emerald-50 text-emerald-950",
error: "border-red-200 bg-red-50 text-red-950",
info: "border-slate-200 bg-white text-slate-950",
};
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const dismissToast = useCallback((id: number) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const showToast = useCallback((toast: ToastInput) => {
const id = Date.now() + Math.floor(Math.random() * 1000);
setToasts((current) => [...current, { ...toast, id }]);
window.setTimeout(() => dismissToast(id), 5000);
}, [dismissToast]);
const value = useMemo(() => ({ showToast }), [showToast]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="fixed right-6 top-6 z-50 flex w-[min(24rem,calc(100vw-3rem))] flex-col gap-3">
{toasts.map((toast) => {
const Icon = iconByType[toast.type];
return (
<div
key={toast.id}
className={`rounded-lg border p-4 shadow-lg ${colorByType[toast.type]}`}
role="status"
>
<div className="flex gap-3">
<Icon className="mt-0.5 h-5 w-5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">{toast.title}</p>
{toast.description && (
<p className="mt-1 text-sm opacity-80">{toast.description}</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => dismissToast(toast.id)}
aria-label="Meldung schließen"
>
<X size={16} />
</Button>
</div>
</div>
);
})}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast muss innerhalb des ToastProvider verwendet werden");
}
return context;
}

View file

@ -0,0 +1,38 @@
export type ApiSuccess<T> = {
success: boolean;
data: T;
message: string;
};
export type AuditLog = {
id: number;
actor_user_id: number | null;
actor_username: string;
action: string;
entity_type: string;
entity_id: number | null;
entity_label: string;
ip_address: string;
user_agent: string;
before_data: unknown;
after_data: unknown;
metadata_data: Record<string, unknown>;
created_at: string;
};
export type AuditLogListResponse = {
items: AuditLog[];
total: number;
page: number;
page_size: number;
};
export type ActivityFeedItem = {
id: number;
title: string;
description: string;
actor: string;
entity_type: string;
entity_id: number | null;
created_at: string;
};