feature: worship and events auth
This commit is contained in:
parent
fb229a76b0
commit
b099197839
4 changed files with 261 additions and 96 deletions
|
|
@ -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(),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")) {
|
||||
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
|
||||
|
||||
// No user = local API (jobs, scripts); unauthenticated REST
|
||||
// writes are already blocked by the collection access.
|
||||
if (!user || user.roles !== 'user') {
|
||||
return true
|
||||
}
|
||||
|
||||
if(hasGroup(user, data)) {
|
||||
return true
|
||||
const groupIds = toIdArray(value)
|
||||
const parishIds = toIdArray(options.data?.parish)
|
||||
|
||||
// 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.'
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
validate: (value, options: { req: { user: any } }) => {
|
||||
let user = options.req.user
|
||||
|
||||
if (!user) {
|
||||
return 'You are not allowed to do this'
|
||||
if (groupIds.length === 0 && parishIds.length === 0) {
|
||||
return 'Sie müssen die Veranstaltung mit mindestens einer Gruppe oder Gemeinde verknüpfen.'
|
||||
}
|
||||
|
||||
if (user.roles === 'user') {
|
||||
if(!Array.isArray(value) || value.length === 0) {
|
||||
return 'Sie müssen die Veranstaltung verknüpfen mit ihrer Gruppe.'
|
||||
}
|
||||
|
||||
if(!Array.isArray(user.groups) || user.groups.length === 0) {
|
||||
return "Sie sind kein Mitglied einer Gruppe, und können deswegen keine Veranstaltung erstellen."
|
||||
}
|
||||
|
||||
if(!value.every(id => user.groups.includes(id))) {
|
||||
return "Sie sind nur berechtigt Veranstaltungen für ihrer Gruppe zu erstellen"
|
||||
}
|
||||
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<any> | 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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string[]> => {
|
||||
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.')
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue