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
+25
View File
@@ -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 });
}
+30
View File
@@ -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"',
},
});
}
+9
View File
@@ -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 : "";
@@ -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 });
}
+5
View File
@@ -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 });
+5
View File
@@ -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 (
@@ -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 });
}
+15
View File
@@ -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);
}
+16 -1
View File
@@ -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,
+21
View File
@@ -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 });
}
}
@@ -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<string | null>(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 (
<div className="flex flex-col gap-2 items-start">
<button
type="button"
onClick={handleClick}
disabled={loading}
className={`px-5 py-3 rounded-sm border border-border hover:border-brand font-bold text-body-sm text-text-primary transition-colors ${loading ? "opacity-70 pointer-events-none" : ""}`}
>
{loading ? "…" : LABEL[action]}
</button>
{error && <p className="text-label text-red-600">{error}</p>}
</div>
);
}
@@ -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
<span className="font-bold text-h-small text-text-primary">{formatPrice(order.total)}</span>
</div>
</div>
{action && <OrderActionButton orderNumber={order.orderNumber} action={action} />}
</Reveal>
</main>
<Footer />
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Reveal } from "../../../components/Reveal";
const inputClass =
"w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors";
export function AccountDataSection() {
const router = useRouter();
const [confirming, setConfirming] = useState(false);
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
async function handleDelete(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setDeleting(true);
setError(null);
try {
const res = await fetch("/api/account/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Konto konnte nicht gelöscht werden.");
setDeleting(false);
return;
}
router.push("/");
router.refresh();
} catch {
setError("Konto konnte gerade nicht gelöscht werden.");
setDeleting(false);
}
}
return (
<Reveal className="flex flex-col gap-4 items-start w-full pt-4 border-t border-border">
<p className="font-semibold text-h-small text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Konto &amp; Daten
</p>
<a href="/api/account/export" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Meine Daten exportieren
</a>
{!confirming ? (
<button
type="button"
onClick={() => setConfirming(true)}
className="text-body-sm text-red-600 underline hover:text-red-700 transition-colors"
>
Konto löschen
</button>
) : (
<form onSubmit={handleDelete} className="flex flex-col gap-3 items-start w-full max-w-sm">
<p className="text-body-sm text-text-primary">
Dein Konto und deine gespeicherte Adresse werden gelöscht. Bereits aufgegebene Bestellungen bleiben aus
steuerrechtlichen Gründen mit ihren eigenen Daten erhalten, sind danach aber keinem Konto mehr zugeordnet.
</p>
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Passwort zur Bestätigung</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
className={inputClass}
/>
</label>
{error && <p className="text-label text-red-600">{error}</p>}
<div className="flex gap-3">
<button
type="submit"
disabled={deleting}
className={`px-5 py-3 rounded-sm bg-red-600 hover:bg-red-700 font-bold text-body-sm text-white transition-colors ${deleting ? "opacity-70 pointer-events-none" : ""}`}
>
{deleting ? "…" : "Konto endgültig löschen"}
</button>
<button type="button" onClick={() => setConfirming(false)} className="px-5 py-3 text-body-sm text-text-muted">
Abbrechen
</button>
</div>
</form>
)}
</Reveal>
);
}
@@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
export function VerificationBanner({ emailVerified, justVerified }: { emailVerified: boolean; justVerified: "1" | "0" | undefined }) {
const [sent, setSent] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
if (emailVerified) {
// Only shown right after clicking the link — not a persistent banner
// once verified, that would just be noise on every future visit.
if (justVerified === "1") {
return <p className="text-label text-success w-full">E-Mail-Adresse bestätigt.</p>;
}
return null;
}
async function handleResend() {
setSending(true);
setError(null);
try {
const res = await fetch("/api/account/resend-verification", { method: "POST" });
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Mail konnte nicht gesendet werden.");
setSending(false);
return;
}
setSent(true);
setSending(false);
} catch {
setError("Mail konnte gerade nicht gesendet werden.");
setSending(false);
}
}
return (
<div className="bg-bg-muted rounded-md p-4 flex flex-col gap-1 w-full">
<p className="text-body-sm text-text-primary">
{justVerified === "0"
? "Der Bestätigungslink ist ungültig oder abgelaufen."
: "Bitte bestätige deine E-Mail-Adresse."}{" "}
{!sent && (
<button type="button" onClick={handleResend} disabled={sending} className="underline font-bold hover:text-brand transition-colors">
{sending ? "…" : "Erneut senden"}
</button>
)}
{sent && <span className="text-success">Mail wurde erneut gesendet.</span>}
</p>
{error && <p className="text-label text-red-600">{error}</p>}
</div>
);
}
+11 -1
View File
@@ -4,25 +4,35 @@ import { Footer } from "../../components/Footer";
import { getSessionCustomer, getCustomerProfile } from "../../lib/customerAuth";
import { ProfileForm } from "./components/ProfileForm";
import { PasswordForm } from "./components/PasswordForm";
import { VerificationBanner } from "./components/VerificationBanner";
import { AccountDataSection } from "./components/AccountDataSection";
export const metadata: Metadata = {
title: "Mein Profil",
robots: { index: false, follow: true },
};
export default async function KontoProfilPage() {
export default async function KontoProfilPage({
searchParams,
}: {
searchParams: Promise<{ verified?: string }>;
}) {
const session = await getSessionCustomer();
if (!session) redirect("/konto/login");
const profile = await getCustomerProfile(session.token);
if (!profile) redirect("/konto/login");
const { verified } = await searchParams;
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<div className="flex flex-col gap-10 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[40rem] mx-auto">
<VerificationBanner emailVerified={profile.emailVerified} justVerified={verified === "1" || verified === "0" ? verified : undefined} />
<ProfileForm profile={profile} />
<PasswordForm email={profile.email} />
<AccountDataSection />
</div>
</main>
<Footer />
+53
View File
@@ -0,0 +1,53 @@
import nodemailer from "nodemailer";
// Server-only. Deliberately its own SMTP transport, independent of
// Payload's (which now also sends mail — see Customers.ts's verification
// email) — the whole point of this module is alerting when something is
// broken, and if Payload itself is what's broken, routing the alert
// through it could mean the alert never arrives. Same Hostinger account
// either way (already proven working via Diun's update notifications),
// just a second, separate connection to it.
const transport = nodemailer.createTransport({
host: "smtp.hostinger.com",
port: 587,
secure: false,
auth: {
user: process.env.SMTP_USER || "",
pass: process.env.SMTP_PASSWORD || "",
},
});
// Fire-and-forget by design — callers should not await this in a way that
// blocks or fails the actual error response the customer sees. Wrap
// everything in its own try/catch so a broken mail relay never becomes a
// second, worse failure on top of the one being reported.
export function sendCriticalAlert(subject: string, details: Record<string, unknown>): void {
transport
.sendMail({
from: '"einfach produktiv Alerts" <admin@mk360.de>',
to: "admin@mk360.de",
subject: `[einfach produktiv] ${subject}`,
text: JSON.stringify(details, null, 2),
})
.catch((err) => {
console.error("sendCriticalAlert: failed to send alert email", err);
});
}
// The *initial* verification email (on registration) is sent by Payload
// itself, via Customers.ts's own afterChange hook — that one fires
// automatically on create and needs no separate wiring. This one is only
// for the "erneut senden" resend path (app/api/account/resend-verification/
// route.ts), which updates the token via the customer's own session
// (app/lib/customerAuth.ts's resendVerificationEmail) but has no Payload
// hook to piggyback on for a plain update, so it sends directly instead —
// same Hostinger transport as the alert above, just a different template.
export async function sendVerificationEmail(to: string, firstName: string, token: string): Promise<void> {
const url = `https://einfach-produktiv.mk360.de/api/account/verify-email?token=${token}`;
await transport.sendMail({
from: '"einfach produktiv" <admin@mk360.de>',
to,
subject: "Bitte bestätige deine E-Mail-Adresse",
html: `<p>Hallo ${firstName},</p><p>bitte bestätige deine E-Mail-Adresse für dein Konto bei einfach produktiv:</p><p><a href="${url}">${url}</a></p><p>Der Link ist 24 Stunden gültig.</p>`,
});
}
+112 -4
View File
@@ -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) {
+43
View File
@@ -0,0 +1,43 @@
// Server-only, in-memory sliding-window limiter — deliberately no Redis:
// this app runs as a single Coolify container, so a plain in-process Map
// is sufficient and needs zero new infra. Counters reset on redeploy/
// restart (acceptable — an attacker gets a few extra free attempts right
// after a deploy, not a meaningful window) and won't share state if this
// ever scales to multiple instances; revisit with a shared store then.
//
// Complements, doesn't replace, Payload's own built-in per-account login
// lockout (Customers collection, maxLoginAttempts: 5 / lockTime: 10min,
// Payload defaults) — that stops brute-forcing one account, this stops an
// IP hammering registration or spraying attempts across many accounts.
const attempts = new Map<string, number[]>();
// Prevents unbounded growth from IPs that hit a route once and never
// return — without this, `attempts` would grow forever on a low-traffic
// site that nonetheless gets scanned/crawled periodically.
const MAX_TRACKED_KEYS = 10_000;
export function checkRateLimit(key: string, { limit, windowMs }: { limit: number; windowMs: number }): boolean {
const now = Date.now();
const windowStart = now - windowMs;
const timestamps = (attempts.get(key) ?? []).filter((t) => t > windowStart);
if (timestamps.length >= limit) {
attempts.set(key, timestamps);
return false;
}
timestamps.push(now);
if (attempts.size >= MAX_TRACKED_KEYS && !attempts.has(key)) {
attempts.clear();
}
attempts.set(key, timestamps);
return true;
}
// Caddy sits in front of this app and sets X-Forwarded-For — falls back to
// a constant key (effectively a single shared bucket) if that's ever
// missing, e.g. local dev, rather than disabling rate limiting outright.
export function getClientIp(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for");
return forwarded?.split(",")[0]?.trim() || "unknown";
}
+21
View File
@@ -11,12 +11,14 @@
"@payloadcms/live-preview-react": "^3.85.2",
"motion": "^12.42.2",
"next": "16.2.9",
"nodemailer": "^9.0.3",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
@@ -1668,6 +1670,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -5398,6 +5410,15 @@
"node": ">=18"
}
},
"node_modules/nodemailer": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+2
View File
@@ -12,12 +12,14 @@
"@payloadcms/live-preview-react": "^3.85.2",
"motion": "^12.42.2",
"next": "16.2.9",
"nodemailer": "^9.0.3",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
+71
View File
@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// Next.js 16 renamed `middleware.ts` to `proxy.ts` — this file must live
// at the project root (same level as `app/`), not inside `app/`. See
// node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md.
//
// Sliding-session refresh: Payload's customers auth token is valid for
// 7200s (2h, Payload default — see Customers.ts in the Payload repo,
// unchanged). Rather than issuing a long-lived token (harder to reason
// about if one ever leaks) or building a separate refresh-token cookie,
// this silently extends the *existing* token via Payload's own built-in
// refresh-token endpoint whenever it's getting close to expiry and the
// customer is actually still browsing — so an active shopper never gets
// logged out mid-session, but someone who walks away is logged out within
// 2h of their last request, same as before.
const SESSION_COOKIE = "ep_customer_token";
const REFRESH_THRESHOLD_MS = 15 * 60 * 1000; // refresh once < 15min remain
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
function getTokenExpiry(token: string): number | null {
try {
const payloadSegment = token.split(".")[1];
if (!payloadSegment) return null;
// Not verified here — this is only a "should I bother refreshing?"
// heuristic. Payload verifies the token for real on every actual API
// call regardless of what this function decides.
const json = JSON.parse(Buffer.from(payloadSegment, "base64url").toString("utf8"));
return typeof json.exp === "number" ? json.exp * 1000 : null;
} catch {
return null;
}
}
export default async function proxy(request: NextRequest) {
const token = request.cookies.get(SESSION_COOKIE)?.value;
if (!token) return NextResponse.next();
const expiresAt = getTokenExpiry(token);
if (!expiresAt || expiresAt - Date.now() > REFRESH_THRESHOLD_MS) return NextResponse.next();
try {
const res = await fetch(`${PAYLOAD_URL}/api/customers/refresh-token`, {
method: "POST",
headers: { Authorization: `JWT ${token}` },
});
if (!res.ok) return NextResponse.next();
const data: { refreshedToken?: string } = await res.json();
if (!data.refreshedToken) return NextResponse.next();
const response = NextResponse.next();
response.cookies.set(SESSION_COOKIE, data.refreshedToken, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 2,
});
return response;
} catch {
// Refresh failing (Payload briefly unreachable etc.) shouldn't block
// the actual page request — worst case the session just expires
// normally and the customer logs in again.
return NextResponse.next();
}
}
export const config = {
matcher: ["/checkout", "/konto/:path*", "/api/account/:path*"],
};