diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2257b68..870d764 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -58,6 +58,8 @@ Sie verbindet: - das konfigurierte Docker-Netzwerk - 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 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. +### 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 Olympus verwendet ein serverseitiges RBAC-System als Grundlage fuer alle CRM-Module. @@ -250,6 +260,10 @@ Dashboard und System: - `system.settings.read` - `system.settings.update` +Audit: + +- `audit_logs.read` + ### Berechtigungspruefung 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. +## 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 ```text backend/hermes app/ api/ + audit/ core/ db/ models/ @@ -393,6 +475,7 @@ backend/hermes frontend/athena app/ api/ + audit-logs/ customers/ roles/ users/ @@ -422,6 +505,9 @@ docker-compose.yml `ACCESS_TOKEN_EXPIRE_MINUTES` : Ablaufzeit fuer Access Tokens in Minuten. +`LOG_LEVEL` +: Runtime-Loglevel fuer Hermes, z. B. `INFO`, `WARNING` oder `ERROR`. + ### Athena `HERMES_INTERNAL_URL` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5027b0d..f4e323d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,6 +64,8 @@ Eine Aenderung gilt erst als fertig, wenn diese Punkte erfuellt sind: - `npx next build --webpack` erfolgreich in `frontend/athena` - `python3 -m compileall backend/hermes/app` erfolgreich im Projektroot - 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 - keine `.env` im Git - keine `node_modules` im Git @@ -85,6 +87,12 @@ npx next build --webpack python3 -m compileall backend/hermes/app ``` +```bash +cd backend/hermes +uv run alembic history +uv run alembic upgrade head +``` + ## Coding Standards ### Frontend @@ -94,6 +102,7 @@ python3 -m compileall backend/hermes/app - Browser-API-Zugriffe nur ueber Athena `/api/...`. - Wiederverwendbare Komponenten bevorzugen. - 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 Tokens in Browser-JavaScript speichern. @@ -107,6 +116,8 @@ python3 -m compileall backend/hermes/app - Nicht gefundene Ressourcen als `404 Not Found`. - Authentifizierung serverseitig 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 @@ -130,6 +141,8 @@ Diese Regeln sind verbindlich: - Keine Cross-Origin-Cookie-Loesungen. - RBAC wird serverseitig in Hermes durchgesetzt. - 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. @@ -151,6 +164,7 @@ Verbindliche Wiederverwendung: - `ConfirmDialog` fuer destruktive Aktionen - `SearchInput` fuer Suchfelder - `StatusBadge` oder aehnliche Badge-Komponenten fuer Statusanzeigen +- `ToastProvider` fuer Erfolg und Fehler bei mutierenden Aktionen - `api` Client aus `frontend/athena/lib/api.ts` - Athena API-Routes als BFF-Schicht - konsistente Fehlerbehandlung @@ -177,6 +191,7 @@ Ein neues CRM-Modul soll sich am Kundenmodul orientieren: - Frontend-Seiten unter `frontend/athena/app/` - wiederverwendbare UI-Komponenten unter `frontend/athena/components` - 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 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 - Permission-Pruefung fuer neue Endpunkte vorhanden - Permission-UI nur als Komfort, nicht als Sicherheitsgrenze +- Audit Logs fuer relevante Aenderungen vorhanden +- API-Fehlerantworten konsistent +- Toasts fuer mutierende UI-Aktionen vorhanden ## Migrationsregeln diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..764f49c --- /dev/null +++ b/ROADMAP.md @@ -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 diff --git a/backend/hermes/alembic/versions/a5e4b7c9d012_add_audit_logs.py b/backend/hermes/alembic/versions/a5e4b7c9d012_add_audit_logs.py new file mode 100644 index 0000000..11d3807 --- /dev/null +++ b/backend/hermes/alembic/versions/a5e4b7c9d012_add_audit_logs.py @@ -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") diff --git a/backend/hermes/app/api/audit.py b/backend/hermes/app/api/audit.py new file mode 100644 index 0000000..15d151c --- /dev/null +++ b/backend/hermes/app/api/audit.py @@ -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") diff --git a/backend/hermes/app/api/auth.py b/backend/hermes/app/api/auth.py index f325b87..0660f5d 100644 --- a/backend/hermes/app/api/auth.py +++ b/backend/hermes/app/api/auth.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session +from starlette.requests import Request from app.core.security import ( create_access_token, @@ -12,6 +13,7 @@ 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.services.audit_service import write_audit_log router = APIRouter( prefix="/auth", @@ -22,23 +24,50 @@ router = APIRouter( @router.post("/login", response_model=LoginResponse) def login( login: LoginRequest, + request: Request, db: Session = Depends(get_db), ): user = UserRepository.get_by_username(db, login.username) 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( status_code=401, detail="Ungültige Anmeldedaten", ) 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( status_code=401, detail="Ungültige Anmeldedaten", ) 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 { "access_token": token, diff --git a/backend/hermes/app/api/customers.py b/backend/hermes/app/api/customers.py index 3cc9339..d26e1be 100644 --- a/backend/hermes/app/api/customers.py +++ b/backend/hermes/app/api/customers.py @@ -2,6 +2,7 @@ import logging from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy.orm import Session +from starlette.requests import Request from app.core.rbac import require_permission from app.db.database import get_db @@ -16,6 +17,7 @@ from app.schemas.customer import ( CustomerResponse, CustomerUpdate, ) +from app.services.audit_service import sanitize, write_audit_log logger = logging.getLogger(__name__) @@ -94,42 +96,81 @@ def get_customer( ) def create_customer( customer: CustomerCreate, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("customers.create")), ): ensure_unique_customer_number(db, customer.customer_number) 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) def update_customer( customer_id: int, customer: CustomerUpdate, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("customers.update")), ): db_customer = get_customer_or_404(db, customer_id) + before_data = sanitize(db_customer) ensure_unique_customer_number(db, customer.customer_number, customer_id) logger.info( "customers.update", 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) def delete_customer( customer_id: int, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("customers.delete")), ): db_customer = get_customer_or_404(db, customer_id) + before_data = sanitize(db_customer) + entity_label = db_customer.company_name logger.info( "customers.delete", extra={"actor_user_id": current_user.id, "target_customer_id": customer_id}, ) 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) @@ -155,15 +196,28 @@ def get_customer_contacts( def create_customer_contact( customer_id: int, contact: CustomerContactCreate, + request: Request, db: Session = Depends(get_db), 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( "customers.contacts.create", 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) @@ -171,11 +225,13 @@ def update_customer_contact( customer_id: int, contact_id: int, contact: CustomerContactUpdate, + request: Request, db: Session = Depends(get_db), 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) + before_data = sanitize(db_contact) logger.info( "customers.contacts.update", extra={ @@ -184,18 +240,34 @@ def update_customer_contact( "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) def delete_customer_contact( customer_id: int, contact_id: int, + request: Request, db: Session = Depends(get_db), 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) + before_data = sanitize(db_contact) + entity_label = f"{db_contact.first_name} {db_contact.last_name}".strip() logger.info( "customers.contacts.delete", extra={ @@ -205,4 +277,15 @@ def delete_customer_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) diff --git a/backend/hermes/app/api/roles.py b/backend/hermes/app/api/roles.py index 72ae4bc..bc9c9ea 100644 --- a/backend/hermes/app/api/roles.py +++ b/backend/hermes/app/api/roles.py @@ -3,12 +3,14 @@ import logging from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy import select from sqlalchemy.orm import Session +from starlette.requests import Request 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 +from app.services.audit_service import sanitize, write_audit_log logger = logging.getLogger(__name__) @@ -80,6 +82,7 @@ def get_role( ) def create_role( role: RoleCreate, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("roles.create")), ): @@ -87,17 +90,30 @@ def create_role( 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) + 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) def update_role( role_id: int, role: RoleUpdate, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("roles.update")), ): db_role = get_role_or_404(db, role_id) + before_data = sanitize(db_role) ensure_unique_role_name(db, role.name, role_id) ensure_permission_ids_exist(db, role.permission_ids) @@ -105,16 +121,31 @@ def update_role( "roles.update", 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) def delete_role( role_id: int, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("roles.delete")), ): db_role = get_role_or_404(db, role_id) + before_data = sanitize(db_role) + entity_label = db_role.name if db_role.is_system: raise HTTPException( 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}, ) 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) @@ -140,14 +181,28 @@ def delete_role( def update_role_permissions( role_id: int, permission_update: RolePermissionUpdate, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("roles.assign")), ): db_role = get_role_or_404(db, role_id) + before_data = sanitize(db_role) 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) + 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 diff --git a/backend/hermes/app/api/users.py b/backend/hermes/app/api/users.py index e394cb7..2bd277b 100644 --- a/backend/hermes/app/api/users.py +++ b/backend/hermes/app/api/users.py @@ -2,6 +2,7 @@ import logging from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy.orm import Session +from starlette.requests import Request from app.core.rbac import get_user_permission_names, require_permission 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.user_repository import UserRepository from app.schemas.user import UserCreate, UserPasswordUpdate, UserResponse, UserUpdate +from app.services.audit_service import sanitize, write_audit_log logger = logging.getLogger(__name__) @@ -98,6 +100,7 @@ def get_user( ) def create_user( user: UserCreate, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("users.create")), ): @@ -113,17 +116,30 @@ def create_user( raise_conflict(conflict[0]) 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) def update_user( user_id: int, user: UserUpdate, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("users.update")), ): db_user = get_user_or_404(db, user_id) + before_data = sanitize(db_user) apply_role(db, user) if user.role_id != db_user.role_id: require_actor_permission(current_user, "roles.assign") @@ -141,28 +157,56 @@ def update_user( "users.update", 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) def update_user_password( user_id: int, password_update: UserPasswordUpdate, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("users.password.update")), ): db_user = get_user_or_404(db, user_id) + before_data = sanitize(db_user) logger.info( "users.password_update", 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) def delete_user( user_id: int, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("users.delete")), ): @@ -173,10 +217,22 @@ def delete_user( ) db_user = get_user_or_404(db, user_id) + before_data = sanitize(db_user) + entity_label = db_user.username logger.info( "users.delete", extra={"actor_user_id": current_user.id, "target_user_id": user_id}, ) 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) diff --git a/backend/hermes/app/core/config.py b/backend/hermes/app/core/config.py index 8a19737..685d618 100644 --- a/backend/hermes/app/core/config.py +++ b/backend/hermes/app/core/config.py @@ -9,6 +9,7 @@ class Settings(BaseSettings): app_version: str = "0.1.0" access_token_expire_minutes: int = 60 jwt_issuer: str = "hermes" + log_level: str = "INFO" model_config = SettingsConfigDict( env_file=".env", diff --git a/backend/hermes/app/core/logging.py b/backend/hermes/app/core/logging.py new file mode 100644 index 0000000..20f777b --- /dev/null +++ b/backend/hermes/app/core/logging.py @@ -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, + ) diff --git a/backend/hermes/app/db/database.py b/backend/hermes/app/db/database.py index dce9d72..3537b5a 100644 --- a/backend/hermes/app/db/database.py +++ b/backend/hermes/app/db/database.py @@ -22,6 +22,7 @@ class Base(DeclarativeBase): # <<< HIER IMPORTIEREN >>> import app.models.rbac import app.models.customer +import app.models.audit import app.models.user diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index a5de34d..2ef7a74 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -1,4 +1,5 @@ import logging +import time from fastapi import FastAPI from fastapi import HTTPException @@ -9,6 +10,7 @@ from starlette import status from starlette.requests import Request 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.dashboard import router as dashboard_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.db.database import SessionLocal from app.db.health import check_database +from app.core.logging import configure_logging from app.rbac.seed import seed_rbac +configure_logging() + app = FastAPI( title="Hermes API", version="0.1.0", @@ -25,6 +30,7 @@ app = FastAPI( ) app.include_router(auth_router) +app.include_router(audit_router) app.include_router(users_router) app.include_router(roles_router) app.include_router(permissions_router) @@ -34,6 +40,34 @@ app.include_router(dashboard_router) 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") def startup_seed_rbac(): db = SessionLocal() @@ -55,7 +89,13 @@ async def http_exception_handler(request: Request, exc: HTTPException): ) return JSONResponse( 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, ) @@ -68,7 +108,13 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE ) return JSONResponse( 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( 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", + }, ) diff --git a/backend/hermes/app/models/audit.py b/backend/hermes/app/models/audit.py new file mode 100644 index 0000000..3a182b7 --- /dev/null +++ b/backend/hermes/app/models/audit.py @@ -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()) diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py index 0c7b9fa..ff244bf 100644 --- a/backend/hermes/app/rbac/defaults.py +++ b/backend/hermes/app/rbac/defaults.py @@ -69,6 +69,7 @@ STANDARD_PERMISSIONS = [ ("dashboard.read", "Dashboard lesen", "Dashboard anzeigen", "dashboard"), ("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"), ("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "system"), + ("audit_logs.read", "Audit Logs lesen", "Audit Logs anzeigen", "audit_logs"), ] ROLE_PERMISSION_NAMES = { diff --git a/backend/hermes/app/repositories/audit_repository.py b/backend/hermes/app/repositories/audit_repository.py new file mode 100644 index 0000000..2277e05 --- /dev/null +++ b/backend/hermes/app/repositories/audit_repository.py @@ -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) + ) + ) diff --git a/backend/hermes/app/schemas/api_response.py b/backend/hermes/app/schemas/api_response.py new file mode 100644 index 0000000..0173f23 --- /dev/null +++ b/backend/hermes/app/schemas/api_response.py @@ -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) diff --git a/backend/hermes/app/schemas/audit.py b/backend/hermes/app/schemas/audit.py new file mode 100644 index 0000000..e576b86 --- /dev/null +++ b/backend/hermes/app/schemas/audit.py @@ -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 diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py new file mode 100644 index 0000000..f981a1d --- /dev/null +++ b/backend/hermes/app/services/audit_service.py @@ -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, + } diff --git a/backend/hermes/docker-compose.yml b/backend/hermes/docker-compose.yml index 1f0635e..c0816c2 100644 --- a/backend/hermes/docker-compose.yml +++ b/backend/hermes/docker-compose.yml @@ -11,6 +11,7 @@ services: SECRET_KEY: ${SECRET_KEY} ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} JWT_ISSUER: ${JWT_ISSUER:-hermes} + LOG_LEVEL: ${LOG_LEVEL:-INFO} ports: - "8000:8000" diff --git a/docker-compose.yml b/docker-compose.yml index f8ce432..f0768fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,9 +14,10 @@ services: SECRET_KEY: ${SECRET_KEY} ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} JWT_ISSUER: ${JWT_ISSUER:-hermes} + LOG_LEVEL: ${LOG_LEVEL:-INFO} - ports: - - "8000:8000" + expose: + - "8000" networks: - olympus-network diff --git a/frontend/athena/app/api/activity-feed/route.ts b/frontend/athena/app/api/activity-feed/route.ts new file mode 100644 index 0000000..962a3b7 --- /dev/null +++ b/frontend/athena/app/api/activity-feed/route.ts @@ -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}`); +} diff --git a/frontend/athena/app/api/audit-logs/route.ts b/frontend/athena/app/api/audit-logs/route.ts new file mode 100644 index 0000000..486c3b5 --- /dev/null +++ b/frontend/athena/app/api/audit-logs/route.ts @@ -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}`); +} diff --git a/frontend/athena/app/audit-logs/page.tsx b/frontend/athena/app/audit-logs/page.tsx new file mode 100644 index 0000000..7d84590 --- /dev/null +++ b/frontend/athena/app/audit-logs/page.tsx @@ -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([]); + 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(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>(`/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[] = [ + { + key: "created_at", + label: "Zeitpunkt", + render: (log) => {formatDate(log.created_at)}, + }, + { + key: "actor_username", + label: "Benutzer", + render: (log) => log.actor_username || "System", + }, + { + key: "action", + label: "Aktion", + render: (log) => {log.action}, + }, + { + key: "entity", + label: "Objekt", + render: (log) => ( +
+

{log.entity_label || "-"}

+

{log.entity_type}{log.entity_id ? ` #${log.entity_id}` : ""}

+
+ ), + }, + { + key: "ip_address", + label: "IP", + render: (log) => log.ip_address || "-", + }, + { + key: "details", + label: "", + className: "px-4 py-3 text-right", + render: (log) => ( + + ), + }, + ]; + + return ( +
+
+
+

Audit Logs

+

Nachvollziehbare Systemereignisse und Änderungen

+
+ +
+ +
+
+ + +
+ + { + setEntityType(event.target.value); + setPage(1); + }} + placeholder="customers" + /> +
+ +
+ + { + setAction(event.target.value); + setPage(1); + }} + placeholder="users.update" + /> +
+ + +
+
+ + 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} + /> + +
+ {total} Einträge +
+ + Seite {page} von {totalPages} + +
+
+ + !open && setSelectedLog(null)}> + + + Audit Log Details + + {selectedLog && ( +
+
+ + + + + + +
+ +
+ + + +
+
+ )} +
+
+
+ ); +} + +function Detail({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function JsonPanel({ title, value }: { title: string; value: unknown }) { + return ( +
+

{title}

+
+        {formatJson(value)}
+      
+
+ ); +} diff --git a/frontend/athena/app/customers/[id]/page.tsx b/frontend/athena/app/customers/[id]/page.tsx index 7ef74f0..12ee9c8 100644 --- a/frontend/athena/app/customers/[id]/page.tsx +++ b/frontend/athena/app/customers/[id]/page.tsx @@ -7,6 +7,7 @@ import { ArrowLeft, Edit, Plus, Trash2 } from "lucide-react"; import ConfirmDialog from "@/components/common/ConfirmDialog"; import DetailSection from "@/components/common/DetailSection"; +import { useToast } from "@/components/common/ToastProvider"; import { Button } from "@/components/ui/button"; import CustomerContactFormDialog from "@/components/customers/CustomerContactFormDialog"; import CustomerFormDialog from "@/components/customers/CustomerFormDialog"; @@ -32,6 +33,7 @@ function formatDateTime(value: string) { } export default function CustomerDetailPage() { + const { showToast } = useToast(); const params = useParams<{ id: string }>(); const router = useRouter(); const [customer, setCustomer] = useState(null); @@ -79,8 +81,11 @@ export default function CustomerDetailPage() { const response = await api.put(`/customers/${customer.id}`, payload); setCustomer(response.data); setFormOpen(false); + showToast({ type: "success", title: "Kunde aktualisiert", description: response.data.company_name }); } catch (err) { - setFormError(getErrorMessage(err)); + const message = getErrorMessage(err); + setFormError(message); + showToast({ type: "error", title: "Kunde konnte nicht gespeichert werden", description: message }); } finally { setPending(false); } @@ -101,14 +106,18 @@ export default function CustomerDetailPage() { try { if (selectedContact) { await api.put(`/customers/${customer.id}/contacts/${selectedContact.id}`, payload); + showToast({ type: "success", title: "Ansprechpartner aktualisiert" }); } else { await api.post(`/customers/${customer.id}/contacts`, payload); + showToast({ type: "success", title: "Ansprechpartner erstellt" }); } const response = await api.get(`/customers/${customer.id}`); setCustomer(response.data); setContactOpen(false); } catch (err) { - setContactError(getErrorMessage(err)); + const message = getErrorMessage(err); + setContactError(message); + showToast({ type: "error", title: "Ansprechpartner konnte nicht gespeichert werden", description: message }); } finally { setPending(false); } @@ -124,9 +133,12 @@ export default function CustomerDetailPage() { await api.delete(`/customers/${customer.id}/contacts/${deleteContact.id}`); const response = await api.get(`/customers/${customer.id}`); setCustomer(response.data); + showToast({ type: "success", title: "Ansprechpartner gelöscht", description: `${deleteContact.first_name} ${deleteContact.last_name}` }); setDeleteContact(null); } catch (err) { - setDeleteError(getErrorMessage(err)); + const message = getErrorMessage(err); + setDeleteError(message); + showToast({ type: "error", title: "Ansprechpartner konnte nicht gelöscht werden", description: message }); } finally { setPending(false); } @@ -139,10 +151,14 @@ export default function CustomerDetailPage() { setPending(true); setDeleteError(""); try { + const deletedCustomerName = customer.company_name; await api.delete(`/customers/${customer.id}`); + showToast({ type: "success", title: "Kunde gelöscht", description: deletedCustomerName }); router.push("/customers"); } catch (err) { - setDeleteError(getErrorMessage(err)); + const message = getErrorMessage(err); + setDeleteError(message); + showToast({ type: "error", title: "Kunde konnte nicht gelöscht werden", description: message }); } finally { setPending(false); } diff --git a/frontend/athena/app/customers/page.tsx b/frontend/athena/app/customers/page.tsx index ee72ae6..69a9e1a 100644 --- a/frontend/athena/app/customers/page.tsx +++ b/frontend/athena/app/customers/page.tsx @@ -7,6 +7,7 @@ import { Edit, Eye, Plus, 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 { useToast } from "@/components/common/ToastProvider"; import { Button, buttonVariants } from "@/components/ui/button"; import CustomerFormDialog from "@/components/customers/CustomerFormDialog"; import CustomerStatusBadge from "@/components/customers/CustomerStatusBadge"; @@ -48,6 +49,7 @@ function getSortableValue(customer: Customer, key: string) { } export default function CustomersPage() { + const { showToast } = useToast(); const [customers, setCustomers] = useState([]); const [currentUser, setCurrentUser] = useState(null); const [loading, setLoading] = useState(true); @@ -183,13 +185,17 @@ export default function CustomersPage() { if (selectedCustomer) { const response = await api.put(`/customers/${selectedCustomer.id}`, payload); setCustomers((current) => current.map((customer) => customer.id === selectedCustomer.id ? response.data : customer)); + showToast({ type: "success", title: "Kunde aktualisiert", description: response.data.company_name }); } else { const response = await api.post("/customers", payload); setCustomers((current) => [response.data, ...current]); + showToast({ type: "success", title: "Kunde erstellt", description: response.data.company_name }); } setFormOpen(false); } catch (err) { - setFormError(getErrorMessage(err)); + const message = getErrorMessage(err); + setFormError(message); + showToast({ type: "error", title: "Kunde konnte nicht gespeichert werden", description: message }); } finally { setPending(false); } @@ -204,9 +210,12 @@ export default function CustomersPage() { try { await api.delete(`/customers/${deleteCustomer.id}`); setCustomers((current) => current.filter((customer) => customer.id !== deleteCustomer.id)); + showToast({ type: "success", title: "Kunde gelöscht", description: deleteCustomer.company_name }); setDeleteCustomer(null); } catch (err) { - setDeleteError(getErrorMessage(err)); + const message = getErrorMessage(err); + setDeleteError(message); + showToast({ type: "error", title: "Kunde konnte nicht gelöscht werden", description: message }); } finally { setPending(false); } diff --git a/frontend/athena/app/dashboard/page.tsx b/frontend/athena/app/dashboard/page.tsx index bb1d1b2..be833b6 100644 --- a/frontend/athena/app/dashboard/page.tsx +++ b/frontend/athena/app/dashboard/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import SummaryCard from "@/components/common/SummaryCard"; import { api } from "@/lib/api"; +import type { ActivityFeedItem, ApiSuccess } from "@/types/audit"; import type { DashboardSummary, EmptyWidget } from "@/types/dashboard"; function getErrorMessage(error: unknown) { @@ -17,13 +18,19 @@ function getErrorMessage(error: unknown) { export default function DashboardPage() { const [summary, setSummary] = useState(null); + const [activities, setActivities] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + const [activityError, setActivityError] = useState(""); const loadSummary = useCallback(async () => { try { - const response = await api.get("/dashboard/summary"); - setSummary(response.data); + const [summaryResponse, activityResponse] = await Promise.all([ + api.get("/dashboard/summary"), + api.get>("/activity-feed?limit=10"), + ]); + setSummary(summaryResponse.data); + setActivities(activityResponse.data.data); } catch (err) { setError(getErrorMessage(err)); } finally { @@ -31,6 +38,16 @@ export default function DashboardPage() { } }, []); + const loadActivities = useCallback(async () => { + setActivityError(""); + try { + const response = await api.get>("/activity-feed?limit=10"); + setActivities(response.data.data); + } catch (err) { + setActivityError(getErrorMessage(err)); + } + }, []); + useEffect(() => { queueMicrotask(() => { void loadSummary(); @@ -92,7 +109,12 @@ export default function DashboardPage() {
- + void loadActivities()} + /> @@ -101,6 +123,54 @@ export default function DashboardPage() { ); } +function ActivityPanel({ + widget, + activities, + error, + onReload, +}: { + widget: EmptyWidget; + activities: ActivityFeedItem[]; + error: string; + onReload: () => void; +}) { + return ( +
+
+

{widget.title}

+ +
+ + {error ? ( +

{error}

+ ) : activities.length === 0 ? ( +

{widget.message}

+ ) : ( +
+ {activities.map((activity) => ( +
+
+

{activity.title}

+ +
+

+ {activity.description} · {activity.actor || "System"} +

+
+ ))} +
+ )} +
+ ); +} + function EmptyPanel({ widget }: { widget: EmptyWidget }) { return (
diff --git a/frontend/athena/app/layout.tsx b/frontend/athena/app/layout.tsx index 7de60c4..7692295 100644 --- a/frontend/athena/app/layout.tsx +++ b/frontend/athena/app/layout.tsx @@ -3,6 +3,7 @@ import "./globals.css"; import Sidebar from "@/components/Sidebar"; import Header from "@/components/Header"; +import { ToastProvider } from "@/components/common/ToastProvider"; export const metadata: Metadata = { title: "Olympus ERP", @@ -18,25 +19,27 @@ export default function RootLayout({ -
+ +
- + -
+
-
+
-
+
- {children} + {children} -
+
+ +
- -
+
); -} \ No newline at end of file +} diff --git a/frontend/athena/app/roles/page.tsx b/frontend/athena/app/roles/page.tsx index 96787de..2fbb84b 100644 --- a/frontend/athena/app/roles/page.tsx +++ b/frontend/athena/app/roles/page.tsx @@ -7,6 +7,7 @@ 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 { useToast } from "@/components/common/ToastProvider"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -47,6 +48,7 @@ function getSortableValue(role: Role, key: string) { } export default function RolesPage() { + const { showToast } = useToast(); const [roles, setRoles] = useState([]); const [permissions, setPermissions] = useState([]); const [currentUser, setCurrentUser] = useState(null); @@ -217,14 +219,18 @@ export default function RolesPage() { setRoles((current) => current.map((role) => ( role.id === selectedRole.id ? response.data : role ))); + showToast({ type: "success", title: "Rolle aktualisiert", description: response.data.display_name }); } else { const response = await api.post("/roles", payload); setRoles((current) => [response.data, ...current]); + showToast({ type: "success", title: "Rolle erstellt", description: response.data.display_name }); } setFormOpen(false); } catch (err) { - setFormError(getErrorMessage(err)); + const message = getErrorMessage(err); + setFormError(message); + showToast({ type: "error", title: "Rolle konnte nicht gespeichert werden", description: message }); } finally { setPending(false); } @@ -241,9 +247,12 @@ export default function RolesPage() { try { await api.delete(`/roles/${deleteRole.id}`); setRoles((current) => current.filter((role) => role.id !== deleteRole.id)); + showToast({ type: "success", title: "Rolle gelöscht", description: deleteRole.display_name }); setDeleteRole(null); } catch (err) { - setDeleteError(getErrorMessage(err)); + const message = getErrorMessage(err); + setDeleteError(message); + showToast({ type: "error", title: "Rolle konnte nicht gelöscht werden", description: message }); } finally { setPending(false); } diff --git a/frontend/athena/app/users/[id]/page.tsx b/frontend/athena/app/users/[id]/page.tsx index a5c1c5e..9769b00 100644 --- a/frontend/athena/app/users/[id]/page.tsx +++ b/frontend/athena/app/users/[id]/page.tsx @@ -7,6 +7,7 @@ import { useParams } from "next/navigation"; import { ArrowLeft, KeyRound, Pencil } from "lucide-react"; import StatusBadge from "@/components/common/StatusBadge"; +import { useToast } from "@/components/common/ToastProvider"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -41,6 +42,7 @@ function formatDateTime(value: string) { } export default function UserDetailPage() { + const { showToast } = useToast(); const params = useParams<{ id: string }>(); const [user, setUser] = useState(null); const [roles, setRoles] = useState([]); @@ -94,8 +96,11 @@ export default function UserDetailPage() { const response = await api.put(`/users/${user.id}`, payload); setUser(response.data); setFormOpen(false); + showToast({ type: "success", title: "Benutzer aktualisiert", description: response.data.username }); } catch (err) { - setFormError(getErrorMessage(err)); + const message = getErrorMessage(err); + setFormError(message); + showToast({ type: "error", title: "Benutzer konnte nicht gespeichert werden", description: message }); } finally { setPending(false); } @@ -119,8 +124,11 @@ export default function UserDetailPage() { setUser(response.data); setPassword(""); setPasswordOpen(false); + showToast({ type: "success", title: "Passwort geändert", description: response.data.username }); } catch (err) { - setPasswordError(getErrorMessage(err)); + const message = getErrorMessage(err); + setPasswordError(message); + showToast({ type: "error", title: "Passwort konnte nicht geändert werden", description: message }); } finally { setPending(false); } diff --git a/frontend/athena/app/users/page.tsx b/frontend/athena/app/users/page.tsx index f66a662..34ee775 100644 --- a/frontend/athena/app/users/page.tsx +++ b/frontend/athena/app/users/page.tsx @@ -5,6 +5,7 @@ import { Plus } from "lucide-react"; import ConfirmDialog from "@/components/common/ConfirmDialog"; import SearchInput from "@/components/common/SearchInput"; +import { useToast } from "@/components/common/ToastProvider"; import { Button } from "@/components/ui/button"; import UserFormDialog from "@/components/users/UserFormDialog"; import UserTable from "@/components/users/UserTable"; @@ -32,6 +33,7 @@ function getSortableValue(user: User, key: string) { } export default function UsersPage() { + const { showToast } = useToast(); const [users, setUsers] = useState([]); const [roles, setRoles] = useState([]); const [currentUser, setCurrentUser] = useState(null); @@ -159,14 +161,18 @@ export default function UsersPage() { setUsers((current) => current.map((user) => ( user.id === selectedUser.id ? response.data : user ))); + showToast({ type: "success", title: "Benutzer aktualisiert", description: response.data.username }); } else { const response = await api.post("/users", payload); setUsers((current) => [response.data, ...current]); + showToast({ type: "success", title: "Benutzer erstellt", description: response.data.username }); } setFormOpen(false); } catch (err) { - setFormError(getErrorMessage(err)); + const message = getErrorMessage(err); + setFormError(message); + showToast({ type: "error", title: "Benutzer konnte nicht gespeichert werden", description: message }); } finally { setPending(false); } @@ -183,9 +189,12 @@ export default function UsersPage() { try { await api.delete(`/users/${deleteUser.id}`); setUsers((current) => current.filter((user) => user.id !== deleteUser.id)); + showToast({ type: "success", title: "Benutzer gelöscht", description: deleteUser.username }); setDeleteUser(null); } catch (err) { - setDeleteError(getErrorMessage(err)); + const message = getErrorMessage(err); + setDeleteError(message); + showToast({ type: "error", title: "Benutzer konnte nicht gelöscht werden", description: message }); } finally { setPending(false); } diff --git a/frontend/athena/components/Sidebar.tsx b/frontend/athena/components/Sidebar.tsx index 6b93c90..7d2b98f 100644 --- a/frontend/athena/components/Sidebar.tsx +++ b/frontend/athena/components/Sidebar.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { + ClipboardList, FileText, LayoutDashboard, Package, @@ -24,6 +25,12 @@ const menu = [ href: "/dashboard", permission: "dashboard.read", }, + { + icon: ClipboardList, + name: "Audit Logs", + href: "/audit-logs", + permission: "audit_logs.read", + }, { icon: Users, name: "Benutzer", diff --git a/frontend/athena/components/common/ToastProvider.tsx b/frontend/athena/components/common/ToastProvider.tsx new file mode 100644 index 0000000..43b63a9 --- /dev/null +++ b/frontend/athena/components/common/ToastProvider.tsx @@ -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; + +type ToastContextValue = { + showToast: (toast: ToastInput) => void; +}; + +const ToastContext = createContext(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([]); + + 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 ( + + {children} +
+ {toasts.map((toast) => { + const Icon = iconByType[toast.type]; + + return ( +
+
+ +
+

{toast.title}

+ {toast.description && ( +

{toast.description}

+ )} +
+ +
+
+ ); + })} +
+
+ ); +} + +export function useToast() { + const context = useContext(ToastContext); + + if (!context) { + throw new Error("useToast muss innerhalb des ToastProvider verwendet werden"); + } + + return context; +} diff --git a/frontend/athena/types/audit.ts b/frontend/athena/types/audit.ts new file mode 100644 index 0000000..f171d16 --- /dev/null +++ b/frontend/athena/types/audit.ts @@ -0,0 +1,38 @@ +export type ApiSuccess = { + 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; + 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; +};