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.
|
||||
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=
|
||||
|
|
|
|||
|
|
@ -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/<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`
|
||||
: 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
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
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
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.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.",
|
||||
|
|
|
|||
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.audit
|
||||
import app.models.user
|
||||
import app.models.repair
|
||||
import app.models.system_setting
|
||||
|
||||
|
||||
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.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__)
|
||||
|
||||
|
|
|
|||
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"),
|
||||
("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"),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
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.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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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_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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{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">
|
||||
<ActivityPanel
|
||||
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({
|
||||
widget,
|
||||
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,
|
||||
name: "Einstellungen",
|
||||
href: "/settings",
|
||||
permission: "system_settings.manage",
|
||||
},
|
||||
] satisfies Array<{
|
||||
icon: typeof LayoutDashboard;
|
||||
|
|
|
|||
|
|
@ -23,5 +23,6 @@ export const config = {
|
|||
"/roles/:path*",
|
||||
"/customers/:path*",
|
||||
"/repairs/:path*",
|
||||
"/settings/:path*",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
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