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:
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user