L6 Benachrichtigungen & Audit: Glocke, Posteingang, Nutzer-Einstellungen, Mailkonfiguration

- Glocke im Backoffice-Header (ungelesen-Zähler, letzte 10, alle gelesen), mobil
  einbindbar über variant="mobile".
- /notifications mit Filter gelesen/ungelesen/Art, Öffnen markiert gelesen (nur
  relative Links), Pagination.
- /account Abschnitt Benachrichtigungen: E-Mail-Opt-out je Typ, Notdienst Pflicht.
- /settings/email (tenant:manage): Absendername, Antwortadresse, Empfänger Notdienst
  und Abrechnung; Validierung gegen Header-Injection, max. 20 Adressen, Audit.
- Actions unter actions/notifications mit moduleGuard + guard; Navigation ergänzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:12:21 +02:00
co-authored by Claude Opus 5
parent 7b37c41a83
commit 879012415e
18 changed files with 793 additions and 3 deletions
+7
View File
@@ -0,0 +1,7 @@
import { requireModule } from "@/server/modules";
/** Tenant mail settings belong to the "notifications" module (actions use its moduleGuard). */
export default async function MailSettingsLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("notifications");
return <>{children}</>;
}
+83
View File
@@ -0,0 +1,83 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { PageHead } from "@/components/mockup-ui";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getMailConfig } from "@/server/mail/config";
import { saveMailSettings } from "@/server/actions/notifications/mail-settings";
import { getMailSettings } from "@/server/services/notifications/mail-settings";
import { pageCtx } from "@/server/services/notifications/page-ctx";
const areaCls = "min-h-28 w-full rounded-md border border-input bg-transparent px-3 py-2 font-mono text-sm";
export default async function MailSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string }> }) {
const ctx = await pageCtx();
if (!ctx.permissions.has("tenant:manage")) redirect("/dashboard");
const [sp, s, t] = await Promise.all([searchParams, getMailSettings(ctx), getTranslations("notifications")]);
const platformFrom = getMailConfig().config?.from ?? "—";
return (
<main className="flex-1 p-4 md:p-6">
<Link href="/settings" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" aria-hidden /> {t("mailSettings.back")}
</Link>
<PageHead crumb={t("mailSettings.crumb")} title={t("mailSettings.title")} sub={t("mailSettings.sub")} />
{sp.saved && (
<p role="status" className="mb-4 rounded-lg border border-[var(--ok)] bg-card px-4 py-3 text-sm text-[var(--ok)]">
✓ {t("mailSettings.saved")}
</p>
)}
{sp.error && (
<p role="alert" className="mb-4 rounded-lg border border-[var(--risk)] bg-card px-4 py-3 text-sm text-[var(--risk)]">
⚠ {t("mailSettings.invalid", { detail: sp.error.split(",").map((f) => t.has(`mailSettings.${f}`) ? t(`mailSettings.${f}`) : f).join(", ") })}
</p>
)}
<form action={saveMailSettings} className="grid max-w-4xl gap-5 lg:grid-cols-2">
<section className="shadow-card rounded-xl border bg-card p-5">
<h2 className="mb-3 font-heading text-sm font-semibold">{t("mailSettings.sender")}</h2>
<div className="space-y-4">
<div>
<Label htmlFor="mailFromName">{t("mailSettings.fromName")}</Label>
<Input id="mailFromName" name="mailFromName" maxLength={100} defaultValue={s.mailFromName ?? ""} placeholder={s.orgName ?? ""} className="mt-1 h-11" />
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("mailSettings.fromNameHint")}</p>
</div>
<div>
<Label>{t("mailSettings.fromAddress")}</Label>
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("mailSettings.fromAddressHint", { address: platformFrom })}</p>
</div>
<div>
<Label htmlFor="mailReplyTo">{t("mailSettings.replyTo")}</Label>
<Input id="mailReplyTo" name="mailReplyTo" type="email" defaultValue={s.mailReplyTo ?? ""} className="mt-1 h-11" />
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("mailSettings.replyToHint")}</p>
</div>
</div>
</section>
<section className="shadow-card rounded-xl border bg-card p-5">
<h2 className="mb-3 font-heading text-sm font-semibold">{t("mailSettings.recipients")}</h2>
<div className="space-y-4">
<div>
<Label htmlFor="emergencyRecipients">{t("mailSettings.emergencyRecipients")}</Label>
<textarea id="emergencyRecipients" name="emergencyRecipients" defaultValue={s.emergencyRecipients.join("\n")} className={`${areaCls} mt-1`} />
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("mailSettings.emergencyHint")}</p>
</div>
<div>
<Label htmlFor="billingRecipients">{t("mailSettings.billingRecipients")}</Label>
<textarea id="billingRecipients" name="billingRecipients" defaultValue={s.billingRecipients.join("\n")} className={`${areaCls} mt-1`} />
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("mailSettings.billingHint")}</p>
</div>
</div>
</section>
<div className="lg:col-span-2">
<Button type="submit" className="min-h-11">{t("mailSettings.save")}</Button>
</div>
</form>
</main>
);
}