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
+5 -4
View File
@@ -5,21 +5,22 @@ import { canViewAnalytics } from "@/access";
* One row per calendar day, upserted by the cleanup cron
* (lib/analytics/cleanup.ts) from raw analytics-events BEFORE those raw rows
* age out. This is what lets aggregated history survive well beyond the raw
* retention window (default 90 days raw vs. 12 months aggregated — see
* retention window (default 90 days raw vs. 12 months aggregated - see
* globals/AnalyticsSettings.ts) without keeping individual events around.
*
* Not currently read by the main Analytics dashboard filters (those all
* stay within raw retention by design — see the implementation plan); this
* stay within raw retention by design - see the implementation plan); this
* exists so long-term aggregated history isn't silently lost once raw
* events are purged, and is available for a future longer-range view.
*/
export const AnalyticsDailyStats: CollectionConfig = {
slug: "analytics-daily-stats",
labels: {
singular: "Analytics-Tagesstatistik",
plural: "Analytics-Tagesstatistiken",
singular: "Tagesstatistik",
plural: "Tagesstatistiken",
},
admin: {
group: "Auswertung",
hidden: true,
},
access: {
+8 -7
View File
@@ -3,25 +3,26 @@ import { canViewAnalytics } from "@/access";
/**
* One row per tracked event (page_view, booking_started, ...). Deliberately
* append-only and never writable via the public/admin API — the only writer
* append-only and never writable via the public/admin API - the only writer
* is app/(payload)/api/analytics/track/route.ts, which uses the trusted
* Local API (overrideAccess: true by default) to insert rows despite
* `create` being locked to `() => false` here. The only deleter is the
* cleanup cron (lib/analytics/cleanup.ts), same mechanism.
*
* No name/email/IP/UA is ever stored — see lib/analytics/salt.ts for how
* No name/email/IP/UA is ever stored - see lib/analytics/salt.ts for how
* `visitorHash` is derived without persisting anything identifying.
*/
export const AnalyticsEvents: CollectionConfig = {
slug: "analytics-events",
labels: {
singular: "Analytics-Event",
plural: "Analytics-Events",
singular: "Messereignis",
plural: "Messereignisse",
},
admin: {
// High-volume, low-signal raw data — not meant for manual browsing.
// The Analytics dashboard (a custom admin view) reads it programmatically.
group: "Auswertung",
hidden: true,
// High-volume, low-signal raw data - not meant for manual browsing.
// The Analytics dashboard (a custom admin view) reads it programmatically.
},
access: {
create: () => false,
@@ -42,7 +43,7 @@ export const AnalyticsEvents: CollectionConfig = {
name: "visitorHash",
type: "text",
required: true,
// Daily-rotating pseudonymous hash (see lib/analytics/salt.ts) — never
// Daily-rotating pseudonymous hash (see lib/analytics/salt.ts) - never
// a raw IP/UA, never stable across days, never exposed to the client.
},
{
+1 -1
View File
@@ -11,7 +11,7 @@ export const AvailabilityOverrides: CollectionConfig = {
useAsTitle: "date",
defaultColumns: ["date", "type", "reason"],
group: "Buchungen",
description: "Einzelne Abweichungen von den wiederkehrenden Verfügbarkeiten — Urlaub, Feiertage, Krankheit oder Sonderöffnungszeiten.",
description: "Einzelne Abweichungen von den wiederkehrenden Verfügbarkeiten - Urlaub, Feiertage, Krankheit oder Sonderöffnungszeiten.",
},
access: {
read: isAdmin,
+4 -4
View File
@@ -183,7 +183,7 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
})) as Event;
online = {
joinUrl: `${serverUrl}/termine/${event.slug}/beitreten`,
password: event.meetingPassword || "—",
password: event.meetingPassword || "-",
};
} else if (doc.appointmentType === "onsite") {
const settings = await req.payload.findGlobal({ slug: "booking-settings", req });
@@ -198,7 +198,7 @@ const notifyByEmail: CollectionAfterChangeHook = async ({ doc, previousDoc, oper
name: user.name,
offerTitle: offer.title,
dateLabel: fmtDate(doc.date),
timeLabel: `${fmtTime(doc.startTime)} – ${fmtTime(doc.endTime)}`,
timeLabel: `${fmtTime(doc.startTime)} - ${fmtTime(doc.endTime)}`,
appointmentTypeLabel: appointmentTypeLabel(doc.appointmentType),
online,
onsite,
@@ -260,12 +260,12 @@ export const BookingRequests: CollectionConfig = {
useAsTitle: "id",
defaultColumns: ["offer", "user", "date", "startTime", "status"],
group: "Buchungen",
description: "Terminanfragen von Nutzern — bestätigen, ablehnen oder einen alternativen Termin vorschlagen, indem du die Felder unten änderst und speicherst.",
description: "Terminanfragen von Nutzern - bestätigen, ablehnen oder einen alternativen Termin vorschlagen, indem du die Felder unten änderst und speicherst.",
},
access: {
// Customer-initiated writes always go through the vetted /api/booking/*
// routes (Local API, elevated access, after the route verifies
// ownership) — never directly through REST/GraphQL. This keeps a
// ownership) - never directly through REST/GraphQL. This keeps a
// customer from ever reading/writing another customer's booking, or
// setting their own status to "confirmed" directly.
create: isAdmin,
+3 -2
View File
@@ -48,7 +48,8 @@ export const ConversationMessages: CollectionConfig = {
useAsTitle: "body",
defaultColumns: ["conversation", "senderType", "senderName", "createdAt"],
group: "Kommunikation",
description: "Einzelne Nachrichten innerhalb einer Anfrage. Antworten aus dem CMS werden automatisch per E-Mail verschickt.",
description: "Einzelne Nachrichten innerhalb einer Anfrage. Antworten aus der Verwaltung werden automatisch per E-Mail verschickt.",
hidden: true,
},
access: {
read: isAdmin,
@@ -79,7 +80,7 @@ export const ConversationMessages: CollectionConfig = {
{ label: "Anfragende Person", value: "visitor" },
{ label: "Anouma", value: "admin" },
],
admin: { description: "Für Antworten im CMS „Anouma“ auswählen. Eingehende E-Mails werden automatisch als Anfragende Person gespeichert." },
admin: { description: "Für Antworten aus der Verwaltung „Anouma“ auswählen. Eingehende E-Mails werden automatisch als anfragende Person gespeichert." },
},
{ name: "senderName", type: "text", label: "Absendername" },
{ name: "senderEmail", type: "email", label: "Absender-E-Mail" },
+6 -6
View File
@@ -8,7 +8,7 @@ import { sendEmail } from "@/lib/email/sendBookingEmails";
// Routes through the shared isAdmin check (role === "admin", not just
// collection === "users") so this stays correct if a non-admin role is ever
// added to Users — see the comment on isAdmin in access/index.ts.
// added to Users - see the comment on isAdmin in access/index.ts.
const isSelfOrAdmin: typeof isAdmin = (args) => {
const user = args.req.user;
if (!user) return false;
@@ -21,12 +21,12 @@ const isSelfOrAdmin: typeof isAdmin = (args) => {
// customer changes their email address: issues a random, single-use,
// time-limited email-verification token and resets emailVerified to false.
// Only the token's hash is ever persisted (see emailVerificationTokenHash
// below) — the plaintext exists only in req.context for the afterChange
// below) - the plaintext exists only in req.context for the afterChange
// hook below to email out, and is never written to the database or logged.
//
// The email-change case matters because `update` access on this collection
// is isSelfOrAdmin and the auto-added `email` field carries no field-level
// access of its own — without this, a customer could verify a throwaway
// access of its own - without this, a customer could verify a throwaway
// address once and then repoint `email` at an address they don't own while
// keeping emailVerified: true (which gates booking-confirmation emails).
const issueVerificationToken: CollectionBeforeChangeHook = ({ data, originalDoc, operation, req }) => {
@@ -110,7 +110,7 @@ export const Customers: CollectionConfig = {
label: "Telefonnummer",
required: false,
admin: {
description: "Optional — für eine spätere Nutzung vorbereitet, aktuell nicht verpflichtend.",
description: "Optional - für eine spätere Nutzung vorbereitet, aktuell nicht verpflichtend.",
},
},
{
@@ -118,7 +118,7 @@ export const Customers: CollectionConfig = {
type: "checkbox",
label: "E-Mail bestätigt",
defaultValue: false,
// "create" AND "update" must both be locked down — otherwise a
// "create" AND "update" must both be locked down - otherwise a
// customer could simply include emailVerified: true in their own
// registration or profile request and skip verification entirely.
access: { create: isAdminFieldLevel, update: isAdminFieldLevel },
@@ -130,7 +130,7 @@ export const Customers: CollectionConfig = {
{
name: "emailVerificationTokenHash",
type: "text",
// Never exposed or settable via any API — only the two hooks above
// Never exposed or settable via any API - only the two hooks above
// (running through the trusted Local API) ever touch this field.
access: { create: () => false, read: () => false, update: () => false },
admin: { hidden: true },
+3 -3
View File
@@ -11,7 +11,7 @@ export const EventRegistrations: CollectionConfig = {
useAsTitle: "name",
defaultColumns: ["name", "email", "event", "status", "registeredAt"],
group: "Inhalte",
description: "Anmeldungen zu Terminen — wird beim öffentlichen Anmeldeformular befüllt.",
description: "Anmeldungen zu Terminen - wird beim öffentlichen Anmeldeformular befüllt.",
},
access: {
// Public registrations must go through /api/meetings/[slug]/register,
@@ -65,14 +65,14 @@ export const EventRegistrations: CollectionConfig = {
{
name: "reminder60Sent",
type: "checkbox",
label: "Reminder 60 Min. gesendet",
label: "Erinnerung 60 Min. gesendet",
defaultValue: false,
admin: { width: "50%" },
},
{
name: "reminder30Sent",
type: "checkbox",
label: "Reminder 30 Min. gesendet",
label: "Erinnerung 30 Min. gesendet",
defaultValue: false,
admin: { width: "50%" },
},
+6 -6
View File
@@ -18,7 +18,7 @@ export const EVENT_CATEGORIES = [
// Auto-generates the meeting password for new online events, and
// regenerates it whenever the admin ticks "Neues Passwort generieren".
// Never derived from the event id/slug — always a fresh random value.
// Never derived from the event id/slug - always a fresh random value.
const setMeetingPassword: CollectionBeforeChangeHook = ({ data, originalDoc }) => {
if (!(data.isOnline ?? originalDoc?.isOnline)) return data;
if (!(data.meetingPassword ?? originalDoc?.meetingPassword) || data.regeneratePassword || originalDoc?.isOnline === false) {
@@ -38,7 +38,7 @@ export const Events: CollectionConfig = {
useAsTitle: "title",
defaultColumns: ["title", "date", "category", "_status"],
group: "Inhalte",
description: "Termine und Veranstaltungen — erscheinen automatisch unter /termine.",
description: "Termine und Veranstaltungen - erscheinen automatisch unter /termine.",
},
versions: {
drafts: {
@@ -149,7 +149,7 @@ export const Events: CollectionConfig = {
},
{
type: "collapsible",
label: "Online-Termin (Video-Call)",
label: "Online-Termin (Videoanruf)",
fields: [
{
name: "isOnline",
@@ -166,7 +166,7 @@ export const Events: CollectionConfig = {
label: "Meeting-Passwort",
// Events are publicly readable (publishedOrAdmin) via the REST/
// GraphQL API, so without this the plaintext password would be
// returned to any visitor fetching the event — defeating the
// returned to any visitor fetching the event - defeating the
// password check in app/(payload)/api/meetings/[slug]/join. Only
// exposed to admins there; the join route reads it via the Local
// API, which bypasses field access by default.
@@ -220,14 +220,14 @@ export const Events: CollectionConfig = {
{
name: "hostReminder60Sent",
type: "checkbox",
label: "Host-Reminder 60 Min. gesendet",
label: "Erinnerung an Anna 60 Min. gesendet",
defaultValue: false,
admin: { readOnly: true, width: "50%" },
},
{
name: "hostReminder30Sent",
type: "checkbox",
label: "Host-Reminder 30 Min. gesendet",
label: "Erinnerung an Anna 30 Min. gesendet",
defaultValue: false,
admin: { readOnly: true, width: "50%" },
},
+3 -2
View File
@@ -12,8 +12,9 @@ export const Media: CollectionConfig = {
plural: "Bilder",
},
admin: {
group: "Inhalte",
useAsTitle: "alt",
description: "Bilder für die Website — Alt-Text ist für Barrierefreiheit und SEO Pflicht.",
description: "Bilder für die Website - Alt-Text ist für Barrierefreiheit und SEO Pflicht.",
},
access: {
// Images are public so the website can display them without a login.
@@ -26,7 +27,7 @@ export const Media: CollectionConfig = {
staticDir: path.resolve(dirname, "..", "media"),
// SVG deliberately excluded: it can carry inline <script>/event-handler
// payloads that execute if the raw file is ever opened directly (not via
// <img>) — not currently needed by any part of the site.
// <img>) - not currently needed by any part of the site.
mimeTypes: ["image/jpeg", "image/png", "image/webp"],
imageSizes: [
{ name: "thumbnail", width: 400, position: "centre" },
+2 -2
View File
@@ -5,7 +5,7 @@ import { seoFields } from "@/fields/seo";
// Groups the 7 individual offers for the mega menu and the /angebote
// overview page (Kindergruppen bundles Erdenkinder + Mädchenkreis, Singkreise
// bundles the three singing circles — matching the site's navigation).
// bundles the three singing circles - matching the site's navigation).
export const OFFER_CATEGORIES = [
{ label: "Prozessbegleitung", value: "prozessbegleitung" },
{ label: "Doula-Begleitung", value: "doula-begleitung" },
@@ -23,7 +23,7 @@ export const Offers: CollectionConfig = {
useAsTitle: "title",
defaultColumns: ["title", "category", "order", "_status"],
group: "Inhalte",
description: "Die Angebote von Anouma — erscheinen im Menü und auf /angebote.",
description: "Die Angebote von Anouma - erscheinen im Menü und auf /angebote.",
},
defaultSort: "order",
versions: {
+4 -4
View File
@@ -3,7 +3,7 @@ import { isAdmin } from "@/access";
function normalizePath(value: string): string {
// Lowercased to match the slugs it points at/from (fields/slug.ts always
// lowercases via slugify) — without this, "/Angebote/Doula" and
// lowercases via slugify) - without this, "/Angebote/Doula" and
// "/angebote/doula" count as different values to the `unique` constraint
// on fromPath, letting near-duplicate redirects coexist.
const trimmed = value.trim().toLowerCase();
@@ -14,7 +14,7 @@ function normalizePath(value: string): string {
/**
* Simple slug-change redirect table (see lib/seo/redirects.ts). Deliberately
* scoped to the three dynamic [slug] detail routes (Angebote/Termine/
* Aktuelles) — that's the only place slugs actually live and can change, so
* Aktuelles) - that's the only place slugs actually live and can change, so
* looking this up only there avoids adding a database lookup to every
* single request the way a catch-all middleware would.
*/
@@ -66,8 +66,8 @@ export const Redirects: CollectionConfig = {
required: true,
defaultValue: "permanent",
options: [
{ label: "301 – Dauerhaft", value: "permanent" },
{ label: "302 – Vorübergehend", value: "temporary" },
{ label: "301 - Dauerhaft", value: "permanent" },
{ label: "302 - Vorübergehend", value: "temporary" },
],
},
{
+6 -5
View File
@@ -10,12 +10,13 @@ export const Users: CollectionConfig = {
admin: {
useAsTitle: "name",
defaultColumns: ["name", "email", "role"],
group: "Verwaltung",
description: "Zugänge für den geschützten Admin-Bereich.",
},
auth: {
// Payload shows a "create first user" screen automatically when this
// collection is empty, bypassing normal access control just for that
// one-time setup — no hardcoded default password is ever needed.
// one-time setup - no hardcoded default password is ever needed.
maxLoginAttempts: 5,
lockTime: 10 * 60 * 1000,
},
@@ -48,24 +49,24 @@ export const Users: CollectionConfig = {
{
name: "canViewAnalytics",
type: "checkbox",
label: "Analytics sichtbar",
label: "Auswertung sichtbar",
defaultValue: true,
// Admins always have access regardless of this flag (see
// access/index.ts's canViewAnalytics) — this only matters once a
// access/index.ts's canViewAnalytics) - this only matters once a
// non-admin role exists, letting an admin grant/revoke Analytics
// visibility per account without changing anyone's role.
access: {
update: isAdminFieldLevel,
},
admin: {
description: "Steuert, ob dieser Zugang das Analytics-Dashboard im Admin-Bereich sehen kann.",
description: "Steuert, ob dieser Zugang die Auswertung im Admin-Bereich sehen kann.",
},
},
{
name: "calendarFeedTokenHash",
type: "text",
// Only ever set by /api/admin/calendar-feed/regenerate (see
// lib/calendar/feedToken.ts) — never exposed or directly settable via
// lib/calendar/feedToken.ts) - never exposed or directly settable via
// any API, so the plaintext token is never retrievable again after
// it's shown once at generation time.
access: { create: () => false, read: () => false, update: () => false },