feat(settings): add SMTP admin configuration

This commit is contained in:
Schubert Ferenc 2026-07-04 23:54:20 +02:00
parent c58e212e3a
commit 884a20e043
32 changed files with 1200 additions and 42 deletions

View file

@ -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=

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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=

View file

@ -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

View file

@ -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")

View file

@ -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.",

View 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)

View file

@ -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():

View file

@ -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__)

View 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(),
)

View file

@ -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"),

View file

@ -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(

View file

@ -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

View file

@ -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

View 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("/")

View file

@ -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)

View file

@ -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(

View file

@ -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

View 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}

View file

@ -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}

View 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/public-links");
}
export async function PUT(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyHermesRequest(request, "/system-settings/public-links");
}

View 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");
}

View 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");
}

View file

@ -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,

View 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>
);
}

View file

@ -81,6 +81,7 @@ const menu = [
icon: Settings,
name: "Einstellungen",
href: "/settings",
permission: "system_settings.manage",
},
] satisfies Array<{
icon: typeof LayoutDashboard;

View file

@ -23,5 +23,6 @@ export const config = {
"/roles/:path*",
"/customers/:path*",
"/repairs/:path*",
"/settings/:path*",
],
};

View file

@ -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;

View 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";
}