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,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { deleteCustomerAccount, getSessionCustomer, loginCustomer, clearSessionCookie } from "../../../lib/customerAuth";
|
||||
|
||||
// GDPR self-service deletion. Password re-verified here (not just trusting
|
||||
// the active session) before anything is deleted — same reasoning as
|
||||
// changeCustomerPassword. See customerAuth.ts's deleteCustomerAccount for
|
||||
// what actually survives (past orders, anonymized-by-omission — their own
|
||||
// snapshot fields aren't touched, only the account/login disappears).
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const password = typeof body?.password === "string" ? body.password : "";
|
||||
if (!password) return NextResponse.json({ ok: false, reason: "Bitte dein Passwort zur Bestätigung eingeben." }, { status: 400 });
|
||||
|
||||
const verify = await loginCustomer({ email: session.customer.email, password });
|
||||
if (!verify.ok) return NextResponse.json({ ok: false, reason: "Passwort ist falsch." }, { status: 400 });
|
||||
|
||||
const deleted = await deleteCustomerAccount(verify.token, session.customer.id);
|
||||
if (!deleted) return NextResponse.json({ ok: false, reason: "Konto konnte nicht gelöscht werden." }, { status: 500 });
|
||||
|
||||
await clearSessionCookie();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, getCustomerProfile, getCustomerOrders, getCustomerOrderDetail } from "../../../lib/customerAuth";
|
||||
|
||||
// GDPR data portability (Art. 20) — a full, structured, machine-readable
|
||||
// export of everything tied to the account: profile + every order's full
|
||||
// detail (not just the summary list, so this is a genuinely complete
|
||||
// export, not a teaser).
|
||||
export async function GET() {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const profile = await getCustomerProfile(session.token);
|
||||
const orderSummaries = await getCustomerOrders(session.token, session.customer.id);
|
||||
const orders = await Promise.all(
|
||||
orderSummaries.map((o) => getCustomerOrderDetail(session.token, session.customer.id, o.orderNumber)),
|
||||
);
|
||||
|
||||
const payload = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
profile,
|
||||
orders: orders.filter(Boolean),
|
||||
};
|
||||
|
||||
return new NextResponse(JSON.stringify(payload, null, 2), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": 'attachment; filename="meine-daten.json"',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { loginCustomer, setSessionCookie } from "../../../lib/customerAuth";
|
||||
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Complements Payload's own per-account lockout (5 attempts / 10min,
|
||||
// see Customers.ts in the Payload repo) with a per-IP layer — that one
|
||||
// alone doesn't stop someone spraying single attempts across many
|
||||
// different email addresses from the same IP.
|
||||
if (!checkRateLimit(`login:${getClientIp(request)}`, { limit: 10, windowMs: 15 * 60 * 1000 })) {
|
||||
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const email = typeof body?.email === "string" ? body.email : "";
|
||||
const password = typeof body?.password === "string" ? body.password : "";
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getSessionCustomer,
|
||||
getCustomerOrderDetail,
|
||||
requestOrderStatusChange,
|
||||
customerOrderAction,
|
||||
} from "../../../../lib/customerAuth";
|
||||
|
||||
// The real security boundary is Orders.ts's beforeChange hook in Payload
|
||||
// (only `status` can change, only via an allowed transition) — the check
|
||||
// against customerOrderAction() here is just for a friendlier error
|
||||
// message than a bare 403 when the button's already stale (e.g. two tabs
|
||||
// open, order shipped in the meantime).
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ orderNumber: string }> }) {
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
const { orderNumber } = await params;
|
||||
const body = await request.json().catch(() => null);
|
||||
const action = body?.action;
|
||||
if (action !== "cancel" && action !== "request-return") {
|
||||
return NextResponse.json({ ok: false, reason: "Ungültige Aktion." }, { status: 400 });
|
||||
}
|
||||
|
||||
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
|
||||
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung nicht gefunden." }, { status: 404 });
|
||||
if (customerOrderAction(order.status) !== action) {
|
||||
return NextResponse.json({ ok: false, reason: "Diese Aktion ist für diese Bestellung gerade nicht möglich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await requestOrderStatusChange(session.token, order.id, action);
|
||||
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { changeCustomerPassword, getSessionCustomer } from "../../../lib/customerAuth";
|
||||
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!checkRateLimit(`password:${getClientIp(request)}`, { limit: 5, windowMs: 15 * 60 * 1000 })) {
|
||||
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
|
||||
}
|
||||
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { registerCustomer, setSessionCookie } from "../../../lib/customerAuth";
|
||||
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!checkRateLimit(`register:${getClientIp(request)}`, { limit: 5, windowMs: 15 * 60 * 1000 })) {
|
||||
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const { firstName, lastName, email, password } = body ?? {};
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionCustomer, resendVerificationEmail } from "../../../lib/customerAuth";
|
||||
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!checkRateLimit(`resend-verification:${getClientIp(request)}`, { limit: 3, windowMs: 15 * 60 * 1000 })) {
|
||||
return NextResponse.json({ ok: false, reason: "Zu viele Versuche. Bitte in ein paar Minuten erneut probieren." }, { status: 429 });
|
||||
}
|
||||
|
||||
const session = await getSessionCustomer();
|
||||
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
|
||||
if (session.customer.emailVerified) return NextResponse.json({ ok: true });
|
||||
|
||||
const ok = await resendVerificationEmail(session);
|
||||
return NextResponse.json({ ok }, { status: ok ? 200 : 500 });
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { verifyEmailByToken } from "../../../lib/customerAuth";
|
||||
|
||||
// Entered from the link in the verification email — no session exists
|
||||
// yet at this point. See Customers.ts's own comment on why this is a
|
||||
// non-blocking flag (login already works before this is ever clicked).
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.nextUrl.searchParams.get("token");
|
||||
if (!token) return new Response("Ungültiger Link.", { status: 400 });
|
||||
|
||||
const ok = await verifyEmailByToken(token);
|
||||
const url = new URL("/konto/profil", request.url);
|
||||
url.searchParams.set("verified", ok ? "1" : "0");
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { validateDiscountCode, redeemDiscountCode } from "../../lib/discountServ
|
||||
import { createOrder } from "../../lib/orderServer";
|
||||
import { getSessionCustomer, registerCustomer, setSessionCookie, type CustomerSummary } from "../../lib/customerAuth";
|
||||
import { fetchProductsBySlug } from "../../lib/productsServer";
|
||||
import { sendCriticalAlert } from "../../lib/alertAdmin";
|
||||
|
||||
type CheckoutBody = {
|
||||
cart: CartItem[];
|
||||
@@ -137,7 +138,21 @@ export async function POST(request: Request) {
|
||||
discountAmount,
|
||||
total,
|
||||
});
|
||||
if (!order) return NextResponse.json({ ok: false, reason: "Bestellung konnte nicht gespeichert werden." }, { status: 500 });
|
||||
if (!order) {
|
||||
// The worst-case failure in this whole flow: the customer went
|
||||
// through checkout believing they bought something, and nothing was
|
||||
// persisted. Kuma's uptime checks can't see this (the site is up,
|
||||
// this route just returned a 500) — this is the one alert path that
|
||||
// can.
|
||||
sendCriticalAlert("Bestellung konnte nicht gespeichert werden", {
|
||||
customerId: customer.id,
|
||||
customerEmail: body.email,
|
||||
cart: body.cart,
|
||||
total,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return NextResponse.json({ ok: false, reason: "Bestellung konnte nicht gespeichert werden." }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
|
||||
// Deliberately checks Payload connectivity, not just "did this route
|
||||
// handler run" — the site can return 200s from every static/ISR page
|
||||
// while Payload itself is unreachable (stale cached content masks it for
|
||||
// a while). Meant for a Kuma HTTP monitor, added to the existing "Content
|
||||
// & API" group alongside the direct Payload monitors (see ~/dev/README.md).
|
||||
export async function GET() {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/posts?limit=1`, { signal: controller.signal, cache: "no-store" });
|
||||
clearTimeout(timeout);
|
||||
if (!res.ok) return NextResponse.json({ ok: false, payload: false }, { status: 503 });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, payload: false }, { status: 503 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user