feat(legal): electronic withdrawal function and remaining compliance fixes

Withdrawal function (§ 356a BGB, mandatory since 19 June 2026):
- /vertrag-widerrufen: two-step flow "Vertrag widerrufen" ->
  "Widerruf bestätigen", no login, only name, order number and e-mail
  required. Prominent footer button on every page, prefilled link per
  order in the customer account.
- /api/widerruf stores every declaration (matched to the order only if
  order number and e-mail fit), mails a receipt with the declaration and
  the exact time of receipt, notifies ADMIN_EMAIL. Same response for
  matching and non-matching orders (no enumeration), origin check and
  rate limit instead of a CAPTCHA that could block a statutory right.
  User input is HTML-escaped and stripped of line breaks in headers.
- /admin/widerrufe lists declarations, open ones first, with an
  open-count badge and "mark as done".
- Withdrawal notice gains the statutory sentence on the online function.

Other fixes:
- Newsletter stores confirmedAt as proof of consent (Art. 7(1) GDPR).
- "Recently viewed" is only persisted with consent (§ 25(1) TDDDG);
  choosing "essential only" or withdrawing consent deletes it. The
  banner no longer promises audience-measurement cookies that do not
  exist.
- Admin rejects an invalid company e-mail; an obfuscated stored value
  falls back to the configured address so the legal notice stays valid.
- Privacy policy: Microsoft 365 as mail processor, log retention of at
  most 14 days, withdrawal-function processing, consent timestamps.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-26 11:14:02 +02:00
1 parent 4ab1a7edae
commit dcfad4e011
27 files changed
+1049 -34

No files matched your search

+13 -2
View File
@@ -6,7 +6,10 @@ import { logAdminAction } from "@/lib/audit-log";
import { CONTENT_REGISTRY } from "@/lib/content-registry";
import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
import { requireAdmin } from "./_shared";
import { succeed } from "@/lib/flash";
import { fail, succeed } from "@/lib/flash";
import { z } from "zod";
const COMPANY_EMAIL_KEY = "company.email";
/**
* Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte)
@@ -48,7 +51,15 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
if (def.type === "video" && valueDe !== "" && !isValidVideoUploadPath(valueDe)) {
continue;
}
updates.push({ key: def.key, valueDe, valueEn: null });
// Die Firmen-E-Mail steht im Impressum (§ 5 Abs. 1 Nr. 2 DDG verlangt
// eine direkt nutzbare Adresse), in der Widerrufsbelehrung und in
// mailto-Links. Eine verschleierte Schreibweise ("info [at] …") waere
// dort rechtlich angreifbar und technisch unbrauchbar — daher wird der
// ganze Speichervorgang abgelehnt statt still uebernommen.
if (def.key === COMPANY_EMAIL_KEY && !z.string().trim().email().safeParse(valueDe).success) {
return fail("/admin/inhalte", "Bitte gib als Firmen-E-Mail eine vollständige, gültige Adresse an (z. B. [email protected]). Sie steht im Impressum und muss direkt nutzbar sein.");
}
updates.push({ key: def.key, valueDe: def.key === COMPANY_EMAIL_KEY ? valueDe.trim() : valueDe, valueEn: null });
}
}
+4 -2
View File
@@ -49,14 +49,15 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
// Was gerade auf Bearbeitung wartet — erscheint als Zaehler in der
// Seitenleiste. Nur echte Arbeit: bezahlte, noch nicht versendete oder
// abgeholte Bestellungen (keine offenen Zahlungen), neue Retouren,
// unfreigegebene Bewertungen, neue Firmenanfragen. Reine COUNT-Abfragen.
// offene Widerrufe, unfreigegebene Bewertungen, neue Firmenanfragen. Reine COUNT-Abfragen.
const brand = await getBrandAssets();
const themeChoice = parseThemeChoice((await cookies()).get(THEME_COOKIE)?.value);
const [openOrders, openReturns, pendingReviews, newInquiries] = await Promise.all([
const [openOrders, openReturns, openWithdrawals, pendingReviews, newInquiries] = await Promise.all([
prisma.order.count({
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
}),
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
prisma.withdrawalDeclaration.count({ where: { processedAt: null } }),
prisma.review.count({ where: { isApproved: false } }),
prisma.businessInquiry.count({ where: { status: "NEU" } }),
]);
@@ -69,6 +70,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
badges={{
"/admin/bestellungen": openOrders,
"/admin/retouren": openReturns,
"/admin/widerrufe": openWithdrawals,
"/admin/bewertungen": pendingReviews,
"/admin/anfragen": newInquiries,
}}
+34
View File
@@ -0,0 +1,34 @@
"use server";
import { revalidatePath } from "next/cache";
import { fail, succeed } from "@/lib/flash";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/app/admin/actions";
import { logAdminAction } from "@/lib/audit-log";
const PATH = "/admin/widerrufe";
/** Markiert eine Widerrufserklaerung als erledigt bzw. oeffnet sie wieder. Die Erklaerung selbst bleibt unveraendert — sie ist Beleg. */
export async function setWithdrawalProcessedAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = String(formData.get("id") ?? "").trim();
const processed = formData.get("processed") === "1";
const declaration = await prisma.withdrawalDeclaration.findUnique({ where: { id }, select: { orderNumber: true } });
if (!declaration) return fail(PATH, "Dieser Widerruf existiert nicht mehr.");
await prisma.withdrawalDeclaration.update({
where: { id },
data: { processedAt: processed ? new Date() : null },
});
await logAdminAction(admin, {
action: processed ? "withdrawal.processed" : "withdrawal.reopened",
entityType: "WithdrawalDeclaration",
entityId: id,
detail: declaration.orderNumber,
});
revalidatePath(PATH);
revalidatePath("/admin", "layout");
return succeed(PATH, `Widerruf zu ${declaration.orderNumber} ${processed ? "als erledigt markiert" : "wieder geöffnet"}.`);
}
+83
View File
@@ -0,0 +1,83 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { Button } from "@/components/ui";
import { AdminBadge, AdminEmptyState, AdminPageHeader } from "@/components/admin-list";
import { formatDate } from "@/lib/format";
import { formatReceivedAt } from "@/lib/withdrawal";
import { setWithdrawalProcessedAction } from "./actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
const MAX_ROWS = 200;
export default async function AdminWiderrufePage() {
const locale = await getLocale();
const t = getDictionary(locale);
const s = t.adminWithdrawalsPage;
// Offene zuerst (aelteste offene oben: deren Frist laeuft zuerst ab), dann erledigte.
const [open, done] = await Promise.all([
prisma.withdrawalDeclaration.findMany({ where: { processedAt: null }, orderBy: { receivedAt: "asc" }, take: MAX_ROWS }),
prisma.withdrawalDeclaration.findMany({ where: { processedAt: { not: null } }, orderBy: { receivedAt: "desc" }, take: MAX_ROWS }),
]);
const declarations = [...open, ...done];
return (
<div>
<BackLink href="/admin" label={s.backToDashboard} />
<AdminPageHeader title={s.title} />
<p className="mb-8 max-w-2xl text-sm text-ink/70">{s.intro}</p>
{declarations.length === 0 ? <AdminEmptyState message={s.empty} /> : null}
<div className="space-y-4">
{declarations.map((d) => (
<div key={d.id} className="border border-ink/10 bg-background p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-heading">{d.orderNumber}</p>
<p className="text-sm text-ink/70">
{d.name} · {d.email}
</p>
<p className="mt-1 text-xs tabular-nums text-ink/65">
{s.receivedAt}: {formatReceivedAt(d.receivedAt, locale)}
</p>
</div>
<AdminBadge tone={d.processedAt ? "positive" : "pending"} pulse={!d.processedAt}>
{d.processedAt ? s.statusProcessed : s.statusOpen}
</AdminBadge>
</div>
{d.message ? <p className="mt-4 whitespace-pre-line text-sm text-ink/80">{d.message}</p> : null}
<ul className="mt-4 space-y-1 text-xs">
<li className={d.orderId ? "text-ink/70" : "text-red-700 dark:text-red-300"}>
{d.orderId ? s.matched : s.unmatched}
</li>
<li className={d.confirmationSentAt ? "text-ink/70" : "text-red-700 dark:text-red-300"}>
{d.confirmationSentAt ? s.mailSent : s.mailNotSent}
</li>
{d.processedAt ? <li className="text-ink/70">{s.processedOn(formatDate(d.processedAt))}</li> : null}
</ul>
<div className="mt-5 flex flex-wrap items-center gap-4">
<form action={setWithdrawalProcessedAction}>
<input type="hidden" name="id" value={d.id} />
<input type="hidden" name="processed" value={d.processedAt ? "0" : "1"} />
<Button type="submit" variant={d.processedAt ? "ghost" : "primary"} className="px-4 py-2 text-xs">
{d.processedAt ? s.reopen : s.markProcessed}
</Button>
</form>
{d.orderId ? (
<Link href={`/admin/bestellungen/${d.orderId}`} className="link-draw pb-0.5 text-sm text-heading">
{s.viewOrder}
</Link>
) : null}
</div>
</div>
))}
</div>
</div>
);
}