feat: redesign CMS and unify German admin UI

This commit is contained in:
2026-09-17 16:51:24 +02:00
parent a2b3e99174
commit 7ec0c51ff5
129 changed files with 952 additions and 550 deletions
@@ -6,10 +6,10 @@ import { calendarFeedUrl, generateCalendarFeedToken, hashCalendarFeedToken } fro
export const dynamic = "force-dynamic";
/**
* POST /api/admin/calendar-feed/regenerate — (re)generates Anna's personal
* POST /api/admin/calendar-feed/regenerate - (re)generates Anna's personal
* ICS feed link. Only the new token's hash is stored; the plaintext URL is
* returned exactly once in this response and can never be retrieved again
* — generating a new one immediately invalidates whichever link was issued
* - generating a new one immediately invalidates whichever link was issued
* before (see collections/Users.ts).
*/
export async function POST(request: Request) {
+2 -2
View File
@@ -23,7 +23,7 @@ function normalizeEventName(value: unknown): string | null {
return trimmed;
}
/** Hostname only (never path/query) — see collections/AnalyticsEvents.ts. */
/** Hostname only (never path/query) - see collections/AnalyticsEvents.ts. */
function referrerHostFrom(referrer: unknown): string | undefined {
if (typeof referrer !== "string" || !referrer) return undefined;
try {
@@ -35,7 +35,7 @@ function referrerHostFrom(referrer: unknown): string | undefined {
/**
* Public tracking beacon, called by lib/analytics/client.ts only after the
* visitor has granted statistics consent — but the client's own claim is
* visitor has granted statistics consent - but the client's own claim is
* never trusted alone: this route re-checks the CookieSettings global's
* `statistics.enabled` flag server-side before writing anything, so a
* global kill-switch (or a stale/tampered client) can never bypass it.
@@ -11,7 +11,7 @@ import type { Customer } from "@/payload-types";
export const dynamic = "force-dynamic";
// Deliberately identical whether or not an account exists / is already
// verified — this endpoint must never let a caller find out either of
// verified - this endpoint must never let a caller find out either of
// those things about an email address that isn't their own session.
const GENERIC_RESPONSE = {
ok: true,
@@ -60,7 +60,7 @@ export async function POST(request: Request) {
body = await request.json();
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
} catch {
// no body — handled below as a missing email
// no body - handled below as a missing email
}
const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : "";
if (!email || email.length > 254) {
@@ -38,7 +38,7 @@ export async function POST(request: Request, { params }: Args) {
}
// The beforeChange hook on booking-requests re-checks for conflicts
// (with an advisory lock) before allowing this to become "confirmed" —
// (with an advisory lock) before allowing this to become "confirmed" -
// the alternative slot may have been taken by someone else since it was
// proposed.
await payload.update({
@@ -16,7 +16,7 @@ export async function POST(request: Request, { params }: Args) {
if (!user || user.collection !== "customers") {
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
}
// Cancelling is deliberately allowed regardless of verification status —
// Cancelling is deliberately allowed regardless of verification status -
// only creating/confirming a booking is a gated "protected" action.
return await withBookingTransaction(payload, `booking:${id}`, async (req) => {
+1 -1
View File
@@ -67,7 +67,7 @@ export async function POST(request: Request) {
const end = new Date(start.getTime() + offer.durationMinutes * 60_000);
// Re-validate the slot is actually free right now (defense against a
// stale slot list) — the hard, race-safe check still runs again when
// stale slot list) - the hard, race-safe check still runs again when
// Anna confirms (see collections/BookingRequests.ts).
const dayBounds = berlinDayBounds(start);
return await withBookingTransaction(payload, dayBounds.key, async (req) => {
@@ -16,7 +16,7 @@ function safeEqual(a: string, b: string): boolean {
/**
* Intended to be called by an external cron job once daily, e.g.:
* curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/analytics-cleanup
* (a second, separate cron entry from /api/cron/event-reminders — reuses
* (a second, separate cron entry from /api/cron/event-reminders - reuses
* the same CRON_SECRET, no new secret needed)
*/
export async function GET(request: Request) {
+1 -1
View File
@@ -8,7 +8,7 @@ export const dynamic = "force-dynamic";
/**
* Used by the Docker healthcheck (see docker-compose.yml) and by
* setup.sh/deploy.sh to confirm the app can actually reach Postgres before
* being considered "up" — not just that the Node process is listening.
* being considered "up" - not just that the Node process is listening.
*/
export async function GET() {
try {
@@ -57,7 +57,7 @@ export async function POST(request: Request, { params }: Args) {
}
const { user } = await payload.auth({ headers: request.headers });
// Must check the collection, not just presence — a logged-in customer
// Must check the collection, not just presence - a logged-in customer
// (collection "customers") must never be treated as host.
const isHost = user?.collection === "users" && user.role === "admin";
@@ -81,7 +81,7 @@ export async function POST(request: Request, { params }: Args) {
role: isHost ? "host" : "participant",
accessVersion: access.accessVersion,
ttlMs: Math.min(6 * 3600000, access.expiresAt - Date.now()),
// Only meaningful for participants — a host authenticates via a real
// Only meaningful for participants - a host authenticates via a real
// session already, so there's no need to anchor a kick to it (and
// hosts can't be kicked in the first place.
clientId: isHost ? undefined : clientId,