From 884a20e0430d6db998b747b5918b969d7eb5a57f Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sat, 4 Jul 2026 23:54:20 +0200 Subject: [PATCH] feat(settings): add SMTP admin configuration --- .env.example | 7 +- ARCHITECTURE.md | 30 +- README-DEV.md | 8 +- ROADMAP.md | 12 +- backend/hermes/.env.example | 4 + backend/hermes/alembic/env.py | 1 + .../0a247aadcfda_create_users.cpython-314.pyc | Bin 2940 -> 2927 bytes .../f2b8d4e6a910_create_system_settings.py | 69 ++++ backend/hermes/app/api/dashboard.py | 43 ++- backend/hermes/app/api/system_settings.py | 67 ++++ backend/hermes/app/db/database.py | 2 + backend/hermes/app/main.py | 2 + backend/hermes/app/models/system_setting.py | 21 ++ backend/hermes/app/rbac/defaults.py | 1 + .../app/repositories/repair_repository.py | 11 + .../system_settings_repository.py | 35 ++ backend/hermes/app/schemas/dashboard.py | 7 + backend/hermes/app/schemas/system_setting.py | 70 ++++ backend/hermes/app/services/audit_service.py | 4 + .../services/repair_notification_service.py | 35 +- .../services/repair_public_link_service.py | 7 +- .../app/services/system_settings_service.py | 316 ++++++++++++++++ docker-compose.yml | 1 + .../api/system-settings/public-links/route.ts | 18 + .../app/api/system-settings/smtp/route.ts | 18 + .../api/system-settings/smtp/test/route.ts | 14 + frontend/athena/app/dashboard/page.tsx | 38 ++ frontend/athena/app/settings/page.tsx | 356 ++++++++++++++++++ frontend/athena/components/Sidebar.tsx | 1 + frontend/athena/proxy.ts | 1 + frontend/athena/types/dashboard.ts | 2 + frontend/athena/types/system-settings.ts | 41 ++ 32 files changed, 1200 insertions(+), 42 deletions(-) create mode 100644 backend/hermes/alembic/versions/f2b8d4e6a910_create_system_settings.py create mode 100644 backend/hermes/app/api/system_settings.py create mode 100644 backend/hermes/app/models/system_setting.py create mode 100644 backend/hermes/app/repositories/system_settings_repository.py create mode 100644 backend/hermes/app/schemas/system_setting.py create mode 100644 backend/hermes/app/services/system_settings_service.py create mode 100644 frontend/athena/app/api/system-settings/public-links/route.ts create mode 100644 frontend/athena/app/api/system-settings/smtp/route.ts create mode 100644 frontend/athena/app/api/system-settings/smtp/test/route.ts create mode 100644 frontend/athena/app/settings/page.tsx create mode 100644 frontend/athena/types/system-settings.ts diff --git a/.env.example b/.env.example index 86fc45b..afd6786 100644 --- a/.env.example +++ b/.env.example @@ -29,11 +29,14 @@ KNOWLEDGE_MAX_UPLOAD_MB=50 # Produktiv als langen Zufallswert setzen. Nicht im Frontend verwenden. OLYMPUS_REPAIR_INTAKE_TOKEN= -# Optionale Website-Basis-URL fuer oeffentliche Reparaturstatuslinks. +# Env-Fallback fuer oeffentliche Reparaturstatuslinks. +# Bevorzugt wird die Admin-Konfiguration unter /settings. # Beispiel: https://test.funktechnik-schubert.de/status PUBLIC_REPAIR_STATUS_BASE_URL= -# Optionaler SMTP-Versand fuer automatische Reparaturstatusmails. +# Env-Fallback fuer SMTP. Bevorzugt wird die Admin-Konfiguration unter /settings. +# Fuer Apple Mail/iCloud: smtp.mail.me.com, Port 587, TLS aktiv, +# Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort. SMTP_HOST= SMTP_PORT=587 SMTP_USERNAME= diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 749cee0..ce893db 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -290,12 +290,38 @@ Vorlagen enthalten deutsche Betreffzeilen und Texte mit Platzhaltern fuer `repai Ab v0.8.3 sendet Hermes beim Statuswechsel automatisch eine Statusmail, wenn eine Kunden-E-Mail vorhanden ist. Fuer jede Mail wird ein frischer sicherer Statuslink erzeugt, weil Klartexttokens nicht gespeichert werden. Ist SMTP nicht konfiguriert oder schlaegt der Versand fehl, wird ein `repair_notification_events`-Eintrag mit `skipped` oder `failed` geschrieben und ein Audit-Eintrag erzeugt. +Ab v0.8.4 wird SMTP bevorzugt ueber `system_settings` aus PostgreSQL geladen. Nur wenn die Admin-Konfiguration deaktiviert, unvollstaendig oder nicht vorhanden ist, nutzt Hermes die Env Vars als Fallback. Das SMTP-Passwort wird nie an Athena zurueckgegeben; die API liefert nur `password_is_set`. Verschluesselung at rest ist in der Struktur vorbereitet und fuer v0.8.x als Sicherheitsausbau geplant. + Hermes-Endpunkt fuer manuellen Neuversand: - `POST /repairs/{id}/send-status-mail` Athena ruft diesen Endpunkt ausschliesslich ueber die BFF-Route `/api/repairs/[id]/send-status-mail` auf. +### System Settings + +`system_settings` speichert zentrale Betriebswerte als Key-Value-Daten: + +- `smtp.host` +- `smtp.port` +- `smtp.username` +- `smtp.password` +- `smtp.from_email` +- `smtp.from_name` +- `smtp.use_tls` +- `smtp.enabled` +- `public.repair_status_base_url` + +Hermes-Endpunkte: + +- `GET /system-settings/smtp` +- `PUT /system-settings/smtp` +- `POST /system-settings/smtp/test` +- `GET /system-settings/public-links` +- `PUT /system-settings/public-links` + +Athena ruft diese Endpunkte ausschliesslich ueber BFF-Routen unter `/api/system-settings/...` auf. Mutierende Requests verwenden den bestehenden Same-Origin-Schutz. Zugriff erfordert `system_settings.manage`, das der Administratorrolle zugewiesen ist. + ### Website Repair Intake Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit. @@ -902,10 +928,10 @@ docker-compose.yml : Server-zu-Server Token fuer `POST /public/repair-intake`. Dieser Wert darf nicht im Frontend oder in Logs erscheinen. `PUBLIC_REPAIR_STATUS_BASE_URL` -: Optionale Basis-URL fuer von Olympus erzeugte oeffentliche Reparaturstatuslinks, z. B. `https://test.funktechnik-schubert.de/status`. Wenn leer, gibt Hermes weiterhin relative Links im Format `/status/` zurueck. +: Env-Fallback fuer von Olympus erzeugte oeffentliche Reparaturstatuslinks, z. B. `https://test.funktechnik-schubert.de/status`. Bevorzugt wird `public.repair_status_base_url` aus der Admin-Konfiguration. Wenn beides leer ist, gibt Hermes weiterhin relative Links im Format `/status/` zurueck. `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM_EMAIL`, `SMTP_FROM_NAME`, `SMTP_USE_TLS` -: Optionale SMTP-Konfiguration fuer automatische Reparaturstatusmails. `SMTP_PASSWORD` darf nicht geloggt oder committed werden. +: Env-Fallback fuer automatische Reparaturstatusmails. Bevorzugt wird die Admin-Konfiguration unter `/settings`. `SMTP_PASSWORD` darf nicht geloggt, committed oder an Athena zurueckgegeben werden. Fuer Apple Mail/iCloud: `smtp.mail.me.com`, Port `587`, TLS/STARTTLS aktiv, Benutzername vollstaendige Mailadresse, Passwort app-spezifisches Passwort. ### Athena diff --git a/README-DEV.md b/README-DEV.md index 275140b..3477ade 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -44,6 +44,8 @@ SMTP_FROM_NAME=Funktechnik Schubert SMTP_USE_TLS=true ``` +`PUBLIC_REPAIR_STATUS_BASE_URL` und die SMTP-Werte sind ab v0.8.4 Fallbacks. Bevorzugt wird die Admin-Konfiguration in Olympus unter `/settings`. Fuer Apple Mail/iCloud gilt: `smtp.mail.me.com`, Port `587`, TLS/STARTTLS aktiv, Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort. + Wenn `SECRET_KEY` Sonderzeichen wie `$` enthaelt, den Wert in der Shell oder Compose-Umgebung korrekt quoten. Secrets gehoeren nicht ins Git. ## Docker Netzwerk @@ -169,6 +171,7 @@ Statuslink-Konzept: - Tokens sind lang, zufaellig und werden nur gehasht gespeichert. - Der Klartexttoken wird nur einmal bei Erstellung zurueckgegeben. - `PUBLIC_REPAIR_STATUS_BASE_URL` kann auf die Website-Route zeigen, z. B. `https://test.funktechnik-schubert.de/status`. +- Ab v0.8.4 kann die Basis-URL im Adminbereich unter `/settings` gepflegt werden; die Env Var bleibt Fallback. - Oeffentliche Statusdaten kommen spaeter ueber `GET /public/repairs/status/{token}`. - Die Antwort enthaelt keine Kundendaten, keine internen Notizen und keine nicht freigegebenen Diagnosen. @@ -178,8 +181,9 @@ Benachrichtigungen: - `repair_notification_events` dokumentiert jeden Versandversuch. - Beim Statuswechsel wird automatisch eine Statusmail verarbeitet. - Ohne SMTP-Konfiguration bleibt der Statuswechsel erfolgreich; der Versandversuch wird als `skipped` dokumentiert. -- SMTP wird ueber `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM_EMAIL`, `SMTP_FROM_NAME` und `SMTP_USE_TLS` konfiguriert. -- `SMTP_PASSWORD` gehoert ausschliesslich in die Umgebung und nie ins Git oder in Logs. +- SMTP wird bevorzugt ueber `/settings` konfiguriert. `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM_EMAIL`, `SMTP_FROM_NAME` und `SMTP_USE_TLS` bleiben als Env-Fallback erhalten. +- `SMTP_PASSWORD` wird nicht an Athena zurueckgegeben und gehoert nie ins Git oder in Logs. +- Verschluesselung at rest ist fuer v0.8.x vorbereitet/geplant; bis dahin bleibt das Passwort serverseitig in `system_settings` und wird in Responses/Audits maskiert. Vorbereitete Website-/Portal-Routen fuer spaeter: diff --git a/ROADMAP.md b/ROADMAP.md index dbddbd3..6699f66 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -113,7 +113,17 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - Dashboard-Kennzahlen fuer Statusmails und Reparaturen ohne Kundenmail - SMTP-Ausfall blockiert keine Reparaturprozesse -## v0.8.4 - Kundenportal, geplant +## v0.8.4 - Admin SMTP Settings + +- SMTP-Konfiguration im Olympus-Webinterface unter `/settings` +- Testmail-Versand mit Audit-Log +- `system_settings` als zentrale Tabelle fuer Systemkonfiguration +- Env Vars bleiben als Fallback fuer SMTP und Public Repair Status Base URL +- Statusmail-Versand nutzt bevorzugt Admin-Konfiguration +- Passwort wird nicht an Athena zurueckgegeben +- Encryption at rest fuer Secrets bleibt als v0.8.x-Haertung geplant + +## v0.8.5 - Kundenportal, geplant - `/portal/login` fuer spaeteren Kundenlogin - Separates Authentifizierungsmodell fuer Kunden diff --git a/backend/hermes/.env.example b/backend/hermes/.env.example index 805a702..6f7a6d0 100644 --- a/backend/hermes/.env.example +++ b/backend/hermes/.env.example @@ -19,7 +19,11 @@ STORAGE_MAX_UPLOAD_MB=50 KNOWLEDGE_STORAGE_PATH=/data/knowledge KNOWLEDGE_MAX_UPLOAD_MB=50 OLYMPUS_REPAIR_INTAKE_TOKEN= +# Env-Fallback. Bevorzugt wird die Admin-Konfiguration unter /settings. PUBLIC_REPAIR_STATUS_BASE_URL= +# Env-Fallback. Bevorzugt wird die Admin-Konfiguration unter /settings. +# Apple Mail/iCloud: smtp.mail.me.com, Port 587, TLS aktiv, +# Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort. SMTP_HOST= SMTP_PORT=587 SMTP_USERNAME= diff --git a/backend/hermes/alembic/env.py b/backend/hermes/alembic/env.py index fb89de0..1f62d94 100644 --- a/backend/hermes/alembic/env.py +++ b/backend/hermes/alembic/env.py @@ -13,6 +13,7 @@ import app.models.knowledge import app.models.audit import app.models.rbac import app.models.repair +import app.models.system_setting config = context.config diff --git a/backend/hermes/alembic/versions/__pycache__/0a247aadcfda_create_users.cpython-314.pyc b/backend/hermes/alembic/versions/__pycache__/0a247aadcfda_create_users.cpython-314.pyc index 795e2da7863f86962bc8f46585451d106f235fd2..683357331166a9ee87c7de1e524d30aea1ce2730 100644 GIT binary patch delta 47 zcmew(_Fjxjn~#@^0SJ_vHgYvFibd&%7N-^!>lY_ylqRJXm87K>rRF7XUe9=z697Mh B4?zF` delta 60 zcmaDa_D76Mn~#@^0SGS0Y~*TUR8H3qElw>e)=x_ None: + op.create_table( + "system_settings", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("key", sa.String(length=160), nullable=False), + sa.Column("value", sa.Text(), server_default="", nullable=False), + sa.Column("is_secret", sa.Boolean(), server_default="false", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("key"), + ) + op.create_index(op.f("ix_system_settings_key"), "system_settings", ["key"], unique=True) + + op.execute( + """ + INSERT INTO permissions (name, display_name, description, module) + VALUES ( + 'system_settings.manage', + 'Systemeinstellungen verwalten', + 'SMTP und öffentliche Systemlinks verwalten', + 'system' + ) + ON CONFLICT (name) DO NOTHING + """ + ) + op.execute( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles, permissions + WHERE roles.name = 'administrator' + AND permissions.name = 'system_settings.manage' + ON CONFLICT DO NOTHING + """ + ) + + +def downgrade() -> None: + op.execute( + """ + DELETE FROM role_permissions + WHERE permission_id IN ( + SELECT id FROM permissions WHERE name = 'system_settings.manage' + ) + """ + ) + op.execute("DELETE FROM permissions WHERE name = 'system_settings.manage'") + op.drop_index(op.f("ix_system_settings_key"), table_name="system_settings") + op.drop_table("system_settings") diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py index 2f1371f..116fd0f 100644 --- a/backend/hermes/app/api/dashboard.py +++ b/backend/hermes/app/api/dashboard.py @@ -7,11 +7,13 @@ 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.rbac import Role +from app.models.audit import AuditLog from app.models.user import User from app.repositories.customer_repository import CustomerRepository from app.repositories.repair_repository import RepairRepository from app.repositories.user_repository import UserRepository -from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard +from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard, SystemStatusItem +from app.services.system_settings_service import SystemSettingsService logger = logging.getLogger(__name__) @@ -32,6 +34,7 @@ def get_dashboard_summary( users: list[MetricCard] = [] roles: list[MetricCard] = [] repairs: list[MetricCard] = [] + system_status: list[SystemStatusItem] = [] if "customers.read" in permissions: customers = [ @@ -69,6 +72,43 @@ def get_dashboard_summary( MetricCard(label="Offen ohne Kundenmail", value=RepairRepository.count_open_repairs_without_customer_email(db)), ] + if "system_settings.manage" in permissions: + smtp_config = SystemSettingsService.get_smtp_runtime_config(db) + public_links = SystemSettingsService.get_public_links_settings(db) + latest_testmail = db.scalar( + select(AuditLog) + .where(AuditLog.action.in_(["system_settings.smtp.test_sent", "system_settings.smtp.test_failed"])) + .order_by(AuditLog.created_at.desc(), AuditLog.id.desc()) + .limit(1) + ) + latest_failed_status_mail = RepairRepository.latest_failed_status_mail(db) + system_status = [ + SystemStatusItem( + label="SMTP-Konfiguration", + value={ + "database": "Admin-Konfiguration", + "environment": "Umgebung", + "missing": "Nicht konfiguriert", + }[smtp_config.source], + status="ok" if smtp_config.is_configured else "warning", + ), + SystemStatusItem( + label="Public Status Base URL", + value=public_links.repair_status_base_url or "Nicht konfiguriert", + status="ok" if public_links.repair_status_base_url else "warning", + ), + SystemStatusItem( + label="Letzte SMTP-Testmail", + value=latest_testmail.created_at.isoformat() if latest_testmail else "Noch keine Testmail", + status="info" if latest_testmail else "warning", + ), + SystemStatusItem( + label="Letzte fehlgeschlagene Statusmail", + value=latest_failed_status_mail.created_at.isoformat() if latest_failed_status_mail else "Keine Fehler", + status="warning" if latest_failed_status_mail else "ok", + ), + ] + logger.info("dashboard.summary", extra={"actor_user_id": current_user.id}) return DashboardSummary( @@ -77,6 +117,7 @@ def get_dashboard_summary( users=users, roles=roles, repairs=repairs, + system_status=system_status, activities=EmptyWidget( title="Letzte Aktivitäten", message="Noch keine Aktivitäten vorhanden.", diff --git a/backend/hermes/app/api/system_settings.py b/backend/hermes/app/api/system_settings.py new file mode 100644 index 0000000..0c3170f --- /dev/null +++ b/backend/hermes/app/api/system_settings.py @@ -0,0 +1,67 @@ +from fastapi import APIRouter, Depends +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.schemas.system_setting import ( + PublicLinksSettingsResponse, + PublicLinksSettingsUpdate, + SmtpSettingsResponse, + SmtpSettingsUpdate, + SmtpTestRequest, + SmtpTestResponse, +) +from app.services.system_settings_service import SystemSettingsService + +router = APIRouter( + prefix="/system-settings", + tags=["System Settings"], +) + + +@router.get("/smtp", response_model=SmtpSettingsResponse) +def get_smtp_settings( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("system_settings.manage")), +): + return SystemSettingsService.get_smtp_settings(db) + + +@router.put("/smtp", response_model=SmtpSettingsResponse) +def update_smtp_settings( + payload: SmtpSettingsUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("system_settings.manage")), +): + return SystemSettingsService.update_smtp_settings(db, payload, actor=current_user, request=request) + + +@router.post("/smtp/test", response_model=SmtpTestResponse) +def send_smtp_test_mail( + payload: SmtpTestRequest, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("system_settings.manage")), +): + return SystemSettingsService.send_test_mail(db, payload.recipient, actor=current_user, request=request) + + +@router.get("/public-links", response_model=PublicLinksSettingsResponse) +def get_public_links_settings( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("system_settings.manage")), +): + return SystemSettingsService.get_public_links_settings(db) + + +@router.put("/public-links", response_model=PublicLinksSettingsResponse) +def update_public_links_settings( + payload: PublicLinksSettingsUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("system_settings.manage")), +): + return SystemSettingsService.update_public_links_settings(db, payload, actor=current_user, request=request) diff --git a/backend/hermes/app/db/database.py b/backend/hermes/app/db/database.py index caf7949..6a559c7 100644 --- a/backend/hermes/app/db/database.py +++ b/backend/hermes/app/db/database.py @@ -25,6 +25,8 @@ import app.models.customer import app.models.knowledge import app.models.audit import app.models.user +import app.models.repair +import app.models.system_setting def get_db(): diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index fbcc986..d66787b 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -17,6 +17,7 @@ from app.api.knowledge import router as knowledge_router from app.api.permissions import router as permissions_router from app.api.repairs import router as repairs_router from app.api.roles import router as roles_router +from app.api.system_settings import router as system_settings_router from app.api.users import router as users_router from app.db.database import SessionLocal from app.db.health import check_database @@ -42,6 +43,7 @@ app.include_router(customers_router) app.include_router(knowledge_router) app.include_router(repairs_router) app.include_router(dashboard_router) +app.include_router(system_settings_router) logger = logging.getLogger(__name__) diff --git a/backend/hermes/app/models/system_setting.py b/backend/hermes/app/models/system_setting.py new file mode 100644 index 0000000..e3350a3 --- /dev/null +++ b/backend/hermes/app/models/system_setting.py @@ -0,0 +1,21 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.database import Base + + +class SystemSetting(Base): + __tablename__ = "system_settings" + + id: Mapped[int] = mapped_column(primary_key=True) + key: Mapped[str] = mapped_column(String(160), unique=True, index=True) + value: Mapped[str] = mapped_column(Text, default="", server_default="") + is_secret: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py index 2cde2c8..96c1699 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"), + ("system_settings.manage", "Systemeinstellungen verwalten", "SMTP und öffentliche Systemlinks verwalten", "system"), ("audit_logs.read", "Audit Logs lesen", "Audit Logs anzeigen", "audit_logs"), ("knowledge.read", "Wissen lesen", "Wissensdatenbank anzeigen", "knowledge"), ("knowledge.create", "Wissen erstellen", "Wissensdatenbank-Einträge erstellen", "knowledge"), diff --git a/backend/hermes/app/repositories/repair_repository.py b/backend/hermes/app/repositories/repair_repository.py index 1390ee6..fb115e0 100644 --- a/backend/hermes/app/repositories/repair_repository.py +++ b/backend/hermes/app/repositories/repair_repository.py @@ -343,6 +343,17 @@ class RepairRepository: .where(RepairNotificationEvent.status.in_(["failed", "skipped"])) ) or 0 + @staticmethod + def latest_failed_status_mail(db: Session) -> RepairNotificationEvent | None: + return db.scalar( + select(RepairNotificationEvent) + .where(RepairNotificationEvent.event_type == "repair_status_mail") + .where(RepairNotificationEvent.success.is_(False)) + .where(RepairNotificationEvent.status.in_(["failed", "skipped"])) + .order_by(RepairNotificationEvent.created_at.desc(), RepairNotificationEvent.id.desc()) + .limit(1) + ) + @staticmethod def count_open_repairs_without_customer_email(db: Session) -> int: return db.scalar( diff --git a/backend/hermes/app/repositories/system_settings_repository.py b/backend/hermes/app/repositories/system_settings_repository.py new file mode 100644 index 0000000..fc8adf1 --- /dev/null +++ b/backend/hermes/app/repositories/system_settings_repository.py @@ -0,0 +1,35 @@ +from collections.abc import Iterable + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.system_setting import SystemSetting + + +class SystemSettingsRepository: + @staticmethod + def get(db: Session, key: str) -> SystemSetting | None: + return db.scalar(select(SystemSetting).where(SystemSetting.key == key)) + + @staticmethod + def get_many(db: Session, keys: Iterable[str]) -> dict[str, SystemSetting]: + key_list = list(keys) + if not key_list: + return {} + + settings = db.scalars(select(SystemSetting).where(SystemSetting.key.in_(key_list))).all() + return {setting.key: setting for setting in settings} + + @staticmethod + def upsert(db: Session, *, key: str, value: str, is_secret: bool = False) -> SystemSetting: + setting = SystemSettingsRepository.get(db, key) + if setting is None: + setting = SystemSetting(key=key, value=value, is_secret=is_secret) + db.add(setting) + db.flush() + return setting + + setting.value = value + setting.is_secret = is_secret + db.flush() + return setting diff --git a/backend/hermes/app/schemas/dashboard.py b/backend/hermes/app/schemas/dashboard.py index ae89527..047f18e 100644 --- a/backend/hermes/app/schemas/dashboard.py +++ b/backend/hermes/app/schemas/dashboard.py @@ -13,12 +13,19 @@ class EmptyWidget(BaseModel): message: str +class SystemStatusItem(BaseModel): + label: str + value: str + status: str = "info" + + class DashboardSummary(BaseModel): customers: list[MetricCard] = [] latest_customers: list[CustomerResponse] = [] users: list[MetricCard] = [] roles: list[MetricCard] = [] repairs: list[MetricCard] = [] + system_status: list[SystemStatusItem] = [] activities: EmptyWidget tasks: EmptyWidget tickets: EmptyWidget diff --git a/backend/hermes/app/schemas/system_setting.py b/backend/hermes/app/schemas/system_setting.py new file mode 100644 index 0000000..fa4b266 --- /dev/null +++ b/backend/hermes/app/schemas/system_setting.py @@ -0,0 +1,70 @@ +from typing import Literal + +from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator + + +SettingsSource = Literal["database", "environment", "missing"] + + +def normalize_text(value: object) -> str: + if value is None: + return "" + return str(value).strip() + + +class SmtpSettingsResponse(BaseModel): + host: str = "" + port: int = 587 + username: str = "" + password_is_set: bool = False + from_email: str = "" + from_name: str = "Funktechnik Schubert" + use_tls: bool = True + enabled: bool = False + source: SettingsSource + + +class SmtpSettingsUpdate(BaseModel): + host: str = Field(default="", max_length=255) + port: int = Field(default=587, ge=1, le=65535) + username: str = Field(default="", max_length=255) + password: str | None = Field(default=None, max_length=1000) + from_email: str = Field(default="", max_length=255) + from_name: str = Field(default="Funktechnik Schubert", max_length=255) + use_tls: bool = True + enabled: bool = False + + @field_validator("host", "username", "password", "from_email", "from_name", mode="before") + @classmethod + def normalize_strings(cls, value: object) -> str: + return normalize_text(value) + + @model_validator(mode="after") + def validate_enabled_configuration(self): + if self.enabled and (not self.host or not self.from_email): + raise ValueError("SMTP Host und Absenderadresse sind erforderlich, wenn SMTP aktiviert ist") + return self + + +class SmtpTestRequest(BaseModel): + recipient: EmailStr + + +class SmtpTestResponse(BaseModel): + success: bool + message: str + source: SettingsSource + + +class PublicLinksSettingsResponse(BaseModel): + repair_status_base_url: str = "" + source: SettingsSource + + +class PublicLinksSettingsUpdate(BaseModel): + repair_status_base_url: str = Field(default="", max_length=500) + + @field_validator("repair_status_base_url", mode="before") + @classmethod + def normalize_url(cls, value: object) -> str: + return normalize_text(value).rstrip("/") diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index d37f223..d38a335 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -169,6 +169,10 @@ def action_title(action: str) -> str: "repairs.public_link.regenerate": "Reparatur-Statuslink erneut erstellt", "repairs.status_mail.sent": "Statusmail versendet", "repairs.status_mail.failed": "Statusmail fehlgeschlagen", + "system_settings.smtp.update": "SMTP-Konfiguration geändert", + "system_settings.smtp.test_sent": "SMTP-Testmail versendet", + "system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen", + "system_settings.public_links.update": "Öffentliche Link-Konfiguration geändert", } return labels.get(action, action) diff --git a/backend/hermes/app/services/repair_notification_service.py b/backend/hermes/app/services/repair_notification_service.py index 1ce0d6e..2e48bd4 100644 --- a/backend/hermes/app/services/repair_notification_service.py +++ b/backend/hermes/app/services/repair_notification_service.py @@ -1,13 +1,10 @@ -import smtplib from dataclasses import dataclass from datetime import UTC, datetime -from email.message import EmailMessage from html import escape from sqlalchemy.orm import Session from starlette.requests import Request -from app.core.config import settings from app.models.repair import Repair, RepairNotificationEvent from app.models.user import User from app.repositories.repair_repository import RepairRepository @@ -15,6 +12,7 @@ from app.schemas.repair import RepairNotificationOverviewResponse, RepairNotific from app.services.audit_service import write_audit_log from app.services.repair_public_link_service import RepairPublicLinkService from app.services.repair_status_labels import MAIL_STATUS_LABELS, STATUS_LABELS +from app.services.system_settings_service import SystemSettingsService @dataclass(frozen=True) @@ -98,16 +96,6 @@ def _repair_label(repair: Repair) -> str: return f"{repair.repair_number} · {repair.customer_name}" -def _smtp_configured() -> bool: - return bool(settings.smtp_host and settings.smtp_from_email) - - -def _from_header() -> str: - if settings.smtp_from_name: - return f"{settings.smtp_from_name} <{settings.smtp_from_email}>" - return str(settings.smtp_from_email) - - def _plain_status_mail(repair: Repair, *, status_label: str, public_status_url: str) -> str: return ( f"Hallo {repair.customer_name},\n\n" @@ -235,8 +223,9 @@ class RepairNotificationService: text = _plain_status_mail(repair, status_label=status_label, public_status_url=public_status_url) html = _html_status_mail(repair, status_label=status_label, public_status_url=public_status_url) + smtp_config = SystemSettingsService.get_smtp_runtime_config(db) - if not _smtp_configured(): + if not smtp_config.is_configured: event = RepairRepository.create_notification_event( db, repair_id=repair.id, @@ -253,7 +242,7 @@ class RepairNotificationService: return event try: - RepairNotificationService._send_email(recipient=recipient, subject=subject, text=text, html=html) + SystemSettingsService.send_email(smtp_config, recipient=recipient, subject=subject, text=text, html=html) except Exception: event = RepairRepository.create_notification_event( db, @@ -294,22 +283,6 @@ class RepairNotificationService: ) return event - @staticmethod - def _send_email(*, recipient: str, subject: str, text: str, html: str) -> None: - message = EmailMessage() - message["Subject"] = subject - message["From"] = _from_header() - message["To"] = recipient - message.set_content(text) - message.add_alternative(html, subtype="html") - - with smtplib.SMTP(str(settings.smtp_host), settings.smtp_port, timeout=15) as smtp: - if settings.smtp_use_tls: - smtp.starttls() - if settings.smtp_username and settings.smtp_password: - smtp.login(settings.smtp_username, settings.smtp_password) - smtp.send_message(message) - @staticmethod def _audit_failure(db: Session, repair: Repair, *, actor: User | None, request: Request | None, reason: str) -> None: write_audit_log( diff --git a/backend/hermes/app/services/repair_public_link_service.py b/backend/hermes/app/services/repair_public_link_service.py index 3b0e499..f35c231 100644 --- a/backend/hermes/app/services/repair_public_link_service.py +++ b/backend/hermes/app/services/repair_public_link_service.py @@ -19,6 +19,7 @@ from app.schemas.repair import ( ) from app.services.audit_service import write_audit_log from app.services.repair_status_labels import STATUS_LABELS +from app.services.system_settings_service import SystemSettingsService def normalize_token(token: str) -> str: @@ -29,10 +30,10 @@ def repair_label(repair: Repair) -> str: return f"{repair.repair_number} · {repair.customer_name}" -def public_status_path(token: str) -> str: +def public_status_path(db: Session, token: str) -> str: normalized_token = normalize_token(token) path = f"/status/{normalized_token}" - base_url = (settings.public_repair_status_base_url or "").strip().rstrip("/") + base_url = SystemSettingsService.get_public_repair_status_base_url(db) if not base_url: return path return f"{base_url}/{normalized_token}" @@ -109,7 +110,7 @@ class RepairPublicLinkService: last_used_at=public_link.last_used_at, created_at=public_link.created_at, revoked_at=public_link.revoked_at, - public_status_path=public_status_path(token), + public_status_path=public_status_path(db, token), ) @staticmethod diff --git a/backend/hermes/app/services/system_settings_service.py b/backend/hermes/app/services/system_settings_service.py new file mode 100644 index 0000000..e0fa53f --- /dev/null +++ b/backend/hermes/app/services/system_settings_service.py @@ -0,0 +1,316 @@ +import smtplib +from dataclasses import dataclass +from email.message import EmailMessage + +from pydantic import EmailStr +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.core.config import settings +from app.models.user import User +from app.repositories.system_settings_repository import SystemSettingsRepository +from app.schemas.system_setting import ( + PublicLinksSettingsResponse, + PublicLinksSettingsUpdate, + SmtpSettingsResponse, + SmtpSettingsUpdate, + SmtpTestResponse, + SettingsSource, +) +from app.services.audit_service import write_audit_log + + +SMTP_KEYS = ( + "smtp.host", + "smtp.port", + "smtp.username", + "smtp.password", + "smtp.from_email", + "smtp.from_name", + "smtp.use_tls", + "smtp.enabled", +) + +SECRET_KEYS = {"smtp.password"} + + +@dataclass(frozen=True) +class SmtpRuntimeConfig: + host: str + port: int + username: str + password: str + from_email: str + from_name: str + use_tls: bool + enabled: bool + source: SettingsSource + + @property + def is_configured(self) -> bool: + return self.enabled and bool(self.host and self.from_email) + + @property + def password_is_set(self) -> bool: + return bool(self.password) + + @property + def from_header(self) -> str: + if self.from_name: + return f"{self.from_name} <{self.from_email}>" + return self.from_email + + +def parse_bool(value: str | None, *, default: bool = False) -> bool: + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def parse_int(value: str | None, *, default: int) -> int: + try: + parsed = int(str(value or "").strip()) + except ValueError: + return default + if parsed < 1 or parsed > 65535: + return default + return parsed + + +class SystemSettingsService: + @staticmethod + def get_smtp_settings(db: Session) -> SmtpSettingsResponse: + return SystemSettingsService.smtp_response(SystemSettingsService.get_smtp_runtime_config(db)) + + @staticmethod + def get_smtp_runtime_config(db: Session) -> SmtpRuntimeConfig: + values = SystemSettingsService._get_values(db, SMTP_KEYS) + database_enabled = parse_bool(values.get("smtp.enabled"), default=False) + database_host = values.get("smtp.host", "") + database_from_email = values.get("smtp.from_email", "") + + if database_enabled and database_host and database_from_email: + return SmtpRuntimeConfig( + host=database_host, + port=parse_int(values.get("smtp.port"), default=587), + username=values.get("smtp.username", ""), + password=values.get("smtp.password", ""), + from_email=database_from_email, + from_name=values.get("smtp.from_name", "Funktechnik Schubert") or "Funktechnik Schubert", + use_tls=parse_bool(values.get("smtp.use_tls"), default=True), + enabled=True, + source="database", + ) + + if settings.smtp_host and settings.smtp_from_email: + return SmtpRuntimeConfig( + host=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_username or "", + password=settings.smtp_password or "", + from_email=settings.smtp_from_email, + from_name=settings.smtp_from_name, + use_tls=settings.smtp_use_tls, + enabled=True, + source="environment", + ) + + return SmtpRuntimeConfig( + host=database_host or settings.smtp_host or "", + port=parse_int(values.get("smtp.port"), default=settings.smtp_port), + username=values.get("smtp.username", "") or settings.smtp_username or "", + password=values.get("smtp.password", "") or settings.smtp_password or "", + from_email=database_from_email or settings.smtp_from_email or "", + from_name=values.get("smtp.from_name", "") or settings.smtp_from_name, + use_tls=parse_bool(values.get("smtp.use_tls"), default=settings.smtp_use_tls), + enabled=False, + source="missing", + ) + + @staticmethod + def update_smtp_settings( + db: Session, + payload: SmtpSettingsUpdate, + *, + actor: User, + request: Request, + ) -> SmtpSettingsResponse: + current_values = SystemSettingsService._get_values(db, SMTP_KEYS) + password = payload.password if payload.password else current_values.get("smtp.password", "") + + updates = { + "smtp.host": payload.host, + "smtp.port": str(payload.port), + "smtp.username": payload.username, + "smtp.password": password, + "smtp.from_email": payload.from_email, + "smtp.from_name": payload.from_name or "Funktechnik Schubert", + "smtp.use_tls": "true" if payload.use_tls else "false", + "smtp.enabled": "true" if payload.enabled else "false", + } + + for key, value in updates.items(): + SystemSettingsRepository.upsert(db, key=key, value=value, is_secret=key in SECRET_KEYS) + db.commit() + + write_audit_log( + db, + action="system_settings.smtp.update", + entity_type="system_settings", + entity_label="SMTP-Konfiguration", + actor=actor, + request=request, + metadata={ + "enabled": payload.enabled, + "host": payload.host, + "from_email": payload.from_email, + "password_changed": bool(payload.password), + }, + ) + return SystemSettingsService.get_smtp_settings(db) + + @staticmethod + def get_public_links_settings(db: Session) -> PublicLinksSettingsResponse: + value = SystemSettingsService.get_public_repair_status_base_url(db) + source: SettingsSource = "missing" + if value: + db_value = SystemSettingsRepository.get(db, "public.repair_status_base_url") + source = "database" if db_value and db_value.value.strip() else "environment" + return PublicLinksSettingsResponse(repair_status_base_url=value, source=source) + + @staticmethod + def get_public_repair_status_base_url(db: Session) -> str: + setting = SystemSettingsRepository.get(db, "public.repair_status_base_url") + if setting and setting.value.strip(): + return setting.value.strip().rstrip("/") + return (settings.public_repair_status_base_url or "").strip().rstrip("/") + + @staticmethod + def update_public_links_settings( + db: Session, + payload: PublicLinksSettingsUpdate, + *, + actor: User, + request: Request, + ) -> PublicLinksSettingsResponse: + SystemSettingsRepository.upsert( + db, + key="public.repair_status_base_url", + value=payload.repair_status_base_url, + is_secret=False, + ) + db.commit() + write_audit_log( + db, + action="system_settings.public_links.update", + entity_type="system_settings", + entity_label="Öffentliche Links", + actor=actor, + request=request, + metadata={"repair_status_base_url": payload.repair_status_base_url}, + ) + return SystemSettingsService.get_public_links_settings(db) + + @staticmethod + def send_test_mail( + db: Session, + recipient: EmailStr, + *, + actor: User, + request: Request, + ) -> SmtpTestResponse: + smtp_config = SystemSettingsService.get_smtp_runtime_config(db) + if not smtp_config.is_configured: + write_audit_log( + db, + action="system_settings.smtp.test_failed", + entity_type="system_settings", + entity_label="SMTP-Testmail", + actor=actor, + request=request, + metadata={"reason": "smtp_not_configured", "source": smtp_config.source}, + ) + return SmtpTestResponse(success=False, message="SMTP ist nicht vollständig konfiguriert.", source=smtp_config.source) + + try: + SystemSettingsService.send_email( + smtp_config, + recipient=str(recipient), + subject="Olympus CRM SMTP-Test", + text=( + "Hallo,\n\n" + "dies ist eine Testmail aus Olympus CRM. " + "Die SMTP-Konfiguration ist grundsätzlich erreichbar.\n\n" + "Olympus CRM" + ), + html=( + "

Hallo,

" + "

dies ist eine Testmail aus Olympus CRM. " + "Die SMTP-Konfiguration ist grundsätzlich erreichbar.

" + "

Olympus CRM

" + ), + ) + except Exception: + write_audit_log( + db, + action="system_settings.smtp.test_failed", + entity_type="system_settings", + entity_label="SMTP-Testmail", + actor=actor, + request=request, + metadata={"reason": "smtp_send_failed", "source": smtp_config.source}, + ) + return SmtpTestResponse(success=False, message="Testmail konnte nicht versendet werden.", source=smtp_config.source) + + write_audit_log( + db, + action="system_settings.smtp.test_sent", + entity_type="system_settings", + entity_label="SMTP-Testmail", + actor=actor, + request=request, + metadata={"source": smtp_config.source}, + ) + return SmtpTestResponse(success=True, message="Testmail wurde versendet.", source=smtp_config.source) + + @staticmethod + def send_email( + smtp_config: SmtpRuntimeConfig, + *, + recipient: str, + subject: str, + text: str, + html: str, + ) -> None: + message = EmailMessage() + message["Subject"] = subject + message["From"] = smtp_config.from_header + message["To"] = recipient + message.set_content(text) + message.add_alternative(html, subtype="html") + + with smtplib.SMTP(smtp_config.host, smtp_config.port, timeout=15) as smtp: + if smtp_config.use_tls: + smtp.starttls() + if smtp_config.username and smtp_config.password: + smtp.login(smtp_config.username, smtp_config.password) + smtp.send_message(message) + + @staticmethod + def smtp_response(smtp_config: SmtpRuntimeConfig) -> SmtpSettingsResponse: + return SmtpSettingsResponse( + host=smtp_config.host, + port=smtp_config.port, + username=smtp_config.username, + password_is_set=smtp_config.password_is_set, + from_email=smtp_config.from_email, + from_name=smtp_config.from_name, + use_tls=smtp_config.use_tls, + enabled=smtp_config.enabled, + source=smtp_config.source, + ) + + @staticmethod + def _get_values(db: Session, keys: tuple[str, ...]) -> dict[str, str]: + settings_by_key = SystemSettingsRepository.get_many(db, keys) + return {key: settings_by_key[key].value.strip() for key in keys if key in settings_by_key} diff --git a/docker-compose.yml b/docker-compose.yml index 8f660ef..5ad8a33 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,7 @@ services: KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge} KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50} OLYMPUS_REPAIR_INTAKE_TOKEN: ${OLYMPUS_REPAIR_INTAKE_TOKEN:-} + # Env-Fallbacks: Admin-Konfiguration in PostgreSQL hat Vorrang. PUBLIC_REPAIR_STATUS_BASE_URL: ${PUBLIC_REPAIR_STATUS_BASE_URL:-} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} diff --git a/frontend/athena/app/api/system-settings/public-links/route.ts b/frontend/athena/app/api/system-settings/public-links/route.ts new file mode 100644 index 0000000..a004455 --- /dev/null +++ b/frontend/athena/app/api/system-settings/public-links/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, "/system-settings/public-links"); +} + +export async function PUT(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/system-settings/public-links"); +} diff --git a/frontend/athena/app/api/system-settings/smtp/route.ts b/frontend/athena/app/api/system-settings/smtp/route.ts new file mode 100644 index 0000000..deed42c --- /dev/null +++ b/frontend/athena/app/api/system-settings/smtp/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, "/system-settings/smtp"); +} + +export async function PUT(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/system-settings/smtp"); +} diff --git a/frontend/athena/app/api/system-settings/smtp/test/route.ts b/frontend/athena/app/api/system-settings/smtp/test/route.ts new file mode 100644 index 0000000..2075228 --- /dev/null +++ b/frontend/athena/app/api/system-settings/smtp/test/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/system-settings/smtp/test"); +} diff --git a/frontend/athena/app/dashboard/page.tsx b/frontend/athena/app/dashboard/page.tsx index ddfb4c0..4b56102 100644 --- a/frontend/athena/app/dashboard/page.tsx +++ b/frontend/athena/app/dashboard/page.tsx @@ -108,6 +108,23 @@ export default function DashboardPage() { )} + {summary.system_status.length > 0 && ( +
+

Systemstatus

+
+ {summary.system_status.map((item) => ( +
+
+

{item.label}

+ +
+

{formatSystemValue(item.value)}

+
+ ))} +
+
+ )} +
= { + database: "Admin-Konfiguration", + environment: "Umgebung", + missing: "Nicht konfiguriert", +}; + +function getErrorMessage(error: unknown, fallback: string) { + if (typeof error === "object" && error !== null && "response" in error) { + const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response; + return response?.data?.detail ?? response?.data?.message ?? fallback; + } + return fallback; +} + +function emptySmtpSettings(): SmtpSettings { + return { + host: "", + port: 587, + username: "", + password_is_set: false, + from_email: "", + from_name: "Funktechnik Schubert", + use_tls: true, + enabled: false, + source: "missing", + }; +} + +export default function SettingsPage() { + const { showToast } = useToast(); + const [activeTab, setActiveTab] = useState("smtp"); + const [smtp, setSmtp] = useState(emptySmtpSettings()); + const [newPassword, setNewPassword] = useState(""); + const [testRecipient, setTestRecipient] = useState(""); + const [publicLinks, setPublicLinks] = useState({ + repair_status_base_url: "", + source: "missing", + }); + const [loading, setLoading] = useState(true); + const [savingSmtp, setSavingSmtp] = useState(false); + const [sendingTest, setSendingTest] = useState(false); + const [savingPublicLinks, setSavingPublicLinks] = useState(false); + const [error, setError] = useState(""); + + const loadSettings = useCallback(async () => { + setError(""); + setLoading(true); + try { + const [smtpResponse, publicLinksResponse] = await Promise.all([ + api.get("/system-settings/smtp"), + api.get("/system-settings/public-links"), + ]); + setSmtp(smtpResponse.data); + setPublicLinks(publicLinksResponse.data); + } catch (err) { + setError(getErrorMessage(err, "Einstellungen konnten nicht geladen werden.")); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + queueMicrotask(() => { + void loadSettings(); + }); + }, [loadSettings]); + + async function saveSmtp() { + setSavingSmtp(true); + try { + const payload: SmtpSettingsPayload = { + host: smtp.host, + port: smtp.port, + username: smtp.username, + from_email: smtp.from_email, + from_name: smtp.from_name, + use_tls: smtp.use_tls, + enabled: smtp.enabled, + }; + + if (newPassword) { + payload.password = newPassword; + } + + const response = await api.put("/system-settings/smtp", payload); + setSmtp(response.data); + setNewPassword(""); + showToast({ type: "success", title: "SMTP-Konfiguration gespeichert" }); + } catch (err) { + showToast({ + type: "error", + title: "SMTP konnte nicht gespeichert werden", + description: getErrorMessage(err, "Bitte prüfe die Eingaben."), + }); + } finally { + setSavingSmtp(false); + } + } + + async function sendTestMail() { + setSendingTest(true); + try { + const response = await api.post("/system-settings/smtp/test", { + recipient: testRecipient, + }); + showToast({ + type: response.data.success ? "success" : "error", + title: response.data.success ? "Testmail versendet" : "Testmail fehlgeschlagen", + description: response.data.message, + }); + } catch (err) { + showToast({ + type: "error", + title: "Testmail fehlgeschlagen", + description: getErrorMessage(err, "Die Testmail konnte nicht versendet werden."), + }); + } finally { + setSendingTest(false); + } + } + + async function savePublicLinks() { + setSavingPublicLinks(true); + try { + const response = await api.put("/system-settings/public-links", { + repair_status_base_url: publicLinks.repair_status_base_url, + }); + setPublicLinks(response.data); + showToast({ type: "success", title: "Öffentliche Links gespeichert" }); + } catch (err) { + showToast({ + type: "error", + title: "Öffentliche Links konnten nicht gespeichert werden", + description: getErrorMessage(err, "Bitte prüfe die Basis-URL."), + }); + } finally { + setSavingPublicLinks(false); + } + } + + if (loading) { + return
Einstellungen werden geladen...
; + } + + if (error) { + return
{error}
; + } + + return ( +
+
+
+

Einstellungen

+

SMTP-Versand und öffentliche Statuslinks verwalten

+
+
+ setActiveTab("smtp")}> + SMTP + + setActiveTab("public-links")}> + Öffentliche Links + +
+
+ + {activeTab === "smtp" ? ( +
+
+
+
+ +

SMTP

+
+

+ Quelle: {sourceLabels[smtp.source]} + {smtp.password_is_set ? " · Passwort ist gesetzt" : ""} +

+
+ +
+ +
+ + + + + + setSmtp((current) => ({ ...current, host: event.target.value }))} /> + + + + setSmtp((current) => ({ ...current, port: Number(event.target.value) || 587 }))} + /> + + + + setSmtp((current) => ({ ...current, username: event.target.value }))} /> + + + + setNewPassword(event.target.value)} + /> + + + + setSmtp((current) => ({ ...current, from_email: event.target.value }))} + /> + + + + setSmtp((current) => ({ ...current, from_name: event.target.value }))} /> + +
+ +
+ Für Apple Mail/iCloud: smtp.mail.me.com, Port 587, TLS/STARTTLS aktiv, + Benutzername vollständige Mailadresse, Passwort = app-spezifisches Passwort. +
+ +
+
+ +

Testmail senden

+
+
+ setTestRecipient(event.target.value)} + /> + +
+
+
+ ) : ( +
+
+
+
+ +

Öffentliche Links

+
+

Quelle: {sourceLabels[publicLinks.source]}

+
+ +
+ + + setPublicLinks((current) => ({ ...current, repair_status_base_url: event.target.value }))} + /> + + +
+ +

+ Statusmails erzeugen weiterhin sichere Einmal-Token in Hermes. Die Basis-URL bestimmt nur, + auf welche öffentliche Website-Route der Link zeigt. +

+
+
+ )} +
+ ); +} + +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( + + ); +} diff --git a/frontend/athena/components/Sidebar.tsx b/frontend/athena/components/Sidebar.tsx index c04c193..07d85b7 100644 --- a/frontend/athena/components/Sidebar.tsx +++ b/frontend/athena/components/Sidebar.tsx @@ -81,6 +81,7 @@ const menu = [ icon: Settings, name: "Einstellungen", href: "/settings", + permission: "system_settings.manage", }, ] satisfies Array<{ icon: typeof LayoutDashboard; diff --git a/frontend/athena/proxy.ts b/frontend/athena/proxy.ts index c44082f..80d0ef8 100644 --- a/frontend/athena/proxy.ts +++ b/frontend/athena/proxy.ts @@ -23,5 +23,6 @@ export const config = { "/roles/:path*", "/customers/:path*", "/repairs/:path*", + "/settings/:path*", ], }; diff --git a/frontend/athena/types/dashboard.ts b/frontend/athena/types/dashboard.ts index bc0f744..c3f4ef1 100644 --- a/frontend/athena/types/dashboard.ts +++ b/frontend/athena/types/dashboard.ts @@ -1,4 +1,5 @@ import type { Customer } from "@/types/customer"; +import type { SystemStatusItem } from "@/types/system-settings"; export interface MetricCard { label: string; @@ -16,6 +17,7 @@ export interface DashboardSummary { users: MetricCard[]; roles: MetricCard[]; repairs: MetricCard[]; + system_status: SystemStatusItem[]; activities: EmptyWidget; tasks: EmptyWidget; tickets: EmptyWidget; diff --git a/frontend/athena/types/system-settings.ts b/frontend/athena/types/system-settings.ts new file mode 100644 index 0000000..c810bad --- /dev/null +++ b/frontend/athena/types/system-settings.ts @@ -0,0 +1,41 @@ +export type SettingsSource = "database" | "environment" | "missing"; + +export interface SmtpSettings { + host: string; + port: number; + username: string; + password_is_set: boolean; + from_email: string; + from_name: string; + use_tls: boolean; + enabled: boolean; + source: SettingsSource; +} + +export interface SmtpSettingsPayload { + host: string; + port: number; + username: string; + password?: string; + from_email: string; + from_name: string; + use_tls: boolean; + enabled: boolean; +} + +export interface SmtpTestResponse { + success: boolean; + message: string; + source: SettingsSource; +} + +export interface PublicLinksSettings { + repair_status_base_url: string; + source: SettingsSource; +} + +export interface SystemStatusItem { + label: string; + value: string; + status: "ok" | "warning" | "info"; +}