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:
Marco
2026-07-22 07:28:01 +00:00
parent 7f37f111e8
commit df05ea5358
22 changed files with 822 additions and 20 deletions
+129 -13
View File
@@ -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 <token>` 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