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

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