From df05ea5358bba8fa0d78a6c9f3ce9aa3f3fbf119 Mon Sep 17 00:00:00 2001 From: Marco Date: Wed, 22 Jul 2026 07:28:01 +0000 Subject: [PATCH] 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 --- README.md | 142 ++++++++++++++++-- app/api/account/delete/route.ts | 25 +++ app/api/account/export/route.ts | 30 ++++ app/api/account/login/route.ts | 9 ++ app/api/account/orders/[orderNumber]/route.ts | 33 ++++ app/api/account/password/route.ts | 5 + app/api/account/register/route.ts | 5 + app/api/account/resend-verification/route.ts | 16 ++ app/api/account/verify-email/route.ts | 15 ++ app/api/checkout/route.ts | 17 ++- app/api/health/route.ts | 21 +++ .../components/OrderActionButton.tsx | 53 +++++++ app/konto/bestellungen/[orderNumber]/page.tsx | 6 +- .../profil/components/AccountDataSection.tsx | 93 ++++++++++++ .../profil/components/VerificationBanner.tsx | 54 +++++++ app/konto/profil/page.tsx | 12 +- app/lib/alertAdmin.ts | 53 +++++++ app/lib/customerAuth.ts | 116 +++++++++++++- app/lib/rateLimit.ts | 43 ++++++ package-lock.json | 21 +++ package.json | 2 + proxy.ts | 71 +++++++++ 22 files changed, 822 insertions(+), 20 deletions(-) create mode 100644 app/api/account/delete/route.ts create mode 100644 app/api/account/export/route.ts create mode 100644 app/api/account/orders/[orderNumber]/route.ts create mode 100644 app/api/account/resend-verification/route.ts create mode 100644 app/api/account/verify-email/route.ts create mode 100644 app/api/health/route.ts create mode 100644 app/konto/bestellungen/[orderNumber]/components/OrderActionButton.tsx create mode 100644 app/konto/profil/components/AccountDataSection.tsx create mode 100644 app/konto/profil/components/VerificationBanner.tsx create mode 100644 app/lib/alertAdmin.ts create mode 100644 app/lib/rateLimit.ts create mode 100644 proxy.ts 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 && }