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:
+112
-4
@@ -1,5 +1,7 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { CartItem } from "./cart";
|
||||
import { sendVerificationEmail } from "./alertAdmin";
|
||||
|
||||
// Server-only — imported by app/api/account/*/route.ts, app/api/checkout/
|
||||
// route.ts, and the /checkout and /konto/* Server Components. Never touch
|
||||
@@ -12,6 +14,13 @@ import type { CartItem } from "./cart";
|
||||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||||
const TENANT_SLUG = "einfach-produktiv";
|
||||
const SESSION_COOKIE = "ep_customer_token";
|
||||
// Reused from the order-creation service call (see orderServer.ts) for
|
||||
// the handful of customer-collection operations that legitimately have no
|
||||
// customer session of their own yet — the email-verification link click
|
||||
// (cold, from an email client) being the main one. Same trust level
|
||||
// ("this app's own backend acting on its own behalf"), so a third secret
|
||||
// felt like unnecessary sprawl rather than added security.
|
||||
const SERVICE_SECRET = process.env.ORDER_SERVICE_SECRET || "";
|
||||
|
||||
async function resolveTenantId(): Promise<number | null> {
|
||||
const params = new URLSearchParams({ "where[slug][equals]": TENANT_SLUG, limit: "1" });
|
||||
@@ -27,6 +36,7 @@ export type CustomerSummary = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
};
|
||||
|
||||
export type AuthResult = { ok: true; token: string; customer: CustomerSummary } | { ok: false; reason: string };
|
||||
@@ -62,8 +72,10 @@ export async function loginCustomer(input: { email: string; password: string }):
|
||||
});
|
||||
if (!res.ok) return { ok: false, reason: "E-Mail-Adresse oder Passwort ist falsch." };
|
||||
|
||||
const data: { token: string; user: { id: number; customerNumber: string; firstName: string; lastName: string; email: string } } =
|
||||
await res.json();
|
||||
const data: {
|
||||
token: string;
|
||||
user: { id: number; customerNumber: string; firstName: string; lastName: string; email: string; emailVerified: boolean };
|
||||
} = await res.json();
|
||||
return {
|
||||
ok: true,
|
||||
token: data.token,
|
||||
@@ -73,6 +85,7 @@ export async function loginCustomer(input: { email: string; password: string }):
|
||||
firstName: data.user.firstName,
|
||||
lastName: data.user.lastName,
|
||||
email: data.user.email,
|
||||
emailVerified: data.user.emailVerified,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -83,8 +96,9 @@ export async function getCustomerFromToken(token: string): Promise<CustomerSumma
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data: { user: { id: number; customerNumber: string; firstName: string; lastName: string; email: string } | null } =
|
||||
await res.json();
|
||||
const data: {
|
||||
user: { id: number; customerNumber: string; firstName: string; lastName: string; email: string; emailVerified: boolean } | null;
|
||||
} = await res.json();
|
||||
if (!data.user) return null;
|
||||
return {
|
||||
id: data.user.id,
|
||||
@@ -92,6 +106,7 @@ export async function getCustomerFromToken(token: string): Promise<CustomerSumma
|
||||
firstName: data.user.firstName,
|
||||
lastName: data.user.lastName,
|
||||
email: data.user.email,
|
||||
emailVerified: data.user.emailVerified,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +128,7 @@ type PayloadCustomerMe = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
deliveryMethod: "address" | "packstation" | null;
|
||||
street: string | null;
|
||||
packstationNumber: string | null;
|
||||
@@ -138,6 +154,7 @@ export async function getCustomerProfile(token: string): Promise<CustomerProfile
|
||||
firstName: u.firstName,
|
||||
lastName: u.lastName,
|
||||
email: u.email,
|
||||
emailVerified: u.emailVerified,
|
||||
deliveryMethod: u.deliveryMethod,
|
||||
street: u.street,
|
||||
packstationNumber: u.packstationNumber,
|
||||
@@ -193,6 +210,61 @@ export async function changeCustomerPassword(
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Called from app/api/account/verify-email/route.ts — no customer session
|
||||
// exists at this point (cold click from an email client), so this
|
||||
// authenticates as the service instead (see SERVICE_SECRET above).
|
||||
export async function verifyEmailByToken(token: string): Promise<boolean> {
|
||||
const params = new URLSearchParams({ "where[emailVerificationToken][equals]": token, limit: "1" });
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/customers?${params}`, {
|
||||
headers: { "x-order-service-secret": SERVICE_SECRET },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data: { docs?: { id: number; emailVerificationExpires: string | null }[] } = await res.json();
|
||||
const doc = data.docs?.[0];
|
||||
if (!doc) return false;
|
||||
if (doc.emailVerificationExpires && new Date(doc.emailVerificationExpires).getTime() < Date.now()) return false;
|
||||
|
||||
const patchRes = await fetch(`${PAYLOAD_URL}/api/customers/${doc.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "x-order-service-secret": SERVICE_SECRET, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ emailVerified: true }),
|
||||
});
|
||||
return patchRes.ok;
|
||||
}
|
||||
|
||||
// Called by an already-logged-in customer (app/api/account/resend-
|
||||
// verification/route.ts) — updates the token via their own session (self-
|
||||
// update access, see Customers.ts), then sends the mail directly (no
|
||||
// Payload afterChange hook to piggyback on for a plain update — that hook
|
||||
// only fires on create, see Customers.ts's own comment).
|
||||
export async function resendVerificationEmail(session: { token: string; customer: CustomerSummary }): Promise<boolean> {
|
||||
const newToken = randomUUID();
|
||||
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/customers/${session.customer.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { Authorization: `JWT ${session.token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ emailVerificationToken: newToken, emailVerificationExpires: expires }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
await sendVerificationEmail(session.customer.email, session.customer.firstName, newToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Self-service GDPR deletion (app/api/account/delete/route.ts) — password
|
||||
// re-verification happens there via loginCustomer() before this is ever
|
||||
// called. orders.customer is ON DELETE SET NULL (see the Payload
|
||||
// migration) — past orders keep their own name/address/items snapshot for
|
||||
// tax-retention purposes (§147 AO / GDPR Art. 17(3)(b)), only the account
|
||||
// itself disappears.
|
||||
export async function deleteCustomerAccount(token: string, customerId: number): Promise<boolean> {
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `JWT ${token}` },
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
export async function getServerCart(token: string): Promise<CartItem[]> {
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/customers/me`, {
|
||||
headers: { Authorization: `JWT ${token}` },
|
||||
@@ -223,8 +295,21 @@ export const ORDER_STATUS_LABEL: Record<string, string> = {
|
||||
processing: "In Bearbeitung",
|
||||
shipped: "Versandt",
|
||||
delivered: "Zugestellt",
|
||||
cancelled: "Storniert",
|
||||
return_requested: "Rücksendung angefragt",
|
||||
returned: "Zurückgesendet",
|
||||
};
|
||||
|
||||
// Which self-service action is available given the order's current
|
||||
// status — mirrors CUSTOMER_ALLOWED_TRANSITIONS in Orders.ts exactly
|
||||
// (that hook is the real security boundary; this is just so the UI can
|
||||
// decide which button, if any, to show).
|
||||
export function customerOrderAction(status: string): "cancel" | "request-return" | null {
|
||||
if (status === "received") return "cancel";
|
||||
if (status === "shipped" || status === "delivered") return "request-return";
|
||||
return null;
|
||||
}
|
||||
|
||||
export type CustomerOrder = {
|
||||
orderNumber: string;
|
||||
createdAt: string;
|
||||
@@ -257,6 +342,7 @@ export async function getCustomerOrders(token: string, customerId: number): Prom
|
||||
}
|
||||
|
||||
export type CustomerOrderDetail = CustomerOrder & {
|
||||
id: number;
|
||||
customerFirstName: string;
|
||||
customerLastName: string;
|
||||
customerEmail: string;
|
||||
@@ -298,6 +384,28 @@ export async function getCustomerOrderDetail(token: string, customerId: number,
|
||||
return { ...doc, itemCount: doc.items.length };
|
||||
}
|
||||
|
||||
// Called from app/api/account/orders/[orderNumber]/route.ts. Security
|
||||
// lives in Orders.ts's beforeChange hook (only `status` can change, and
|
||||
// only via an allowed transition) — this is just the authenticated call;
|
||||
// a request the hook rejects comes back as a non-ok response here.
|
||||
export async function requestOrderStatusChange(
|
||||
token: string,
|
||||
orderId: number,
|
||||
action: "cancel" | "request-return",
|
||||
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
||||
const status = action === "cancel" ? "cancelled" : "return_requested";
|
||||
const res = await fetch(`${PAYLOAD_URL}/api/orders/${orderId}`, {
|
||||
method: "PATCH",
|
||||
headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
return { ok: false, reason: data?.errors?.[0]?.message ?? "Aktion war nicht möglich." };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Cookie helpers — Next.js's async cookies() API (Next 15+), usable in
|
||||
// Route Handlers (read/write) and Server Components (read-only).
|
||||
export async function setSessionCookie(token: string) {
|
||||
|
||||
Reference in New Issue
Block a user