From b099197839c91cf98c4275fbf14c64ee0f65de4a Mon Sep 17 00:00:00 2001 From: Benno Tielen Date: Thu, 16 Jul 2026 13:27:37 +0200 Subject: [PATCH] feature: worship and events auth --- src/collections/EventOccurrences.ts | 5 +- src/collections/Events.ts | 140 +++++++++------------ src/collections/Worship.ts | 28 ++++- src/collections/access/assigned.ts | 184 +++++++++++++++++++++++++++- 4 files changed, 261 insertions(+), 96 deletions(-) diff --git a/src/collections/EventOccurrences.ts b/src/collections/EventOccurrences.ts index 8e940d8..5c98d2b 100644 --- a/src/collections/EventOccurrences.ts +++ b/src/collections/EventOccurrences.ts @@ -1,5 +1,6 @@ import { CollectionConfig } from 'payload' import { isAdminOrEmployee } from '@/collections/access/admin' +import { canMutateOccurrenceOfAssignedEvent } from '@/collections/access/assigned' export const EventOccurrences: CollectionConfig = { slug: 'eventOccurrence', @@ -85,7 +86,9 @@ export const EventOccurrences: CollectionConfig = { access: { read: () => true, create: isAdminOrEmployee(), - update: isAdminOrEmployee(), + // Whoever may edit the parent event may cancel/uncancel its occurrences + // (used by the "Nächste Termine" table on the event edit view). + update: canMutateOccurrenceOfAssignedEvent(), delete: isAdminOrEmployee(), }, } diff --git a/src/collections/Events.ts b/src/collections/Events.ts index ad5b023..a7471c5 100644 --- a/src/collections/Events.ts +++ b/src/collections/Events.ts @@ -1,7 +1,16 @@ import { CollectionConfig } from 'payload' -import { Group, User } from '@/payload-types' -import { fetchEventById } from '@/fetch/events' import { isPublishedPublic } from '@/collections/access/public' +import { + canCreateAssignedEvent, + canMutateAssignedEvent, + defaultToOnlyAssigned, + filterEventsToAssigned, + getAssignedIds, + hasAssignedMatch, + hideUnlessAssignedAny, + toIdArray, + unassignedAdditions, +} from '@/collections/access/assigned' import { regenerateOccurrencesForEvent } from '@/jobs/generateEventOccurrences' export const Events: CollectionConfig = { @@ -122,17 +131,17 @@ export const Events: CollectionConfig = { label: { de: 'Gemeinde', }, - validate: (value, options) => { - let user = options.req.user - - if (!user) { - return 'You are not allowed to do this' + defaultValue: defaultToOnlyAssigned('parishes'), + // A Gläubiger may only ADD their own parishes — unless they are + // assigned to a group; group members may attach any parish. + // Connections already on the doc may always be kept. + validate: (value: unknown, options: any) => { + const user = options.req?.user + if (!user || user.roles !== 'user') return true + if (getAssignedIds(user, 'groups').length > 0) return true + if (unassignedAdditions(user, 'parishes', value, options.previousValue).length > 0) { + return 'Sie können nur Gemeinden hinzufügen, die Ihnen zugewiesen sind.' } - - if (user.roles === 'user' && value && value.length > 0) { - return 'Sie sind nur erlaubt Veranstaltungen für Gruppen zu erstellen.' - } - return true }, }, @@ -144,38 +153,40 @@ export const Events: CollectionConfig = { label: { de: 'Gruppe', }, - access: { - update: ({req: { user}, data}) => { - if(user && (user.roles == "admin" || user.roles =="employee")) { - return true - } + defaultValue: defaultToOnlyAssigned('groups'), + // Combined rule for parish AND group (validated once, here): + // a Gläubiger's event needs at least one connection, and at + // least one of them must be an own assignment. Additional + // foreign connections are allowed. + validate: (value: unknown, options: any) => { + const user = options.req?.user - if(hasGroup(user, data)) { - return true - } - - return false - } - }, - validate: (value, options: { req: { user: any } }) => { - let user = options.req.user - - if (!user) { - return 'You are not allowed to do this' + // No user = local API (jobs, scripts); unauthenticated REST + // writes are already blocked by the collection access. + if (!user || user.roles !== 'user') { + return true } - if (user.roles === 'user') { - if(!Array.isArray(value) || value.length === 0) { - return 'Sie müssen die Veranstaltung verknüpfen mit ihrer Gruppe.' - } + const groupIds = toIdArray(value) + const parishIds = toIdArray(options.data?.parish) - if(!Array.isArray(user.groups) || user.groups.length === 0) { - return "Sie sind kein Mitglied einer Gruppe, und können deswegen keine Veranstaltung erstellen." - } + // Parish-assigned users may attach any group; otherwise only own groups may be added. + if ( + getAssignedIds(user, 'parishes').length === 0 && + unassignedAdditions(user, 'groups', value, options.previousValue).length > 0 + ) { + return 'Sie können nur Gruppen hinzufügen, die Ihnen zugewiesen sind.' + } - if(!value.every(id => user.groups.includes(id))) { - return "Sie sind nur berechtigt Veranstaltungen für ihrer Gruppe zu erstellen" - } + if (groupIds.length === 0 && parishIds.length === 0) { + return 'Sie müssen die Veranstaltung mit mindestens einer Gruppe oder Gemeinde verknüpfen.' + } + + if ( + !hasAssignedMatch(user, groupIds, 'groups') && + !hasAssignedMatch(user, parishIds, 'parishes') + ) { + return 'Mindestens eine der ausgewählten Gruppen oder Gemeinden muss Ihnen zugewiesen sein.' } return true @@ -369,6 +380,8 @@ export const Events: CollectionConfig = { ], admin: { useAsTitle: 'title', + hidden: hideUnlessAssignedAny(['groups', 'parishes']), + baseFilter: filterEventsToAssigned(), livePreview: { url: ({ data }) => `/api/draft?url=/veranstaltungen/${data.id}`, }, @@ -380,26 +393,9 @@ export const Events: CollectionConfig = { }, access: { read: isPublishedPublic(), - // admins and employees can delete, others only if they are member of the group - delete: async ({ req: { user }, id }) => { - if (!user) { - return false - } - - if(user.roles === 'admin' || user.roles === 'employee') - return true - - if(typeof id !== 'string') { - return false - } - - const event = await fetchEventById(id) - if (hasGroup(user, event)) { - return true - } - - return false - }, + create: canCreateAssignedEvent(), + update: canMutateAssignedEvent(), + delete: canMutateAssignedEvent(), }, hooks: { afterChange: [ @@ -450,29 +446,3 @@ export const Events: CollectionConfig = { }, } -/** - * Check if we have - * - a user - * - data with groups - * - the user is member of one of the groups - * - * @param user - * @param data - */ -const hasGroup = (user: null | User , data: Partial | undefined) => { - return user - && user.roles === 'user' - && data - && Array.isArray(data.group) - && data.group.length > 0 - && data.group.some((group: string | Group) => { - if (!Array.isArray(user.groups)) { - return false; - } - - if (typeof group === "string") - return user.groups.includes(group) - else - return user.groups.includes(group.id) - }) -} diff --git a/src/collections/Worship.ts b/src/collections/Worship.ts index b927f81..b92c871 100644 --- a/src/collections/Worship.ts +++ b/src/collections/Worship.ts @@ -1,5 +1,11 @@ import { CollectionConfig } from 'payload' -import { hide, isAdminOrEmployee } from '@/collections/access/admin' +import { + canCreateAssignedWorship, + canMutateAssignedWorship, + filterWorshipToAssigned, + hideUnlessAssigned, + resolveAssignedChurchIds, +} from '@/collections/access/assigned' export const Worship: CollectionConfig = { slug: 'worship', @@ -35,6 +41,17 @@ export const Worship: CollectionConfig = { type: 'relationship', relationTo: 'church', required: true, + // The update access Where only constrains which docs may be targeted; + // this keeps a Gläubiger from moving a worship doc to a foreign church. + validate: async (value: unknown, options: any) => { + const user = options.req?.user + if (!user || user.roles !== 'user') return true + const churchIds = await resolveAssignedChurchIds(options.req, user) + if (!value || !churchIds.includes(String(value))) { + return 'Sie sind nur berechtigt, Gottesdienste für Kirchen Ihrer zugewiesenen Gemeinden zu verwalten.' + } + return true + }, }, { name: 'type', @@ -125,12 +142,13 @@ export const Worship: CollectionConfig = { admin: { defaultColumns: ["date", 'location', 'type', 'celebrant'], listSearchableFields: ['date', 'location'], - hidden: hide + hidden: hideUnlessAssigned('parishes'), + baseFilter: filterWorshipToAssigned(), }, access: { read: () => true, - create: isAdminOrEmployee(), - update: isAdminOrEmployee(), - delete: isAdminOrEmployee(), + create: canCreateAssignedWorship(), + update: canMutateAssignedWorship(), + delete: canMutateAssignedWorship(), }, } diff --git a/src/collections/access/assigned.ts b/src/collections/access/assigned.ts index b178a78..4c6f7fe 100644 --- a/src/collections/access/assigned.ts +++ b/src/collections/access/assigned.ts @@ -1,4 +1,4 @@ -import type { Access, BaseFilter, ClientUser } from 'payload' +import type { Access, BaseFilter, ClientUser, PayloadRequest, Where } from 'payload' /** * Assignment fields on the Users collection: each holds the ids of the @@ -11,14 +11,23 @@ export type AssignmentField = 'groups' | 'parishes' | 'pages' * The fields use maxDepth: 0 so values are plain ids, but handle * populated objects defensively. */ -const getAssignedIds = (user: unknown, field: AssignmentField): string[] => { +export const getAssignedIds = (user: unknown, field: AssignmentField): string[] => { const value = (user as { [K in AssignmentField]?: unknown } | null | undefined)?.[field] - if (!Array.isArray(value)) return [] - return value.map((doc) => - typeof doc === 'object' && doc !== null ? String((doc as { id: unknown }).id) : String(doc), + return toIdArray(value) +} + +/** Normalize any relationship value (single or hasMany, id or populated doc) to id strings. */ +export const toIdArray = (value: unknown): string[] => { + if (value == null) return [] + const arr = Array.isArray(value) ? value : [value] + return arr.map((v) => + typeof v === 'object' && v !== null ? String((v as { id: unknown }).id) : String(v), ) } +const isStaff = (user: { roles?: unknown } | null | undefined): boolean => + user?.roles === 'admin' || user?.roles === 'employee' + /** * Update access: admin/employee always; role 'user' only for documents * they are assigned to. The collection-level probe (no id) is granted @@ -57,3 +66,168 @@ export const hideUnlessAssigned = if (!user || user.roles !== 'user') return false return getAssignedIds(user, field).length === 0 } + +/** + * admin.hidden: hide the collection from role 'user' unless they have at + * least one assignment in ANY of the given fields. + */ +export const hideUnlessAssignedAny = + (fields: AssignmentField[]) => + ({ user }: { user: ClientUser }): boolean => { + if (!user || user.roles !== 'user') return false + return fields.every((field) => getAssignedIds(user, field).length === 0) + } + +/** True if at least one of the given ids is in the user's assignment field. */ +export const hasAssignedMatch = (user: unknown, ids: unknown, field: AssignmentField): boolean => { + const assigned = getAssignedIds(user, field) + return toIdArray(ids).some((id) => assigned.includes(id)) +} + +/** + * defaultValue for hasMany relationship fields: when a Gläubiger has exactly + * one assignment, pre-fill it on new documents. Staff get no pre-fill. + */ +export const defaultToOnlyAssigned = + (field: AssignmentField) => + ({ user }: { user: unknown }): string[] | undefined => { + if ((user as { roles?: unknown } | null)?.roles !== 'user') return undefined + const ids = getAssignedIds(user, field) + return ids.length === 1 ? ids : undefined + } + +/** + * Ids in `value` that are neither carried over from `previousValue` nor among + * the user's own assignments — i.e. foreign connections the user is trying to + * ADD. Existing foreign connections (set by staff) are not flagged. + */ +export const unassignedAdditions = ( + user: unknown, + field: AssignmentField, + value: unknown, + previousValue: unknown, +): string[] => { + const assigned = getAssignedIds(user, field) + const previous = toIdArray(previousValue) + return toIdArray(value).filter((id) => !previous.includes(id) && !assigned.includes(id)) +} + +// --------------------------------------------------------------------------- +// Worship: a worship doc has no parish field — only `location` (church). +// A Gläubiger's scope is the union of churches of their assigned parishes. +// --------------------------------------------------------------------------- + +/** Resolve the user's assigned parishes to the union of their church ids. */ +export const resolveAssignedChurchIds = async ( + req: PayloadRequest, + user: unknown, +): Promise => { + const parishIds = getAssignedIds(user, 'parishes') + if (parishIds.length === 0) return [] + const { docs } = await req.payload.find({ + collection: 'parish', + where: { id: { in: parishIds } }, + depth: 0, + pagination: false, + select: { churches: true }, + req, + }) + return [...new Set(docs.flatMap((doc) => toIdArray((doc as { churches?: unknown }).churches)))] +} + +/** + * Worship create access. The admin UI probes create permission without data; + * the actual create always carries data, where `location` is enforced (and + * additionally validated on the field for a German error message). + */ +export const canCreateAssignedWorship = (): Access => + async ({ req, data }) => { + const user = req.user + if (!user) return false + if (isStaff(user)) return true + if (user.roles !== 'user') return false + const churchIds = await resolveAssignedChurchIds(req, user) + if (churchIds.length === 0) return false + if (data?.location === undefined) return true + return churchIds.includes(String(data.location)) + } + +/** Worship update/delete access: staff always; role 'user' only within their churches. */ +export const canMutateAssignedWorship = (): Access => + async ({ req }) => { + const user = req.user + if (!user) return false + if (isStaff(user)) return true + if (user.roles !== 'user') return false + const churchIds = await resolveAssignedChurchIds(req, user) + if (churchIds.length === 0) return false + return { location: { in: churchIds } } as Where + } + +/** Worship admin list filter: staff unfiltered; role 'user' only their churches. */ +export const filterWorshipToAssigned = (): BaseFilter => + async ({ req }) => { + const user = req.user + if (!user || user.roles !== 'user') return null + return { location: { in: await resolveAssignedChurchIds(req, user) } } + } + +// --------------------------------------------------------------------------- +// Events: connected to groups AND/OR parishes; one own match grants access. +// --------------------------------------------------------------------------- + +/** + * Where matching events with at least one assigned group OR parish. + * `prefix: 'event.'` targets the parent event from the occurrence collection. + * Returns false when the user has no assignments at all. + */ +export const eventMatchWhere = (user: unknown, prefix = ''): false | Where => { + const groupIds = getAssignedIds(user, 'groups') + const parishIds = getAssignedIds(user, 'parishes') + const or: Where[] = [] + if (groupIds.length > 0) or.push({ [`${prefix}group`]: { in: groupIds } }) + if (parishIds.length > 0) or.push({ [`${prefix}parish`]: { in: parishIds } }) + if (or.length === 0) return false + return { or } +} + +/** + * Event create access: staff always; role 'user' with at least one group or + * parish assignment. Which connections the new event must carry is enforced + * by the combined validate on the `group` field. + */ +export const canCreateAssignedEvent = (): Access => + ({ req: { user } }) => { + if (!user) return false + if (isStaff(user)) return true + if (user.roles !== 'user') return false + return ( + getAssignedIds(user, 'groups').length > 0 || getAssignedIds(user, 'parishes').length > 0 + ) + } + +/** Event update/delete access: staff always; role 'user' via one-match Where. */ +export const canMutateAssignedEvent = (): Access => + ({ req: { user } }) => { + if (!user) return false + if (isStaff(user)) return true + if (user.roles !== 'user') return false + return eventMatchWhere(user) + } + +/** Events admin list filter: staff unfiltered; role 'user' one-match filter. */ +export const filterEventsToAssigned = (): BaseFilter => + ({ req: { user } }) => { + if (!user || user.roles !== 'user') return null + const where = eventMatchWhere(user) + return where === false ? { id: { in: [] } } : where + } + +/** Occurrence update access: whoever may edit the parent event may toggle its occurrences. */ +export const canMutateOccurrenceOfAssignedEvent = (): Access => + ({ req: { user } }) => { + if (!user) return false + if (isStaff(user)) return true + if (user.roles !== 'user') return false + return eventMatchWhere(user, 'event.') + }