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:
@@ -0,0 +1,94 @@
|
||||
import Link from "next/link";
|
||||
import { Bell } from "lucide-react";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { markAllNotificationsRead, openNotification } from "@/server/actions/notifications/inbox";
|
||||
import { bellSummary } from "@/server/services/notifications/inbox";
|
||||
import { isNotificationsModuleEnabled, pageCtx } from "@/server/services/notifications/page-ctx";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Notification bell for the back office header and the mobile header (L4 embeds
|
||||
* `<NotificationBell variant="mobile" />`). Server component, no client JS: the dropdown is a
|
||||
* native <details>. Hidden without `notification:read` or with the module disabled.
|
||||
*/
|
||||
export async function NotificationBell({ variant = "desktop" }: { variant?: "desktop" | "mobile" }) {
|
||||
const ctx = await pageCtx();
|
||||
if (!ctx.permissions.has("notification:read") || !(await isNotificationsModuleEnabled(ctx))) return null;
|
||||
|
||||
const [{ unread, latest }, t, format] = await Promise.all([bellSummary(ctx), getTranslations("notifications"), getFormatter()]);
|
||||
const touch = variant === "mobile" ? "size-12" : "size-11";
|
||||
|
||||
return (
|
||||
<details className="group relative">
|
||||
<summary
|
||||
className={cn(
|
||||
"relative grid cursor-pointer list-none place-items-center rounded-lg text-foreground hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 [&::-webkit-details-marker]:hidden",
|
||||
touch,
|
||||
)}
|
||||
aria-label={`${t("bell.label")} – ${t("bell.unreadCount", { count: unread })}`}
|
||||
>
|
||||
<Bell className="size-5" aria-hidden />
|
||||
{unread > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-1 right-1 min-w-[18px] rounded-full bg-[var(--ui-accent)] px-1 text-center text-[10.5px] leading-[18px] font-bold text-[var(--ui-accent-foreground)]"
|
||||
>
|
||||
{unread > 99 ? "99+" : unread}
|
||||
</span>
|
||||
)}
|
||||
</summary>
|
||||
|
||||
<div className="shadow-card absolute right-0 z-40 mt-2 w-[min(24rem,calc(100vw-2rem))] rounded-xl border bg-card">
|
||||
<div className="flex items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div>
|
||||
<p className="font-heading text-sm font-semibold">{t("bell.label")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("bell.unreadCount", { count: unread })}</p>
|
||||
</div>
|
||||
{unread > 0 && (
|
||||
<form action={markAllNotificationsRead}>
|
||||
<button type="submit" className="min-h-11 rounded-md px-2 text-[12.5px] font-semibold text-[var(--primary)] hover:bg-muted">
|
||||
{t("bell.markAllRead")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{latest.length === 0 ? (
|
||||
<p className="px-4 py-6 text-sm text-muted-foreground">{t("bell.empty")}</p>
|
||||
) : (
|
||||
<ul className="max-h-[60vh] divide-y overflow-y-auto">
|
||||
{latest.map((n) => (
|
||||
<li key={n.id}>
|
||||
<form action={openNotification}>
|
||||
<input type="hidden" name="id" value={n.id} />
|
||||
<button type="submit" className="flex min-h-11 w-full items-start gap-2.5 px-4 py-2.5 text-left hover:bg-muted/60">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("mt-1.5 size-2 shrink-0 rounded-full", n.readAt ? "bg-transparent" : "bg-[var(--ui-accent)]")}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={cn("truncate text-[13px]", n.readAt ? "font-medium" : "font-semibold")}>{n.title}</span>
|
||||
{!n.readAt && <span className="shrink-0 text-[10.5px] font-bold text-[var(--primary)] uppercase">{t("list.unread")}</span>}
|
||||
</span>
|
||||
<span className="line-clamp-2 block text-[12px] text-muted-foreground">{n.message}</span>
|
||||
<span className="block text-[11px] text-muted-foreground">
|
||||
{format.dateTime(n.createdAt, { dateStyle: "short", timeStyle: "short" })}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="border-t px-4 py-2">
|
||||
<Link href="/notifications" className="flex min-h-11 items-center justify-center text-[13px] font-semibold text-[var(--primary)]">
|
||||
{t("bell.showAll")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { saveNotificationPreferences, type PreferencesState } from "@/server/actions/notifications/preferences";
|
||||
|
||||
export type PreferenceItem = { type: string; label: string; email: boolean; mandatory: boolean };
|
||||
|
||||
/** E-mail opt-out per notification type (in-app notifications are always on). */
|
||||
export function NotificationPreferencesForm({
|
||||
items,
|
||||
labels,
|
||||
}: {
|
||||
items: PreferenceItem[];
|
||||
labels: { email: string; mandatory: string; save: string; saved: string; error: string };
|
||||
}) {
|
||||
const [state, action, pending] = useActionState(saveNotificationPreferences, { status: "idle" } as PreferencesState);
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-3">
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{items.map((item) => {
|
||||
const id = `pref-${item.type}`;
|
||||
return (
|
||||
<li key={item.type} className="flex min-h-11 items-center justify-between gap-3 px-3 py-1.5">
|
||||
<label htmlFor={id} className="flex-1 text-[13px]">
|
||||
{item.label}
|
||||
{item.mandatory && <span className="block text-[11.5px] text-muted-foreground">{item.mandatory ? labels.mandatory : null}</span>}
|
||||
</label>
|
||||
<span className="flex items-center gap-2 text-[12px] text-muted-foreground">
|
||||
{labels.email}
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
name="emailOn"
|
||||
value={item.type}
|
||||
defaultChecked={item.email}
|
||||
disabled={item.mandatory}
|
||||
className="size-5 accent-[var(--primary)]"
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" disabled={pending} className="min-h-11">
|
||||
{labels.save}
|
||||
</Button>
|
||||
<p role="status" className="text-[12.5px]">
|
||||
{state.status === "saved" && <span className="text-[var(--ok)]">✓ {labels.saved}</span>}
|
||||
{state.status === "error" && <span className="text-[var(--risk)]">⚠ {labels.error}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getPreferences } from "@/server/services/notifications/preferences";
|
||||
import { isNotificationsModuleEnabled, pageCtx } from "@/server/services/notifications/page-ctx";
|
||||
import { eventKey } from "@/server/services/notifications/texts";
|
||||
import { NotificationPreferencesForm } from "./preferences-form";
|
||||
|
||||
/** "Benachrichtigungen" section of /account (self-contained: loads its own data). */
|
||||
export async function NotificationPreferencesSection() {
|
||||
const ctx = await pageCtx();
|
||||
if (!ctx.permissions.has("notification:read") || !(await isNotificationsModuleEnabled(ctx))) return null;
|
||||
const [prefs, t, tc] = await Promise.all([getPreferences(ctx), getTranslations("notifications"), getTranslations("common")]);
|
||||
|
||||
return (
|
||||
<section className="shadow-card mt-5 rounded-xl border bg-card p-5" aria-labelledby="notification-preferences">
|
||||
<p id="notification-preferences" className="mb-1 font-heading text-sm font-semibold">
|
||||
{t("preferences.title")}
|
||||
</p>
|
||||
<p className="mb-3 text-[12.5px] text-muted-foreground">{t("preferences.sub")}</p>
|
||||
<NotificationPreferencesForm
|
||||
items={prefs.map((p) => ({ type: p.type, label: t(`types.${eventKey(p.type)}`), email: p.email, mandatory: p.mandatory }))}
|
||||
labels={{
|
||||
email: t("preferences.email"),
|
||||
mandatory: t("preferences.mandatory"),
|
||||
save: t("preferences.save"),
|
||||
saved: t("preferences.saved"),
|
||||
error: tc("readOnly"),
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user