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:
1 parent
4ab1a7edae
commit
dcfad4e011
27 files changed
+1049
-34
No files matched your search
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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"}.`);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,9 @@ export async function GET(request: Request) {
|
||||
|
||||
await prisma.newsletterSubscriber.update({
|
||||
where: { id: subscriber.id },
|
||||
data: { confirmed: true, unsubscribedAt: null },
|
||||
// confirmedAt belegt die Einwilligung (Art. 7 Abs. 1 DSGVO). Jede erneute
|
||||
// Bestaetigung ueberschreibt ihn, weil erst sie die aktuelle Einwilligung ist.
|
||||
data: { confirmed: true, confirmedAt: new Date(), unsubscribedAt: null },
|
||||
});
|
||||
await ensureNewsletterDiscountCode();
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { withdrawalSchema } from "@/lib/validation";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { isTrustedOrigin } from "@/lib/csrf";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { withdrawalReceiptEmail } from "@/lib/email-templates";
|
||||
import { escapeHtml, formatReceivedAt, withdrawalDeclarationLines } from "@/lib/withdrawal";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
|
||||
const WITHDRAWALS_PER_WINDOW = 10;
|
||||
const WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Elektronische Widerrufsfunktion (§ 356a BGB), zweite Stufe "Widerruf
|
||||
* bestaetigen". Bewusst OHNE Turnstile: eine fehlschlagende Pruefung duerfte
|
||||
* die Ausuebung eines gesetzlichen Rechts nicht verhindern. Missbrauch
|
||||
* begrenzen Herkunftspruefung und Rate-Limit.
|
||||
*
|
||||
* Die Antwort ist fuer passende und unpassende Bestellnummern identisch —
|
||||
* sonst liessen sich Bestellnummern samt E-Mail-Adresse durchprobieren. Die
|
||||
* Erklaerung wird in jedem Fall gespeichert: sie ist mit dem Zugang wirksam
|
||||
* abgegeben, die Zuordnung zur Bestellung ist unsere Aufgabe (Admin).
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
if (!isTrustedOrigin(request)) {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`withdrawal:${ip}`, WITHDRAWALS_PER_WINDOW, WINDOW_MS)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Zu viele Anfragen. Bitte versuche es später erneut oder schreib uns eine E-Mail." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const body: unknown = await request.json().catch(() => null);
|
||||
const parsed = withdrawalSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bitte überprüfe deine Angaben.", issues: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
const locale = await getLocale();
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { orderNumber: input.orderNumber },
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
const orderId = order && order.email.trim().toLowerCase() === input.email ? order.id : null;
|
||||
|
||||
const declaration = await prisma.withdrawalDeclaration.create({
|
||||
data: { ...input, locale, orderId },
|
||||
});
|
||||
|
||||
const receipt = await withdrawalReceiptEmail(input, declaration.receivedAt, locale);
|
||||
const confirmationSent = await sendMail({ to: input.email, ...receipt });
|
||||
if (confirmationSent) {
|
||||
await prisma.withdrawalDeclaration.update({
|
||||
where: { id: declaration.id },
|
||||
data: { confirmationSentAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
// Interne Benachrichtigung: ein Widerruf hat eine 14-Tage-Frist fuer die
|
||||
// Erstattung und darf nicht bis zum naechsten Blick ins Admin liegen bleiben.
|
||||
const adminEmail = process.env.ADMIN_EMAIL;
|
||||
if (adminEmail) {
|
||||
const noticeLines = [
|
||||
...withdrawalDeclarationLines(input, "de"),
|
||||
"",
|
||||
`Eingegangen am: ${formatReceivedAt(declaration.receivedAt, "de")}`,
|
||||
orderId ? "Automatisch der Bestellung zugeordnet." : "Keine passende Bestellung gefunden — bitte manuell prüfen.",
|
||||
`Eingangsbestätigung an Kundschaft: ${confirmationSent ? "versendet" : "NICHT versendet"}`,
|
||||
];
|
||||
await sendMail({
|
||||
to: adminEmail,
|
||||
subject: `Neuer Widerruf zu ${input.orderNumber.replace(/[\r\n]+/g, " ")}`,
|
||||
text: noticeLines.join("\n"),
|
||||
html: `<p style="font-family:Arial,sans-serif;line-height:1.6;">${noticeLines.map(escapeHtml).join("<br>")}</p>`,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
receivedAt: formatReceivedAt(declaration.receivedAt, locale),
|
||||
lines: withdrawalDeclarationLines(input, locale),
|
||||
confirmationSent,
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
}
|
||||
@@ -20,11 +20,12 @@ export async function toggleNewsletterAction(): Promise<void> {
|
||||
} else if (existing) {
|
||||
await prisma.newsletterSubscriber.update({
|
||||
where: { id: existing.id },
|
||||
data: { unsubscribedAt: null, confirmed: true },
|
||||
// Der Klick im eigenen Konto ist die Einwilligung — Zeitpunkt als Nachweis.
|
||||
data: { unsubscribedAt: null, confirmed: true, confirmedAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
await prisma.newsletterSubscriber.create({
|
||||
data: { email: customer.email, customerId: customer.id, confirmed: true },
|
||||
data: { email: customer.email, customerId: customer.id, confirmed: true, confirmedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,17 @@ export default async function KontoBestellungenPage() {
|
||||
{t.ordersPage.requestReturn}
|
||||
</Link>
|
||||
) : null}
|
||||
{RETURN_ELIGIBLE_STATUSES.includes(order.status) ? (
|
||||
// Elektronische Widerrufsfunktion (§ 356a BGB) mit
|
||||
// vorausgefuellter Bestellnummer — die Rueckgabe oben ist
|
||||
// ein Service, der Widerruf das gesetzliche Recht.
|
||||
<Link
|
||||
href={`/vertrag-widerrufen?bestellung=${encodeURIComponent(order.orderNumber)}`}
|
||||
className="text-sm text-heading underline"
|
||||
>
|
||||
{t.withdrawalPage.footerButton}
|
||||
</Link>
|
||||
) : null}
|
||||
<ReorderButton
|
||||
items={order.items.map((item) => ({
|
||||
productId: item.productId,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading, FormPanel } from "@/components/ui";
|
||||
import { WithdrawalForm } from "@/components/withdrawal-form";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Vertrag widerrufen", alternates: { canonical: "/vertrag-widerrufen" } };
|
||||
|
||||
/** Nur Zeichen, die in Bestellnummern vorkommen — alles andere wird nicht vorausgefuellt. */
|
||||
const ORDER_NUMBER_PREFILL = /^[A-Za-z0-9-]{1,40}$/;
|
||||
|
||||
/**
|
||||
* Elektronische Widerrufsfunktion (§ 356a BGB). Ohne Anmeldung erreichbar —
|
||||
* auch Gastbestellungen muessen hier widerrufen werden koennen. Aus dem
|
||||
* Kundenkonto kommt man mit ?bestellung=… und vorausgefuellter Nummer.
|
||||
*/
|
||||
export default async function VertragWiderrufenPage(props: PageProps<"/vertrag-widerrufen">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { bestellung } = await props.searchParams;
|
||||
const prefill = typeof bestellung === "string" && ORDER_NUMBER_PREFILL.test(bestellung) ? bestellung : "";
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-xl">
|
||||
<FormPanel>
|
||||
<SectionHeading title={t.withdrawalPage.title} level="h1" />
|
||||
<p className="mt-6 text-sm leading-relaxed text-ink/75">{t.withdrawalPage.intro}</p>
|
||||
<div className="mt-8">
|
||||
<WithdrawalForm defaultOrderNumber={prefill} />
|
||||
</div>
|
||||
</FormPanel>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { LegalPage } from "@/components/legal-page";
|
||||
import { getContentValue, parseSections } from "@/lib/content";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { getSiteUrl } from "@/lib/url";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
@@ -14,12 +15,16 @@ export const metadata: Metadata = { title: "Widerrufsbelehrung", alternates: { c
|
||||
* einem Umzug oder neuer Adresse automatisch richtig.
|
||||
*/
|
||||
const PROVIDER_PLACEHOLDER = "{{anbieter}}";
|
||||
/** Adresse der elektronischen Widerrufsfunktion (§ 356a BGB, app/vertrag-widerrufen). */
|
||||
const WITHDRAWAL_FUNCTION_PLACEHOLDER = "{{widerrufsfunktion}}";
|
||||
|
||||
export default async function WiderrufPage() {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const [company, raw] = await Promise.all([getCompanyInfo(), getContentValue("legal.widerruf", locale)]);
|
||||
const provider = `${company.name}, ${company.address.street}, ${company.address.zip} ${company.address.city}, E-Mail: ${company.email}`;
|
||||
const sections = parseSections(raw.replaceAll(PROVIDER_PLACEHOLDER, provider));
|
||||
const sections = parseSections(
|
||||
raw.replaceAll(PROVIDER_PLACEHOLDER, provider).replaceAll(WITHDRAWAL_FUNCTION_PLACEHOLDER, `${getSiteUrl()}/vertrag-widerrufen`)
|
||||
);
|
||||
return <LegalPage title={t.widerrufPage.title} noticeText={t.legalPage.reviewNotice} sections={sections} />;
|
||||
}
|
||||
Reference in new issue
Block a user