feat(settings): add SMTP admin configuration
This commit is contained in:
parent
c58e212e3a
commit
884a20e043
32 changed files with 1200 additions and 42 deletions
|
|
@ -29,11 +29,14 @@ KNOWLEDGE_MAX_UPLOAD_MB=50
|
||||||
# Produktiv als langen Zufallswert setzen. Nicht im Frontend verwenden.
|
# Produktiv als langen Zufallswert setzen. Nicht im Frontend verwenden.
|
||||||
OLYMPUS_REPAIR_INTAKE_TOKEN=
|
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
|
# Beispiel: https://test.funktechnik-schubert.de/status
|
||||||
PUBLIC_REPAIR_STATUS_BASE_URL=
|
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_HOST=
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USERNAME=
|
SMTP_USERNAME=
|
||||||
|
|
|
||||||
|
|
@ -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.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:
|
Hermes-Endpunkt fuer manuellen Neuversand:
|
||||||
|
|
||||||
- `POST /repairs/{id}/send-status-mail`
|
- `POST /repairs/{id}/send-status-mail`
|
||||||
|
|
||||||
Athena ruft diesen Endpunkt ausschliesslich ueber die BFF-Route `/api/repairs/[id]/send-status-mail` auf.
|
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
|
### Website Repair Intake
|
||||||
|
|
||||||
Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit.
|
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.
|
: Server-zu-Server Token fuer `POST /public/repair-intake`. Dieser Wert darf nicht im Frontend oder in Logs erscheinen.
|
||||||
|
|
||||||
`PUBLIC_REPAIR_STATUS_BASE_URL`
|
`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/<token>` 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/<token>` zurueck.
|
||||||
|
|
||||||
`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM_EMAIL`, `SMTP_FROM_NAME`, `SMTP_USE_TLS`
|
`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
|
### Athena
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ SMTP_FROM_NAME=Funktechnik Schubert
|
||||||
SMTP_USE_TLS=true
|
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.
|
Wenn `SECRET_KEY` Sonderzeichen wie `$` enthaelt, den Wert in der Shell oder Compose-Umgebung korrekt quoten. Secrets gehoeren nicht ins Git.
|
||||||
|
|
||||||
## Docker Netzwerk
|
## Docker Netzwerk
|
||||||
|
|
@ -169,6 +171,7 @@ Statuslink-Konzept:
|
||||||
- Tokens sind lang, zufaellig und werden nur gehasht gespeichert.
|
- Tokens sind lang, zufaellig und werden nur gehasht gespeichert.
|
||||||
- Der Klartexttoken wird nur einmal bei Erstellung zurueckgegeben.
|
- 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`.
|
- `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}`.
|
- Oeffentliche Statusdaten kommen spaeter ueber `GET /public/repairs/status/{token}`.
|
||||||
- Die Antwort enthaelt keine Kundendaten, keine internen Notizen und keine nicht freigegebenen Diagnosen.
|
- Die Antwort enthaelt keine Kundendaten, keine internen Notizen und keine nicht freigegebenen Diagnosen.
|
||||||
|
|
||||||
|
|
@ -178,8 +181,9 @@ Benachrichtigungen:
|
||||||
- `repair_notification_events` dokumentiert jeden Versandversuch.
|
- `repair_notification_events` dokumentiert jeden Versandversuch.
|
||||||
- Beim Statuswechsel wird automatisch eine Statusmail verarbeitet.
|
- Beim Statuswechsel wird automatisch eine Statusmail verarbeitet.
|
||||||
- Ohne SMTP-Konfiguration bleibt der Statuswechsel erfolgreich; der Versandversuch wird als `skipped` dokumentiert.
|
- 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 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` gehoert ausschliesslich in die Umgebung und nie ins Git oder in Logs.
|
- `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:
|
Vorbereitete Website-/Portal-Routen fuer spaeter:
|
||||||
|
|
||||||
|
|
|
||||||
12
ROADMAP.md
12
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
|
- Dashboard-Kennzahlen fuer Statusmails und Reparaturen ohne Kundenmail
|
||||||
- SMTP-Ausfall blockiert keine Reparaturprozesse
|
- 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
|
- `/portal/login` fuer spaeteren Kundenlogin
|
||||||
- Separates Authentifizierungsmodell fuer Kunden
|
- Separates Authentifizierungsmodell fuer Kunden
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,11 @@ STORAGE_MAX_UPLOAD_MB=50
|
||||||
KNOWLEDGE_STORAGE_PATH=/data/knowledge
|
KNOWLEDGE_STORAGE_PATH=/data/knowledge
|
||||||
KNOWLEDGE_MAX_UPLOAD_MB=50
|
KNOWLEDGE_MAX_UPLOAD_MB=50
|
||||||
OLYMPUS_REPAIR_INTAKE_TOKEN=
|
OLYMPUS_REPAIR_INTAKE_TOKEN=
|
||||||
|
# Env-Fallback. Bevorzugt wird die Admin-Konfiguration unter /settings.
|
||||||
PUBLIC_REPAIR_STATUS_BASE_URL=
|
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_HOST=
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USERNAME=
|
SMTP_USERNAME=
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import app.models.knowledge
|
||||||
import app.models.audit
|
import app.models.audit
|
||||||
import app.models.rbac
|
import app.models.rbac
|
||||||
import app.models.repair
|
import app.models.repair
|
||||||
|
import app.models.system_setting
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,69 @@
|
||||||
|
"""create system settings
|
||||||
|
|
||||||
|
Revision ID: f2b8d4e6a910
|
||||||
|
Revises: e9a7c3d5b812
|
||||||
|
Create Date: 2026-07-04 14:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "f2b8d4e6a910"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "e9a7c3d5b812"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> 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")
|
||||||
|
|
@ -7,11 +7,13 @@ from sqlalchemy.orm import Session
|
||||||
from app.core.rbac import get_user_permission_names, require_permission
|
from app.core.rbac import get_user_permission_names, require_permission
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.models.rbac import Role
|
from app.models.rbac import Role
|
||||||
|
from app.models.audit import AuditLog
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.repositories.customer_repository import CustomerRepository
|
from app.repositories.customer_repository import CustomerRepository
|
||||||
from app.repositories.repair_repository import RepairRepository
|
from app.repositories.repair_repository import RepairRepository
|
||||||
from app.repositories.user_repository import UserRepository
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -32,6 +34,7 @@ def get_dashboard_summary(
|
||||||
users: list[MetricCard] = []
|
users: list[MetricCard] = []
|
||||||
roles: list[MetricCard] = []
|
roles: list[MetricCard] = []
|
||||||
repairs: list[MetricCard] = []
|
repairs: list[MetricCard] = []
|
||||||
|
system_status: list[SystemStatusItem] = []
|
||||||
|
|
||||||
if "customers.read" in permissions:
|
if "customers.read" in permissions:
|
||||||
customers = [
|
customers = [
|
||||||
|
|
@ -69,6 +72,43 @@ def get_dashboard_summary(
|
||||||
MetricCard(label="Offen ohne Kundenmail", value=RepairRepository.count_open_repairs_without_customer_email(db)),
|
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})
|
logger.info("dashboard.summary", extra={"actor_user_id": current_user.id})
|
||||||
|
|
||||||
return DashboardSummary(
|
return DashboardSummary(
|
||||||
|
|
@ -77,6 +117,7 @@ def get_dashboard_summary(
|
||||||
users=users,
|
users=users,
|
||||||
roles=roles,
|
roles=roles,
|
||||||
repairs=repairs,
|
repairs=repairs,
|
||||||
|
system_status=system_status,
|
||||||
activities=EmptyWidget(
|
activities=EmptyWidget(
|
||||||
title="Letzte Aktivitäten",
|
title="Letzte Aktivitäten",
|
||||||
message="Noch keine Aktivitäten vorhanden.",
|
message="Noch keine Aktivitäten vorhanden.",
|
||||||
|
|
|
||||||
67
backend/hermes/app/api/system_settings.py
Normal file
67
backend/hermes/app/api/system_settings.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -25,6 +25,8 @@ import app.models.customer
|
||||||
import app.models.knowledge
|
import app.models.knowledge
|
||||||
import app.models.audit
|
import app.models.audit
|
||||||
import app.models.user
|
import app.models.user
|
||||||
|
import app.models.repair
|
||||||
|
import app.models.system_setting
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
|
|
|
||||||
|
|
@ -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.permissions import router as permissions_router
|
||||||
from app.api.repairs import router as repairs_router
|
from app.api.repairs import router as repairs_router
|
||||||
from app.api.roles import router as roles_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.api.users import router as users_router
|
||||||
from app.db.database import SessionLocal
|
from app.db.database import SessionLocal
|
||||||
from app.db.health import check_database
|
from app.db.health import check_database
|
||||||
|
|
@ -42,6 +43,7 @@ app.include_router(customers_router)
|
||||||
app.include_router(knowledge_router)
|
app.include_router(knowledge_router)
|
||||||
app.include_router(repairs_router)
|
app.include_router(repairs_router)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
|
app.include_router(system_settings_router)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
21
backend/hermes/app/models/system_setting.py
Normal file
21
backend/hermes/app/models/system_setting.py
Normal file
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
|
@ -69,6 +69,7 @@ STANDARD_PERMISSIONS = [
|
||||||
("dashboard.read", "Dashboard lesen", "Dashboard anzeigen", "dashboard"),
|
("dashboard.read", "Dashboard lesen", "Dashboard anzeigen", "dashboard"),
|
||||||
("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"),
|
("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"),
|
||||||
("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "system"),
|
("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "system"),
|
||||||
|
("system_settings.manage", "Systemeinstellungen verwalten", "SMTP und öffentliche Systemlinks verwalten", "system"),
|
||||||
("audit_logs.read", "Audit Logs lesen", "Audit Logs anzeigen", "audit_logs"),
|
("audit_logs.read", "Audit Logs lesen", "Audit Logs anzeigen", "audit_logs"),
|
||||||
("knowledge.read", "Wissen lesen", "Wissensdatenbank anzeigen", "knowledge"),
|
("knowledge.read", "Wissen lesen", "Wissensdatenbank anzeigen", "knowledge"),
|
||||||
("knowledge.create", "Wissen erstellen", "Wissensdatenbank-Einträge erstellen", "knowledge"),
|
("knowledge.create", "Wissen erstellen", "Wissensdatenbank-Einträge erstellen", "knowledge"),
|
||||||
|
|
|
||||||
|
|
@ -343,6 +343,17 @@ class RepairRepository:
|
||||||
.where(RepairNotificationEvent.status.in_(["failed", "skipped"]))
|
.where(RepairNotificationEvent.status.in_(["failed", "skipped"]))
|
||||||
) or 0
|
) 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
|
@staticmethod
|
||||||
def count_open_repairs_without_customer_email(db: Session) -> int:
|
def count_open_repairs_without_customer_email(db: Session) -> int:
|
||||||
return db.scalar(
|
return db.scalar(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -13,12 +13,19 @@ class EmptyWidget(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class SystemStatusItem(BaseModel):
|
||||||
|
label: str
|
||||||
|
value: str
|
||||||
|
status: str = "info"
|
||||||
|
|
||||||
|
|
||||||
class DashboardSummary(BaseModel):
|
class DashboardSummary(BaseModel):
|
||||||
customers: list[MetricCard] = []
|
customers: list[MetricCard] = []
|
||||||
latest_customers: list[CustomerResponse] = []
|
latest_customers: list[CustomerResponse] = []
|
||||||
users: list[MetricCard] = []
|
users: list[MetricCard] = []
|
||||||
roles: list[MetricCard] = []
|
roles: list[MetricCard] = []
|
||||||
repairs: list[MetricCard] = []
|
repairs: list[MetricCard] = []
|
||||||
|
system_status: list[SystemStatusItem] = []
|
||||||
activities: EmptyWidget
|
activities: EmptyWidget
|
||||||
tasks: EmptyWidget
|
tasks: EmptyWidget
|
||||||
tickets: EmptyWidget
|
tickets: EmptyWidget
|
||||||
|
|
|
||||||
70
backend/hermes/app/schemas/system_setting.py
Normal file
70
backend/hermes/app/schemas/system_setting.py
Normal file
|
|
@ -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("/")
|
||||||
|
|
@ -169,6 +169,10 @@ def action_title(action: str) -> str:
|
||||||
"repairs.public_link.regenerate": "Reparatur-Statuslink erneut erstellt",
|
"repairs.public_link.regenerate": "Reparatur-Statuslink erneut erstellt",
|
||||||
"repairs.status_mail.sent": "Statusmail versendet",
|
"repairs.status_mail.sent": "Statusmail versendet",
|
||||||
"repairs.status_mail.failed": "Statusmail fehlgeschlagen",
|
"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)
|
return labels.get(action, action)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
import smtplib
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from email.message import EmailMessage
|
|
||||||
from html import escape
|
from html import escape
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.models.repair import Repair, RepairNotificationEvent
|
from app.models.repair import Repair, RepairNotificationEvent
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.repositories.repair_repository import RepairRepository
|
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.audit_service import write_audit_log
|
||||||
from app.services.repair_public_link_service import RepairPublicLinkService
|
from app.services.repair_public_link_service import RepairPublicLinkService
|
||||||
from app.services.repair_status_labels import MAIL_STATUS_LABELS, STATUS_LABELS
|
from app.services.repair_status_labels import MAIL_STATUS_LABELS, STATUS_LABELS
|
||||||
|
from app.services.system_settings_service import SystemSettingsService
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -98,16 +96,6 @@ def _repair_label(repair: Repair) -> str:
|
||||||
return f"{repair.repair_number} · {repair.customer_name}"
|
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:
|
def _plain_status_mail(repair: Repair, *, status_label: str, public_status_url: str) -> str:
|
||||||
return (
|
return (
|
||||||
f"Hallo {repair.customer_name},\n\n"
|
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)
|
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)
|
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(
|
event = RepairRepository.create_notification_event(
|
||||||
db,
|
db,
|
||||||
repair_id=repair.id,
|
repair_id=repair.id,
|
||||||
|
|
@ -253,7 +242,7 @@ class RepairNotificationService:
|
||||||
return event
|
return event
|
||||||
|
|
||||||
try:
|
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:
|
except Exception:
|
||||||
event = RepairRepository.create_notification_event(
|
event = RepairRepository.create_notification_event(
|
||||||
db,
|
db,
|
||||||
|
|
@ -294,22 +283,6 @@ class RepairNotificationService:
|
||||||
)
|
)
|
||||||
return event
|
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
|
@staticmethod
|
||||||
def _audit_failure(db: Session, repair: Repair, *, actor: User | None, request: Request | None, reason: str) -> None:
|
def _audit_failure(db: Session, repair: Repair, *, actor: User | None, request: Request | None, reason: str) -> None:
|
||||||
write_audit_log(
|
write_audit_log(
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ from app.schemas.repair import (
|
||||||
)
|
)
|
||||||
from app.services.audit_service import write_audit_log
|
from app.services.audit_service import write_audit_log
|
||||||
from app.services.repair_status_labels import STATUS_LABELS
|
from app.services.repair_status_labels import STATUS_LABELS
|
||||||
|
from app.services.system_settings_service import SystemSettingsService
|
||||||
|
|
||||||
|
|
||||||
def normalize_token(token: str) -> str:
|
def normalize_token(token: str) -> str:
|
||||||
|
|
@ -29,10 +30,10 @@ def repair_label(repair: Repair) -> str:
|
||||||
return f"{repair.repair_number} · {repair.customer_name}"
|
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)
|
normalized_token = normalize_token(token)
|
||||||
path = f"/status/{normalized_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:
|
if not base_url:
|
||||||
return path
|
return path
|
||||||
return f"{base_url}/{normalized_token}"
|
return f"{base_url}/{normalized_token}"
|
||||||
|
|
@ -109,7 +110,7 @@ class RepairPublicLinkService:
|
||||||
last_used_at=public_link.last_used_at,
|
last_used_at=public_link.last_used_at,
|
||||||
created_at=public_link.created_at,
|
created_at=public_link.created_at,
|
||||||
revoked_at=public_link.revoked_at,
|
revoked_at=public_link.revoked_at,
|
||||||
public_status_path=public_status_path(token),
|
public_status_path=public_status_path(db, token),
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
||||||
316
backend/hermes/app/services/system_settings_service.py
Normal file
316
backend/hermes/app/services/system_settings_service.py
Normal file
|
|
@ -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=(
|
||||||
|
"<p>Hallo,</p>"
|
||||||
|
"<p>dies ist eine Testmail aus Olympus CRM. "
|
||||||
|
"Die SMTP-Konfiguration ist grundsätzlich erreichbar.</p>"
|
||||||
|
"<p>Olympus CRM</p>"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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}
|
||||||
|
|
@ -26,6 +26,7 @@ services:
|
||||||
KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge}
|
KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge}
|
||||||
KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50}
|
KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50}
|
||||||
OLYMPUS_REPAIR_INTAKE_TOKEN: ${OLYMPUS_REPAIR_INTAKE_TOKEN:-}
|
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:-}
|
PUBLIC_REPAIR_STATUS_BASE_URL: ${PUBLIC_REPAIR_STATUS_BASE_URL:-}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
18
frontend/athena/app/api/system-settings/smtp/route.ts
Normal file
18
frontend/athena/app/api/system-settings/smtp/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return proxyHermesRequest(request, "/system-settings/smtp");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyHermesRequest(request, "/system-settings/smtp");
|
||||||
|
}
|
||||||
14
frontend/athena/app/api/system-settings/smtp/test/route.ts
Normal file
14
frontend/athena/app/api/system-settings/smtp/test/route.ts
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -108,6 +108,23 @@ export default function DashboardPage() {
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{summary.system_status.length > 0 && (
|
||||||
|
<section className="rounded-lg border bg-white p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-950">Systemstatus</h2>
|
||||||
|
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{summary.system_status.map((item) => (
|
||||||
|
<div key={item.label} className="rounded-lg border bg-slate-50 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-medium text-slate-600">{item.label}</p>
|
||||||
|
<span className={`h-2.5 w-2.5 rounded-full ${statusColor(item.status)}`} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 break-words text-sm font-semibold text-slate-950">{formatSystemValue(item.value)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<ActivityPanel
|
<ActivityPanel
|
||||||
widget={summary.activities}
|
widget={summary.activities}
|
||||||
|
|
@ -123,6 +140,27 @@ export default function DashboardPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusColor(status: "ok" | "warning" | "info") {
|
||||||
|
if (status === "ok") {
|
||||||
|
return "bg-emerald-500";
|
||||||
|
}
|
||||||
|
if (status === "warning") {
|
||||||
|
return "bg-amber-500";
|
||||||
|
}
|
||||||
|
return "bg-slate-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSystemValue(value: string) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!Number.isNaN(date.getTime()) && value.includes("T")) {
|
||||||
|
return new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function ActivityPanel({
|
function ActivityPanel({
|
||||||
widget,
|
widget,
|
||||||
activities,
|
activities,
|
||||||
|
|
|
||||||
356
frontend/athena/app/settings/page.tsx
Normal file
356
frontend/athena/app/settings/page.tsx
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { ExternalLink, MailCheck, Save, Send, ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
|
import { useToast } from "@/components/common/ToastProvider";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type {
|
||||||
|
PublicLinksSettings,
|
||||||
|
SettingsSource,
|
||||||
|
SmtpSettings,
|
||||||
|
SmtpSettingsPayload,
|
||||||
|
SmtpTestResponse,
|
||||||
|
} from "@/types/system-settings";
|
||||||
|
|
||||||
|
type SettingsTab = "smtp" | "public-links";
|
||||||
|
|
||||||
|
const sourceLabels: Record<SettingsSource, string> = {
|
||||||
|
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<SettingsTab>("smtp");
|
||||||
|
const [smtp, setSmtp] = useState<SmtpSettings>(emptySmtpSettings());
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [testRecipient, setTestRecipient] = useState("");
|
||||||
|
const [publicLinks, setPublicLinks] = useState<PublicLinksSettings>({
|
||||||
|
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<SmtpSettings>("/system-settings/smtp"),
|
||||||
|
api.get<PublicLinksSettings>("/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<SmtpSettings>("/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<SmtpTestResponse>("/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<PublicLinksSettings>("/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 <div className="rounded-lg border bg-white p-8 text-slate-500">Einstellungen werden geladen...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="rounded-lg border bg-white p-8 text-red-600">{error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-950">Einstellungen</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">SMTP-Versand und öffentliche Statuslinks verwalten</p>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex w-fit rounded-lg border bg-white p-1">
|
||||||
|
<TabButton active={activeTab === "smtp"} onClick={() => setActiveTab("smtp")}>
|
||||||
|
SMTP
|
||||||
|
</TabButton>
|
||||||
|
<TabButton active={activeTab === "public-links"} onClick={() => setActiveTab("public-links")}>
|
||||||
|
Öffentliche Links
|
||||||
|
</TabButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === "smtp" ? (
|
||||||
|
<section className="rounded-lg border bg-white p-6">
|
||||||
|
<div className="mb-6 flex flex-col gap-3 border-b pb-5 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MailCheck className="h-5 w-5 text-slate-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-slate-950">SMTP</h2>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Quelle: {sourceLabels[smtp.source]}
|
||||||
|
{smtp.password_is_set ? " · Passwort ist gesetzt" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" onClick={() => void saveSmtp()} disabled={savingSmtp}>
|
||||||
|
<Save />
|
||||||
|
{savingSmtp ? "Speichert..." : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
|
<label className="flex items-center gap-3 rounded-lg border p-4 text-sm font-medium text-slate-800">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-slate-300"
|
||||||
|
checked={smtp.enabled}
|
||||||
|
onChange={(event) => setSmtp((current) => ({ ...current, enabled: event.target.checked }))}
|
||||||
|
/>
|
||||||
|
SMTP über Admin-Konfiguration aktivieren
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 rounded-lg border p-4 text-sm font-medium text-slate-800">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-slate-300"
|
||||||
|
checked={smtp.use_tls}
|
||||||
|
onChange={(event) => setSmtp((current) => ({ ...current, use_tls: event.target.checked }))}
|
||||||
|
/>
|
||||||
|
TLS / STARTTLS aktiv
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Field label="SMTP Host">
|
||||||
|
<Input value={smtp.host} onChange={(event) => setSmtp((current) => ({ ...current, host: event.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Port">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={smtp.port}
|
||||||
|
onChange={(event) => setSmtp((current) => ({ ...current, port: Number(event.target.value) || 587 }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Benutzername">
|
||||||
|
<Input value={smtp.username} onChange={(event) => setSmtp((current) => ({ ...current, username: event.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label={smtp.password_is_set ? "Neues Passwort setzen" : "Passwort"}>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
placeholder={smtp.password_is_set ? "Leer lassen, um aktuelles Passwort zu behalten" : ""}
|
||||||
|
onChange={(event) => setNewPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Absenderadresse">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={smtp.from_email}
|
||||||
|
onChange={(event) => setSmtp((current) => ({ ...current, from_email: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Absendername">
|
||||||
|
<Input value={smtp.from_name} onChange={(event) => setSmtp((current) => ({ ...current, from_name: event.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-950">
|
||||||
|
Für Apple Mail/iCloud: smtp.mail.me.com, Port 587, TLS/STARTTLS aktiv,
|
||||||
|
Benutzername vollständige Mailadresse, Passwort = app-spezifisches Passwort.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-lg border bg-slate-50 p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Send className="h-5 w-5 text-slate-500" />
|
||||||
|
<h3 className="font-semibold text-slate-950">Testmail senden</h3>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="empfaenger@example.com"
|
||||||
|
value={testRecipient}
|
||||||
|
onChange={(event) => setTestRecipient(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="button" onClick={() => void sendTestMail()} disabled={sendingTest || !testRecipient}>
|
||||||
|
<Send />
|
||||||
|
{sendingTest ? "Sendet..." : "Testmail"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="rounded-lg border bg-white p-6">
|
||||||
|
<div className="mb-6 flex flex-col gap-3 border-b pb-5 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ExternalLink className="h-5 w-5 text-slate-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-slate-950">Öffentliche Links</h2>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">Quelle: {sourceLabels[publicLinks.source]}</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" onClick={() => void savePublicLinks()} disabled={savingPublicLinks}>
|
||||||
|
<Save />
|
||||||
|
{savingPublicLinks ? "Speichert..." : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Reparaturstatus Basis-URL">
|
||||||
|
<Input
|
||||||
|
placeholder="https://test.funktechnik-schubert.de/status"
|
||||||
|
value={publicLinks.repair_status_base_url}
|
||||||
|
onChange={(event) => setPublicLinks((current) => ({ ...current, repair_status_base_url: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-start gap-3 rounded-lg border border-emerald-100 bg-emerald-50 p-4 text-sm text-emerald-950">
|
||||||
|
<ShieldCheck className="mt-0.5 h-5 w-5 shrink-0" />
|
||||||
|
<p>
|
||||||
|
Statusmails erzeugen weiterhin sichere Einmal-Token in Hermes. Die Basis-URL bestimmt nur,
|
||||||
|
auf welche öffentliche Website-Route der Link zeigt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
active ? "bg-slate-900 text-white" : "text-slate-600 hover:bg-slate-100 hover:text-slate-950"
|
||||||
|
}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium text-slate-700">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -81,6 +81,7 @@ const menu = [
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
name: "Einstellungen",
|
name: "Einstellungen",
|
||||||
href: "/settings",
|
href: "/settings",
|
||||||
|
permission: "system_settings.manage",
|
||||||
},
|
},
|
||||||
] satisfies Array<{
|
] satisfies Array<{
|
||||||
icon: typeof LayoutDashboard;
|
icon: typeof LayoutDashboard;
|
||||||
|
|
|
||||||
|
|
@ -23,5 +23,6 @@ export const config = {
|
||||||
"/roles/:path*",
|
"/roles/:path*",
|
||||||
"/customers/:path*",
|
"/customers/:path*",
|
||||||
"/repairs/:path*",
|
"/repairs/:path*",
|
||||||
|
"/settings/:path*",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Customer } from "@/types/customer";
|
import type { Customer } from "@/types/customer";
|
||||||
|
import type { SystemStatusItem } from "@/types/system-settings";
|
||||||
|
|
||||||
export interface MetricCard {
|
export interface MetricCard {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -16,6 +17,7 @@ export interface DashboardSummary {
|
||||||
users: MetricCard[];
|
users: MetricCard[];
|
||||||
roles: MetricCard[];
|
roles: MetricCard[];
|
||||||
repairs: MetricCard[];
|
repairs: MetricCard[];
|
||||||
|
system_status: SystemStatusItem[];
|
||||||
activities: EmptyWidget;
|
activities: EmptyWidget;
|
||||||
tasks: EmptyWidget;
|
tasks: EmptyWidget;
|
||||||
tickets: EmptyWidget;
|
tickets: EmptyWidget;
|
||||||
|
|
|
||||||
41
frontend/athena/types/system-settings.ts
Normal file
41
frontend/athena/types/system-settings.ts
Normal file
|
|
@ -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";
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue