feat(platform): add audit logs and activity feed
This commit is contained in:
parent
92cb8d1286
commit
c816e9869d
34 changed files with 1592 additions and 43 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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/<module>`
|
||||
- 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
|
||||
|
||||
|
|
|
|||
66
ROADMAP.md
Normal file
66
ROADMAP.md
Normal 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
|
||||
|
|
@ -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")
|
||||
80
backend/hermes/app/api/audit.py
Normal file
80
backend/hermes/app/api/audit.py
Normal 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")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
14
backend/hermes/app/core/logging.py
Normal file
14
backend/hermes/app/core/logging.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
28
backend/hermes/app/models/audit.py
Normal file
28
backend/hermes/app/models/audit.py
Normal 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())
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
104
backend/hermes/app/repositories/audit_repository.py
Normal file
104
backend/hermes/app/repositories/audit_repository.py
Normal 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)
|
||||
)
|
||||
)
|
||||
16
backend/hermes/app/schemas/api_response.py
Normal file
16
backend/hermes/app/schemas/api_response.py
Normal 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)
|
||||
39
backend/hermes/app/schemas/audit.py
Normal file
39
backend/hermes/app/schemas/audit.py
Normal 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
|
||||
148
backend/hermes/app/services/audit_service.py
Normal file
148
backend/hermes/app/services/audit_service.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
7
frontend/athena/app/api/activity-feed/route.ts
Normal file
7
frontend/athena/app/api/activity-feed/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, `/activity-feed${request.nextUrl.search}`);
|
||||
}
|
||||
7
frontend/athena/app/api/audit-logs/route.ts
Normal file
7
frontend/athena/app/api/audit-logs/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyHermesRequest(request, `/audit-logs${request.nextUrl.search}`);
|
||||
}
|
||||
294
frontend/athena/app/audit-logs/page.tsx
Normal file
294
frontend/athena/app/audit-logs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Customer | null>(null);
|
||||
|
|
@ -79,8 +81,11 @@ export default function CustomerDetailPage() {
|
|||
const response = await api.put<Customer>(`/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<CustomerContact>(`/customers/${customer.id}/contacts/${selectedContact.id}`, payload);
|
||||
showToast({ type: "success", title: "Ansprechpartner aktualisiert" });
|
||||
} else {
|
||||
await api.post<CustomerContact>(`/customers/${customer.id}/contacts`, payload);
|
||||
showToast({ type: "success", title: "Ansprechpartner erstellt" });
|
||||
}
|
||||
const response = await api.get<Customer>(`/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<Customer>(`/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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Customer[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -183,13 +185,17 @@ export default function CustomersPage() {
|
|||
if (selectedCustomer) {
|
||||
const response = await api.put<Customer>(`/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<Customer>("/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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<DashboardSummary | null>(null);
|
||||
const [activities, setActivities] = useState<ActivityFeedItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [activityError, setActivityError] = useState("");
|
||||
|
||||
const loadSummary = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get<DashboardSummary>("/dashboard/summary");
|
||||
setSummary(response.data);
|
||||
const [summaryResponse, activityResponse] = await Promise.all([
|
||||
api.get<DashboardSummary>("/dashboard/summary"),
|
||||
api.get<ApiSuccess<ActivityFeedItem[]>>("/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<ApiSuccess<ActivityFeedItem[]>>("/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() {
|
|||
</section>
|
||||
|
||||
<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.tickets} />
|
||||
<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 }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-5">
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<html lang="de">
|
||||
<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>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Role[]>([]);
|
||||
const [permissions, setPermissions] = useState<Permission[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(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<Role>("/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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<User | null>(null);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
|
|
@ -94,8 +96,11 @@ export default function UserDetailPage() {
|
|||
const response = await api.put<User>(`/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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<User[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(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<User>("/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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
107
frontend/athena/components/common/ToastProvider.tsx
Normal file
107
frontend/athena/components/common/ToastProvider.tsx
Normal 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;
|
||||
}
|
||||
38
frontend/athena/types/audit.ts
Normal file
38
frontend/athena/types/audit.ts
Normal 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;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue