feat: redesign CMS and unify German admin UI
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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.
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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%" },
|
||||
},
|
||||
|
||||
@@ -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%" },
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user