Files
einfach-produktiv/app/lib/customerAuth.ts
T
Marco df05ea5358 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>
2026-07-22 07:28:01 +00:00

441 lines
16 KiB
TypeScript

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
// Payload's own auth cookie directly: Payload (payload.mk360.de) and this
// app (einfach-produktiv.mk360.de) are different origins, so instead this
// app mints its OWN httpOnly cookie holding the JWT Payload issued, and
// simply forwards that token as an Authorization header on every
// subsequent Payload call — no shared-domain cookie config, no CORS setup
// needed on the Payload side.
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" });
const res = await fetch(`${PAYLOAD_URL}/api/tenants?${params}`, { cache: "no-store" });
if (!res.ok) return null;
const data: { docs?: { id: number }[] } = await res.json();
return data.docs?.[0]?.id ?? null;
}
export type CustomerSummary = {
id: number;
customerNumber: string;
firstName: string;
lastName: string;
email: string;
emailVerified: boolean;
};
export type AuthResult = { ok: true; token: string; customer: CustomerSummary } | { ok: false; reason: string };
export async function registerCustomer(input: {
firstName: string;
lastName: string;
email: string;
password: string;
}): Promise<AuthResult> {
const tenantId = await resolveTenantId();
if (tenantId == null) return { ok: false, reason: "Registrierung ist gerade nicht möglich." };
const res = await fetch(`${PAYLOAD_URL}/api/customers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...input, tenant: tenantId }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
const message: string | undefined = data?.errors?.[0]?.message;
return { ok: false, reason: message ?? "Diese E-Mail-Adresse ist bereits registriert." };
}
return loginCustomer(input);
}
export async function loginCustomer(input: { email: string; password: string }): Promise<AuthResult> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
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; emailVerified: boolean };
} = await res.json();
return {
ok: true,
token: data.token,
customer: {
id: data.user.id,
customerNumber: data.user.customerNumber,
firstName: data.user.firstName,
lastName: data.user.lastName,
email: data.user.email,
emailVerified: data.user.emailVerified,
},
};
}
export async function getCustomerFromToken(token: string): Promise<CustomerSummary | null> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/me`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!res.ok) return null;
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,
customerNumber: data.user.customerNumber,
firstName: data.user.firstName,
lastName: data.user.lastName,
email: data.user.email,
emailVerified: data.user.emailVerified,
};
}
export type CustomerAddress = {
deliveryMethod: "address" | "packstation" | null;
street: string | null;
packstationNumber: string | null;
postNumber: string | null;
zip: string | null;
city: string | null;
country: string | null;
};
export type CustomerProfile = CustomerSummary & CustomerAddress;
type PayloadCustomerMe = {
id: number;
customerNumber: string;
firstName: string;
lastName: string;
email: string;
emailVerified: boolean;
deliveryMethod: "address" | "packstation" | null;
street: string | null;
packstationNumber: string | null;
postNumber: string | null;
zip: string | null;
city: string | null;
country: string | null;
cart: { product: number; productSlug: string; quantity: number }[] | null;
};
export async function getCustomerProfile(token: string): Promise<CustomerProfile | null> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/me`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!res.ok) return null;
const data: { user: PayloadCustomerMe | null } = await res.json();
if (!data.user) return null;
const u = data.user;
return {
id: u.id,
customerNumber: u.customerNumber,
firstName: u.firstName,
lastName: u.lastName,
email: u.email,
emailVerified: u.emailVerified,
deliveryMethod: u.deliveryMethod,
street: u.street,
packstationNumber: u.packstationNumber,
postNumber: u.postNumber,
zip: u.zip,
city: u.city,
country: u.country,
};
}
export async function updateCustomerProfile(
token: string,
customerId: number,
data: {
firstName: string;
lastName: string;
deliveryMethod: "address" | "packstation";
street?: string;
packstationNumber?: string;
postNumber?: string;
zip: string;
city: string;
country: string;
},
): Promise<{ ok: true } | { ok: false; reason: string }> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, {
method: "PATCH",
headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) return { ok: false, reason: "Profil konnte nicht gespeichert werden." };
return { ok: true };
}
// Verifies the current password by attempting a real login with it (rather
// than trusting the caller) before changing anything — self-update access
// alone (see Customers.ts) would let an already-authenticated request set
// any password without proving it knows the old one.
export async function changeCustomerPassword(
email: string,
currentPassword: string,
newPassword: string,
): Promise<{ ok: true } | { ok: false; reason: string }> {
const verify = await loginCustomer({ email, password: currentPassword });
if (!verify.ok) return { ok: false, reason: "Aktuelles Passwort ist falsch." };
const res = await fetch(`${PAYLOAD_URL}/api/customers/${verify.customer.id}`, {
method: "PATCH",
headers: { Authorization: `JWT ${verify.token}`, "Content-Type": "application/json" },
body: JSON.stringify({ password: newPassword }),
});
if (!res.ok) return { ok: false, reason: "Passwort konnte nicht geändert werden." };
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}` },
cache: "no-store",
});
if (!res.ok) return [];
const data: { user: PayloadCustomerMe | null } = await res.json();
return (data.user?.cart ?? []).map((line) => ({ id: line.productSlug, qty: line.quantity }));
}
export async function saveServerCart(
token: string,
customerId: number,
cart: { productId: number; productSlug: string; quantity: number }[],
): Promise<boolean> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, {
method: "PATCH",
headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
cart: cart.map((line) => ({ product: line.productId, productSlug: line.productSlug, quantity: line.quantity })),
}),
});
return res.ok;
}
export const ORDER_STATUS_LABEL: Record<string, string> = {
received: "Eingegangen",
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;
total: number;
status: string;
itemCount: number;
};
export async function getCustomerOrders(token: string, customerId: number): Promise<CustomerOrder[]> {
const params = new URLSearchParams({
"where[customer][equals]": String(customerId),
sort: "-createdAt",
depth: "0",
limit: "50",
});
const res = await fetch(`${PAYLOAD_URL}/api/orders?${params}`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!res.ok) return [];
const data: { docs?: { orderNumber: string; createdAt: string; total: number; status: string; items: unknown[] }[] } =
await res.json();
return (data.docs ?? []).map((doc) => ({
orderNumber: doc.orderNumber,
createdAt: doc.createdAt,
total: doc.total,
status: doc.status,
itemCount: doc.items.length,
}));
}
export type CustomerOrderDetail = CustomerOrder & {
id: number;
customerFirstName: string;
customerLastName: string;
customerEmail: string;
deliveryMethod: "address" | "packstation";
street: string | null;
packstationNumber: string | null;
postNumber: string | null;
zip: string;
city: string;
country: string;
subtotal: number;
shippingCost: number;
shippingMethodTitle: string;
paymentMethodTitle: string;
discountCode: string | null;
discountAmount: number;
items: { productName: string; quantity: number; unitPrice: number }[];
};
// Access control (Orders.ts) already scopes a customer's own JWT to only
// their own orders — the where[customer] filter here is redundant with
// that, kept only so a wrong/foreign orderNumber returns "not found"
// instead of leaking whether that order number exists for someone else.
export async function getCustomerOrderDetail(token: string, customerId: number, orderNumber: string): Promise<CustomerOrderDetail | null> {
const params = new URLSearchParams({
"where[orderNumber][equals]": orderNumber,
"where[customer][equals]": String(customerId),
limit: "1",
});
const res = await fetch(`${PAYLOAD_URL}/api/orders?${params}`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!res.ok) return null;
const data: { docs?: (Omit<CustomerOrderDetail, "itemCount"> & { items: { productName: string; quantity: number; unitPrice: number }[] })[] } =
await res.json();
const doc = data.docs?.[0];
if (!doc) return null;
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) {
const store = await cookies();
store.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 2, // matches Payload's default JWT lifetime — no refresh flow in this stage
});
}
export async function clearSessionCookie() {
const store = await cookies();
store.delete(SESSION_COOKIE);
}
export async function readSessionToken(): Promise<string | null> {
const store = await cookies();
return store.get(SESSION_COOKIE)?.value ?? null;
}
// Convenience for Server Components (checkout page, /konto/*) that just
// need "who's logged in, if anyone" without touching the cookie API twice.
export async function getSessionCustomer(): Promise<{ token: string; customer: CustomerSummary } | null> {
const token = await readSessionToken();
if (!token) return null;
const customer = await getCustomerFromToken(token);
if (!customer) return null;
return { token, customer };
}