diff --git a/README.md b/README.md index 1f9a11b..d42fa65 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,14 @@ Preview components should ever point somewhere else). `DISCOUNT_SERVICE_SECRET` (no safe default — required for discount codes to validate/redeem at all; must match the value set on the Payload backend). `ORDER_SERVICE_SECRET` (no safe default — required for `/api/checkout` to persist an order in -Payload at all; must match the value set on the Payload backend). Set in -Coolify's app settings for production, not in a committed `.env` — this app -has no other secrets. +Payload at all, and for `/api/account/verify-email` to look up a customer +by their verification token; must match the value set on the Payload +backend — also used there for the same header). `SMTP_USER`/`SMTP_PASSWORD` +(no safe default — required for `app/lib/alertAdmin.ts`'s critical-failure +alerts and resend-verification emails; **does not** need to match anything +on the Payload side — this app's SMTP connection is deliberately +independent, see the "Monitoring & alerting" section). Set in Coolify's +app settings for production, not in a committed `.env`. ## Pages @@ -234,21 +239,26 @@ without leaving the page. `einfach-produktiv.mk360.de` vs `payload.mk360.de`) and instead mints its **own** httpOnly `ep_customer_token` cookie holding that JWT, forwarded as an `Authorization: JWT ` header on every subsequent Payload - call. No token refresh in this stage — Payload's ~2h default JWT - lifetime means a session just expires and the customer logs in again. + call. - **`app/api/account/*`** — thin route handlers around `customerAuth.ts`: `register`, `login`, `logout`, `me`, `orders` (list), `profile` (GET/PATCH incl. the one saved default address), `password` (verifies the current password via a real login attempt before changing it, - doesn't just trust the caller), `cart` (GET/POST, see below). + doesn't just trust the caller), `cart` (GET/POST, see below), + `verify-email`, `resend-verification`, `delete`, `export` (see "Email + verification" and "GDPR self-service" below), and + `orders/[orderNumber]` (PATCH — cancel/return-request, see "Order + cancellation & returns" below). - **`/konto/bestellungen`** lists a customer's own orders; **`/konto/bestellungen/[orderNumber]`** shows one order's full detail (items, address, totals, `status`). `status` (`received` → `processing` - → `shipped` → `delivered`) is maintained by hand in the Payload admin — - no shipping-carrier API integration. + → `shipped` → `delivered`, plus `cancelled`/`return_requested`/`returned`) + is maintained by hand in the Payload admin for the shipping states — no + shipping-carrier API integration. - **`/konto/profil`** edits name + the one saved default address (deliberately a single address, not a full address book — see the assistant's memory - note on optionally expanding this later) and changes the password. + note on optionally expanding this later), changes the password, shows + the email-verification banner, and has the GDPR export/delete section. - **Cart sync**: `app/components/CartSync.tsx` (mounted once in `app/layout.tsx`) watches the local cart via `useCart()` and debounce-POSTs it to `/api/account/cart` on every change; the route @@ -258,10 +268,116 @@ without leaving the page. saved server-side into the local cart by quantity — CartSync's own effect then pushes the merged result back up on its own, so there's no separate explicit "save after merge" call. -- Only a single default address per account, single-currency, no order - cancellation/return flow, no email verification, no password-reset - (self-service — a customer who forgets their password currently has no - recovery path). All known, deliberately out of scope for now. +- Still out of scope: a full address book (single default address only — + see the assistant's memory note), single-currency. + +### Rate limiting + +`app/lib/rateLimit.ts` — an in-memory, per-IP sliding-window limiter +(`checkRateLimit(key, {limit, windowMs})`), deliberately no Redis: this +app runs as a single Coolify container, so a plain `Map` is enough and +needs no new infra. Resets on redeploy/restart — acceptable at this +shop's traffic level; revisit with a shared store if this ever scales to +multiple instances. Applied (keyed by `X-Forwarded-For`, which Caddy +already sets) to `/api/account/register`, `/api/account/login`, +`/api/account/password`, and `/api/account/resend-verification`. + +This complements, not replaces, Payload's own **per-account** login +lockout (`customers.auth`, `maxLoginAttempts: 5` / `lockTime: 10min`, +Payload defaults — see the Payload README's "Login rate limiting" +section) — that stops brute-forcing one known email, this stops an IP +spraying attempts across many, or hammering registration. + +### Session refresh + +`proxy.ts` (project root — Next.js 16 renamed `middleware.ts` to +`proxy.ts`; see `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md` +if this ever looks wrong against older docs/training data). Runs on +`/checkout`, `/konto/*`, `/api/account/*`. Decodes (not verifies — Payload +verifies for real on every actual API call) the `ep_customer_token` +cookie's JWT `exp` claim; if less than 15 minutes remain, silently calls +Payload's built-in `POST /api/customers/refresh-token` and swaps in the +refreshed token. Net effect: an actively-browsing customer never gets +logged out mid-session, but someone who walks away is logged out within +~2h of their last request (Payload's `tokenExpiration` default, unchanged +on the Payload side). + +### Email verification + +Non-blocking by design — see the Payload README's `customers.emailVerified` +section for why this is a custom flag rather than Payload's built-in +`auth.verify: true` (short version: that would hard-block login for a +brand-new customer trying to finish the purchase they just registered +mid-checkout for). The initial email is sent by Payload itself (an +`afterChange` hook on `customers`, fires on create). `/konto/profil`'s +`VerificationBanner.tsx` shows a non-blocking "bitte bestätigen" hint with +a resend link when `!profile.emailVerified`; resending +(`/api/account/resend-verification`) is sent directly from this app +instead (`app/lib/alertAdmin.ts`'s `sendVerificationEmail()` — same +Hostinger SMTP, no Payload hook to piggyback on for a plain field update). + +### GDPR self-service + +`/konto/profil`'s "Konto & Daten" section: +- **Export** (`/api/account/export`, GET) — profile + every order's full + detail as one downloadable JSON (`Content-Disposition: attachment`). + Genuinely complete, not a summary — Art. 20 data portability. +- **Delete** (`/api/account/delete`, POST, password re-verified via a real + login attempt first) — deletes the `customers` document. Past orders + are **not** touched: `orders.customer` is `ON DELETE SET NULL` in + Payload, so an order keeps its own name/address/items snapshot (already + stored independently for exactly this kind of reason) for tax-retention + purposes (§147 AO / GDPR Art. 17(3)(b) explicitly permits this) — only + the account/login itself disappears. The UI says this explicitly before + deleting, not as a surprise afterward. + +### Order cancellation & returns + +`/konto/bestellungen/[orderNumber]` shows one self-service button when +applicable: "Bestellung stornieren" while `status === 'received'`, or +"Rücksendung anfragen" while `status` is `'shipped'` or `'delivered'` +(`customerOrderAction()` in `customerAuth.ts` decides which, if any). +Posts to `/api/account/orders/[orderNumber]` (PATCH), which re-checks the +transition is still valid (friendlier error than a bare 403 if it's gone +stale — two tabs open, order shipped in the meantime) before calling +`requestOrderStatusChange()`. + +**The real security boundary is in Payload**, not here: `orders.access.update` +already scoped a customer's JWT to their own order, but with no +field-level restriction — before this stage, a logged-in customer could in +principle PATCH *any* field of their own order (`total`, `items`, +anything), just because nothing in the frontend had ever exercised that +path yet. `Orders.ts`'s `beforeChange` hook now rejects a +customer-authenticated update unless the only changed field is `status`, +via an allowed transition. See the Payload README's own writeup for the +full detail. + +No hard 14-day return-window check in code (no separately tracked delivery +date exists yet) — relies on the existing `/widerruf` legal text plus +manual admin review. No automatic refund (no payment provider exists yet) +— a return/cancellation request is just captured structurally instead of +arriving by email/phone; the admin still processes it by hand in the +Payload admin. + +### Monitoring & alerting + +Base uptime (is the site/Payload reachable at all) is already covered by +existing Uptime Kuma HTTP monitors with email alerting (`monitor.mk360.de` +— see `~/dev/README.md`'s Kuma section) and isn't part of this app. What's +new here is the one failure mode Kuma structurally can't see: the site is +up, a customer completes checkout, and the order still doesn't get +persisted (`createOrder()` returns `null` in `/api/checkout/route.ts`). +That path calls `app/lib/alertAdmin.ts`'s `sendCriticalAlert()` — its own, +independent SMTP connection (same Hostinger account, but **not** routed +through Payload, since Payload being the actual problem is one of the +scenarios this needs to still report on). Fire-and-forget, its own +try/catch, never blocks or fails the actual error response the customer +sees. + +`/api/health` (GET) — checks Payload's public API is actually reachable +(3s timeout), not just that this page rendered; added as a Kuma HTTP +monitor in the existing "Content & API" group (`~/dev/README.md`'s +documented `sqlite3`-insert method, Kuma 1.x has no REST API for this). ## Deployment diff --git a/app/api/account/delete/route.ts b/app/api/account/delete/route.ts new file mode 100644 index 0000000..1d1978d --- /dev/null +++ b/app/api/account/delete/route.ts @@ -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 }); +} diff --git a/app/api/account/export/route.ts b/app/api/account/export/route.ts new file mode 100644 index 0000000..679791f --- /dev/null +++ b/app/api/account/export/route.ts @@ -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"', + }, + }); +} diff --git a/app/api/account/login/route.ts b/app/api/account/login/route.ts index bbaad1e..173946f 100644 --- a/app/api/account/login/route.ts +++ b/app/api/account/login/route.ts @@ -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 : ""; diff --git a/app/api/account/orders/[orderNumber]/route.ts b/app/api/account/orders/[orderNumber]/route.ts new file mode 100644 index 0000000..3ef6d74 --- /dev/null +++ b/app/api/account/orders/[orderNumber]/route.ts @@ -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 }); +} diff --git a/app/api/account/password/route.ts b/app/api/account/password/route.ts index d9da1a0..2280e2b 100644 --- a/app/api/account/password/route.ts +++ b/app/api/account/password/route.ts @@ -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 }); diff --git a/app/api/account/register/route.ts b/app/api/account/register/route.ts index 1e212ef..3fe73c8 100644 --- a/app/api/account/register/route.ts +++ b/app/api/account/register/route.ts @@ -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 ( diff --git a/app/api/account/resend-verification/route.ts b/app/api/account/resend-verification/route.ts new file mode 100644 index 0000000..79bda5d --- /dev/null +++ b/app/api/account/resend-verification/route.ts @@ -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 }); +} diff --git a/app/api/account/verify-email/route.ts b/app/api/account/verify-email/route.ts new file mode 100644 index 0000000..e83e320 --- /dev/null +++ b/app/api/account/verify-email/route.ts @@ -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); +} diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts index 7c96708..f38fb7c 100644 --- a/app/api/checkout/route.ts +++ b/app/api/checkout/route.ts @@ -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, diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..7ee720e --- /dev/null +++ b/app/api/health/route.ts @@ -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 }); + } +} diff --git a/app/konto/bestellungen/[orderNumber]/components/OrderActionButton.tsx b/app/konto/bestellungen/[orderNumber]/components/OrderActionButton.tsx new file mode 100644 index 0000000..8c51050 --- /dev/null +++ b/app/konto/bestellungen/[orderNumber]/components/OrderActionButton.tsx @@ -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(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 ( +
+ + {error &&

{error}

} +
+ ); +} diff --git a/app/konto/bestellungen/[orderNumber]/page.tsx b/app/konto/bestellungen/[orderNumber]/page.tsx index 8b310d2..d618517 100644 --- a/app/konto/bestellungen/[orderNumber]/page.tsx +++ b/app/konto/bestellungen/[orderNumber]/page.tsx @@ -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 {formatPrice(order.total)} + + {action && }