Add rate limiting, sliding sessions, email verification, GDPR self-service, order cancellation/returns, and critical-error alerting

Complements Payload's per-account login lockout with per-IP rate limiting
on auth routes; proxy.ts silently refreshes an active customer's session
via Payload's built-in refresh-token endpoint instead of a long-lived
token. Registration now sends a non-blocking email-verification link
(doesn't gate login, since checkout registers and immediately logs in
mid-purchase). /konto/profil gets GDPR export/delete; order detail pages
get self-service cancel/return-request, backed by a Payload hook that
closes a real gap (a customer's JWT could previously PATCH any field of
their own order, not just status). Checkout failures now email an alert
independent of Payload's own health, since Kuma's uptime checks can't see
an order silently failing to persist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-22 07:28:01 +00:00
parent 7f37f111e8
commit df05ea5358
22 changed files with 822 additions and 20 deletions
@@ -0,0 +1,53 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
const LABEL = { cancel: "Bestellung stornieren", "request-return": "Rücksendung anfragen" } as const;
const CONFIRM = {
cancel: "Bestellung wirklich stornieren?",
"request-return": "Rücksendung wirklich anfragen? Wir melden uns mit den nächsten Schritten.",
} as const;
export function OrderActionButton({ orderNumber, action }: { orderNumber: string; action: "cancel" | "request-return" }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleClick() {
if (!window.confirm(CONFIRM[action])) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/account/orders/${encodeURIComponent(orderNumber)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
});
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Aktion war nicht möglich.");
setLoading(false);
return;
}
router.refresh();
} catch {
setError("Aktion war gerade nicht möglich.");
setLoading(false);
}
}
return (
<div className="flex flex-col gap-2 items-start">
<button
type="button"
onClick={handleClick}
disabled={loading}
className={`px-5 py-3 rounded-sm border border-border hover:border-brand font-bold text-body-sm text-text-primary transition-colors ${loading ? "opacity-70 pointer-events-none" : ""}`}
>
{loading ? "…" : LABEL[action]}
</button>
{error && <p className="text-label text-red-600">{error}</p>}
</div>
);
}
@@ -4,7 +4,8 @@ import Link from "next/link";
import { Reveal } from "../../../components/Reveal";
import { Footer } from "../../../components/Footer";
import { formatPrice, formatDate } from "../../../lib/format";
import { getSessionCustomer, getCustomerOrderDetail, ORDER_STATUS_LABEL } from "../../../lib/customerAuth";
import { getSessionCustomer, getCustomerOrderDetail, ORDER_STATUS_LABEL, customerOrderAction } from "../../../lib/customerAuth";
import { OrderActionButton } from "./components/OrderActionButton";
export const metadata: Metadata = {
title: "Bestelldetails",
@@ -23,6 +24,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
order.deliveryMethod === "address"
? order.street
: `Packstation ${order.packstationNumber} · Postnummer ${order.postNumber}`;
const action = customerOrderAction(order.status);
return (
<>
@@ -104,6 +106,8 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
<span className="font-bold text-h-small text-text-primary">{formatPrice(order.total)}</span>
</div>
</div>
{action && <OrderActionButton orderNumber={order.orderNumber} action={action} />}
</Reveal>
</main>
<Footer />
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Reveal } from "../../../components/Reveal";
const inputClass =
"w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors";
export function AccountDataSection() {
const router = useRouter();
const [confirming, setConfirming] = useState(false);
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
async function handleDelete(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setDeleting(true);
setError(null);
try {
const res = await fetch("/api/account/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Konto konnte nicht gelöscht werden.");
setDeleting(false);
return;
}
router.push("/");
router.refresh();
} catch {
setError("Konto konnte gerade nicht gelöscht werden.");
setDeleting(false);
}
}
return (
<Reveal className="flex flex-col gap-4 items-start w-full pt-4 border-t border-border">
<p className="font-semibold text-h-small text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Konto &amp; Daten
</p>
<a href="/api/account/export" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Meine Daten exportieren
</a>
{!confirming ? (
<button
type="button"
onClick={() => setConfirming(true)}
className="text-body-sm text-red-600 underline hover:text-red-700 transition-colors"
>
Konto löschen
</button>
) : (
<form onSubmit={handleDelete} className="flex flex-col gap-3 items-start w-full max-w-sm">
<p className="text-body-sm text-text-primary">
Dein Konto und deine gespeicherte Adresse werden gelöscht. Bereits aufgegebene Bestellungen bleiben aus
steuerrechtlichen Gründen mit ihren eigenen Daten erhalten, sind danach aber keinem Konto mehr zugeordnet.
</p>
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Passwort zur Bestätigung</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
className={inputClass}
/>
</label>
{error && <p className="text-label text-red-600">{error}</p>}
<div className="flex gap-3">
<button
type="submit"
disabled={deleting}
className={`px-5 py-3 rounded-sm bg-red-600 hover:bg-red-700 font-bold text-body-sm text-white transition-colors ${deleting ? "opacity-70 pointer-events-none" : ""}`}
>
{deleting ? "…" : "Konto endgültig löschen"}
</button>
<button type="button" onClick={() => setConfirming(false)} className="px-5 py-3 text-body-sm text-text-muted">
Abbrechen
</button>
</div>
</form>
)}
</Reveal>
);
}
@@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
export function VerificationBanner({ emailVerified, justVerified }: { emailVerified: boolean; justVerified: "1" | "0" | undefined }) {
const [sent, setSent] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
if (emailVerified) {
// Only shown right after clicking the link — not a persistent banner
// once verified, that would just be noise on every future visit.
if (justVerified === "1") {
return <p className="text-label text-success w-full">E-Mail-Adresse bestätigt.</p>;
}
return null;
}
async function handleResend() {
setSending(true);
setError(null);
try {
const res = await fetch("/api/account/resend-verification", { method: "POST" });
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Mail konnte nicht gesendet werden.");
setSending(false);
return;
}
setSent(true);
setSending(false);
} catch {
setError("Mail konnte gerade nicht gesendet werden.");
setSending(false);
}
}
return (
<div className="bg-bg-muted rounded-md p-4 flex flex-col gap-1 w-full">
<p className="text-body-sm text-text-primary">
{justVerified === "0"
? "Der Bestätigungslink ist ungültig oder abgelaufen."
: "Bitte bestätige deine E-Mail-Adresse."}{" "}
{!sent && (
<button type="button" onClick={handleResend} disabled={sending} className="underline font-bold hover:text-brand transition-colors">
{sending ? "…" : "Erneut senden"}
</button>
)}
{sent && <span className="text-success">Mail wurde erneut gesendet.</span>}
</p>
{error && <p className="text-label text-red-600">{error}</p>}
</div>
);
}
+11 -1
View File
@@ -4,25 +4,35 @@ import { Footer } from "../../components/Footer";
import { getSessionCustomer, getCustomerProfile } from "../../lib/customerAuth";
import { ProfileForm } from "./components/ProfileForm";
import { PasswordForm } from "./components/PasswordForm";
import { VerificationBanner } from "./components/VerificationBanner";
import { AccountDataSection } from "./components/AccountDataSection";
export const metadata: Metadata = {
title: "Mein Profil",
robots: { index: false, follow: true },
};
export default async function KontoProfilPage() {
export default async function KontoProfilPage({
searchParams,
}: {
searchParams: Promise<{ verified?: string }>;
}) {
const session = await getSessionCustomer();
if (!session) redirect("/konto/login");
const profile = await getCustomerProfile(session.token);
if (!profile) redirect("/konto/login");
const { verified } = await searchParams;
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<div className="flex flex-col gap-10 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[40rem] mx-auto">
<VerificationBanner emailVerified={profile.emailVerified} justVerified={verified === "1" || verified === "0" ? verified : undefined} />
<ProfileForm profile={profile} />
<PasswordForm email={profile.email} />
<AccountDataSection />
</div>
</main>
<Footer />