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:
@@ -12,6 +12,7 @@ import { TenantRecoveryRegenForm } from "@/components/tenant-recovery-regen-form
|
||||
import { PasskeyManager } from "@/components/passkey-manager";
|
||||
import { ChangePasswordSelfForm } from "@/components/auth-recovery-forms";
|
||||
import { describePasswordPolicy, resolvePasswordPolicy } from "@/lib/password-policy";
|
||||
import { NotificationPreferencesSection } from "@/components/notifications/preferences-section";
|
||||
|
||||
/**
|
||||
* Persönliches Konto des Mandanten-Nutzers (Paket C): optionale MFA selbst
|
||||
@@ -117,6 +118,8 @@ export default async function AccountPage() {
|
||||
Eine Änderung ist derzeit nur über die Plattform-Administration möglich.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NotificationPreferencesSection />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TenantBrand } from "@/components/brand/tenant-brand";
|
||||
import { TenantSwitcher } from "@/components/tenant-switcher";
|
||||
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
|
||||
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
||||
import { NotificationBell } from "@/components/notifications/bell";
|
||||
|
||||
export default async function AppLayout({
|
||||
children,
|
||||
@@ -135,6 +136,7 @@ export default async function AppLayout({
|
||||
<header className="sticky top-0 z-10 flex items-center gap-4 border-b bg-[var(--panel)] px-6 py-2.5 backdrop-blur-md">
|
||||
{/* TODO(craftvia): globale Suche (Aufträge/Kunden/Objekte) — Andockpunkt für die Fachmodule. */}
|
||||
<div className="flex-1" />
|
||||
<NotificationBell />
|
||||
<UiLocaleSwitcher current={identity.uiLocale} />
|
||||
<Link href="/account" className="flex items-center gap-3">
|
||||
<div className="text-right leading-tight">
|
||||
|
||||
@@ -1,5 +1,134 @@
|
||||
import { ModulePlaceholder } from "@/components/module-placeholder";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { BellDot, CheckCheck } from "lucide-react";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { EVENT_TYPES } from "@/lib/events";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { markAllNotificationsRead, markNotificationRead, openNotification } from "@/server/actions/notifications/inbox";
|
||||
import { listNotifications } from "@/server/services/notifications/inbox";
|
||||
import { pageCtx } from "@/server/services/notifications/page-ctx";
|
||||
import { eventKey } from "@/server/services/notifications/texts";
|
||||
|
||||
export default function Page() {
|
||||
return <ModulePlaceholder moduleKey="notifications" />;
|
||||
const selectCls = "h-11 rounded-md border border-input bg-card px-3 text-sm";
|
||||
|
||||
export default async function NotificationsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const ctx = await pageCtx();
|
||||
if (!ctx.permissions.has("notification:read")) redirect("/dashboard");
|
||||
const sp = await searchParams;
|
||||
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
|
||||
const data = await listNotifications(ctx, { status: one(sp.status), type: one(sp.type), page: one(sp.page) });
|
||||
const [t, format] = await Promise.all([getTranslations("notifications"), getFormatter()]);
|
||||
|
||||
const pageHref = (page: number) => {
|
||||
const q = new URLSearchParams();
|
||||
if (data.filter.status !== "all") q.set("status", data.filter.status);
|
||||
if (data.filter.type) q.set("type", data.filter.type);
|
||||
q.set("page", String(page));
|
||||
return `/notifications?${q.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<PageHead
|
||||
crumb={t("list.crumb")}
|
||||
title={t("list.title")}
|
||||
sub={t("list.sub")}
|
||||
actions={
|
||||
<form action={markAllNotificationsRead}>
|
||||
<Button type="submit" variant="outline" className="min-h-11">
|
||||
<CheckCheck aria-hidden /> {t("list.markAllRead")}
|
||||
</Button>
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
|
||||
<form method="get" className="shadow-card mb-4 flex flex-wrap items-end gap-3 rounded-xl border bg-card p-4">
|
||||
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
|
||||
{t("list.filterStatus")}
|
||||
<select name="status" defaultValue={data.filter.status} className={selectCls}>
|
||||
<option value="all">{t("list.statusAll")}</option>
|
||||
<option value="unread">{t("list.statusUnread")}</option>
|
||||
<option value="read">{t("list.statusRead")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
|
||||
{t("list.filterType")}
|
||||
<select name="type" defaultValue={data.filter.type ?? ""} className={selectCls}>
|
||||
<option value="">{t("list.typeAll")}</option>
|
||||
{EVENT_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{t(`types.${eventKey(type)}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Button type="submit" className="min-h-11">{t("list.apply")}</Button>
|
||||
<Link href="/notifications" className="flex min-h-11 items-center px-2 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
{t("list.reset")}
|
||||
</Link>
|
||||
</form>
|
||||
|
||||
{data.rows.length === 0 ? (
|
||||
<p className="shadow-card rounded-xl border bg-card p-6 text-sm text-muted-foreground">{t("list.empty")}</p>
|
||||
) : (
|
||||
<ul className="shadow-card divide-y rounded-xl border bg-card">
|
||||
{data.rows.map((n) => (
|
||||
<li key={n.id} className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-3">
|
||||
{n.readAt ? (
|
||||
<Pill tone="mut">{t("list.read")}</Pill>
|
||||
) : (
|
||||
<Pill tone="orange">
|
||||
<BellDot className="size-3.5" aria-hidden /> {t("list.unread")}
|
||||
</Pill>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className={n.readAt ? "text-sm font-medium" : "text-sm font-semibold"}>{n.title}</p>
|
||||
<p className="text-[13px] text-muted-foreground">{n.message}</p>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
|
||||
{EVENT_TYPES.includes(n.type as (typeof EVENT_TYPES)[number])
|
||||
? t(`types.${eventKey(n.type as (typeof EVENT_TYPES)[number])}`)
|
||||
: n.type}
|
||||
{" · "}
|
||||
{format.dateTime(n.createdAt, { dateStyle: "medium", timeStyle: "short" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
{!n.readAt && (
|
||||
<form action={markNotificationRead}>
|
||||
<input type="hidden" name="id" value={n.id} />
|
||||
<Button type="submit" variant="ghost" className="min-h-11">{t("list.markRead")}</Button>
|
||||
</form>
|
||||
)}
|
||||
{n.link && (
|
||||
<form action={openNotification}>
|
||||
<input type="hidden" name="id" value={n.id} />
|
||||
<Button type="submit" variant="outline" className="min-h-11">{t("list.open")}</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{data.pages > 1 && (
|
||||
<nav className="mt-4 flex items-center justify-between text-[13px]" aria-label={t("list.title")}>
|
||||
{data.page > 1 ? (
|
||||
<Link href={pageHref(data.page - 1)} className="flex min-h-11 items-center font-semibold text-[var(--primary)]">← {t("list.previous")}</Link>
|
||||
) : <span />}
|
||||
<span className="text-muted-foreground">{t("list.page", { page: data.page, pages: data.pages })}</span>
|
||||
{data.page < data.pages ? (
|
||||
<Link href={pageHref(data.page + 1)} className="flex min-h-11 items-center font-semibold text-[var(--primary)]">{t("list.next")} →</Link>
|
||||
) : <span />}
|
||||
</nav>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user