feature: extended access for pages and parishes

This commit is contained in:
Benno Tielen 2026-07-16 11:27:55 +02:00
parent f2b927a359
commit fb229a76b0
10 changed files with 27773 additions and 27 deletions

View file

@ -1,5 +1,6 @@
import { CollectionConfig } from 'payload'
import { isAdminOrEmployee } from '@/collections/access/admin'
import { filterToAssigned, hideUnlessAssigned, isStaffOrAssigned } from '@/collections/access/assigned'
import { ParagraphBlock } from '@/collections/blocks/Paragraph'
import { GalleryBlock } from '@/collections/blocks/Gallery'
import { ContactformBlock } from '@/collections/blocks/Contactform'
@ -105,6 +106,8 @@ export const Groups: CollectionConfig = {
},
admin: {
useAsTitle: 'name',
hidden: hideUnlessAssigned('groups'),
baseFilter: filterToAssigned('groups'),
livePreview: {
url: ({ data }) => `/api/draft?url=/gruppe/${data.slug}`,
},
@ -117,24 +120,7 @@ export const Groups: CollectionConfig = {
access: {
read: isPublishedPublic(),
create: isAdminOrEmployee(),
update: ({ req, id }) => {
if (!req.user) {
return false
}
if (
req.user.roles === 'user' &&
id &&
req.user.groups?.find(
(group) =>
group === id || (typeof group === 'object' && group.id === id),
) === undefined
) {
return false
}
return true
},
update: isStaffOrAssigned('groups'),
delete: isAdminOrEmployee(),
},
}

View file

@ -1,6 +1,7 @@
import { CollectionConfig } from 'payload'
import { revalidateTag } from 'next/cache'
import { hide, isAdminOrEmployee } from '@/collections/access/admin'
import { isAdminOrEmployee } from '@/collections/access/admin'
import { filterToAssigned, hideUnlessAssigned, isStaffOrAssigned } from '@/collections/access/assigned'
import { ParagraphBlock } from '@/collections/blocks/Paragraph'
import { DocumentBlock } from '@/collections/blocks/Document'
import { ContactformBlock } from '@/collections/blocks/Contactform'
@ -119,7 +120,8 @@ export const Pages: CollectionConfig = {
],
admin: {
useAsTitle: 'title',
hidden: hide,
hidden: hideUnlessAssigned('pages'),
baseFilter: filterToAssigned('pages'),
livePreview: {
url: ({ data }) => `/api/draft?url=/${data.slug}`,
},
@ -132,7 +134,7 @@ export const Pages: CollectionConfig = {
access: {
read: isPublishedPublic(),
create: isAdminOrEmployee(),
update: isAdminOrEmployee(),
update: isStaffOrAssigned('pages'),
delete: isAdminOrEmployee(),
},
hooks: {

View file

@ -1,5 +1,6 @@
import { CollectionConfig } from 'payload'
import { hide, isAdmin, isAdminOrEmployee } from '@/collections/access/admin'
import { isAdmin } from '@/collections/access/admin'
import { filterToAssigned, hideUnlessAssigned, isStaffOrAssigned } from '@/collections/access/assigned'
import { ParagraphBlock } from '@/collections/blocks/Paragraph'
import { DocumentBlock } from '@/collections/blocks/Document'
import { DonationBlock } from '@/collections/blocks/Donation'
@ -181,7 +182,8 @@ export const Parish: CollectionConfig = {
],
admin: {
useAsTitle: 'name',
hidden: hide,
hidden: hideUnlessAssigned('parishes'),
baseFilter: filterToAssigned('parishes'),
livePreview: {
url: ({ data }) => `/api/draft?url=/gemeinde/${data.slug}`,
},
@ -194,7 +196,7 @@ export const Parish: CollectionConfig = {
access: {
read: isPublishedPublic(),
create: isAdmin(),
update: isAdminOrEmployee(),
update: isStaffOrAssigned('parishes'),
delete: isAdmin(),
},
}

View file

@ -63,13 +63,33 @@ export const Users: CollectionConfig = {
{
name: 'groups',
label: {
de: 'Mitgliedschaft',
de: 'Gruppen (Bearbeitungsrechte)',
},
type: 'relationship',
relationTo: 'group',
hasMany: true,
maxDepth: 0,
},
{
name: 'parishes',
label: {
de: 'Gemeinden (Bearbeitungsrechte)',
},
type: 'relationship',
relationTo: 'parish',
hasMany: true,
maxDepth: 0,
},
{
name: 'pages',
label: {
de: 'Seiten (Bearbeitungsrechte)',
},
type: 'relationship',
relationTo: 'pages',
hasMany: true,
maxDepth: 0,
},
],
access: {
read: isAdminOrEmployee(),

View file

@ -0,0 +1,59 @@
import type { Access, BaseFilter, ClientUser } from 'payload'
/**
* Assignment fields on the Users collection: each holds the ids of the
* documents a Gläubiger (role 'user') is allowed to edit.
*/
export type AssignmentField = 'groups' | 'parishes' | 'pages'
/**
* Normalize a user's assignment field to an array of id strings.
* The fields use maxDepth: 0 so values are plain ids, but handle
* populated objects defensively.
*/
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),
)
}
/**
* Update access: admin/employee always; role 'user' only for documents
* they are assigned to. The collection-level probe (no id) is granted
* only if the user has at least one assignment.
*/
export const isStaffOrAssigned =
(field: AssignmentField): Access =>
({ req: { user }, id }) => {
if (!user) return false
if (user.roles === 'admin' || user.roles === 'employee') return true
if (user.roles !== 'user') return false
const ids = getAssignedIds(user, field)
if (ids.length === 0) return false
if (id === undefined) return true
return ids.includes(String(id))
}
/**
* Admin base filter: staff see everything (null = unfiltered); role 'user'
* sees only assigned documents. An empty id list matches nothing.
*/
export const filterToAssigned =
(field: AssignmentField): BaseFilter =>
({ req: { user } }) => {
if (!user || user.roles !== 'user') return null
return { id: { in: getAssignedIds(user, field) } }
}
/**
* admin.hidden: hide the collection from role 'user' unless they have at
* least one assignment; never hidden for staff.
*/
export const hideUnlessAssigned =
(field: AssignmentField) =>
({ user }: { user: ClientUser }): boolean => {
if (!user || user.roles !== 'user') return false
return getAssignedIds(user, field).length === 0
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-07-19T09:00:32.028Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-07-19T09:00:32.325Z';
ALTER TABLE "blog_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'chemnitz@pfarrei-bddmei.de';
ALTER TABLE "_blog_v_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'chemnitz@pfarrei-bddmei.de';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-08-15T09:00:32.384Z';
ALTER TABLE "group_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'chemnitz@pfarrei-bddmei.de';
ALTER TABLE "_group_v_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'chemnitz@pfarrei-bddmei.de';
ALTER TABLE "pages_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'chemnitz@pfarrei-bddmei.de';
ALTER TABLE "_pages_v_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'chemnitz@pfarrei-bddmei.de';
ALTER TABLE "users_rels" ADD COLUMN "parish_id" uuid;
ALTER TABLE "users_rels" ADD COLUMN "pages_id" uuid;
ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_parish_fk" FOREIGN KEY ("parish_id") REFERENCES "public"."parish"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_pages_fk" FOREIGN KEY ("pages_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "users_rels_parish_id_idx" ON "users_rels" USING btree ("parish_id");
CREATE INDEX "users_rels_pages_id_idx" ON "users_rels" USING btree ("pages_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "users_rels" DROP CONSTRAINT "users_rels_parish_fk";
ALTER TABLE "users_rels" DROP CONSTRAINT "users_rels_pages_fk";
DROP INDEX "users_rels_parish_id_idx";
DROP INDEX "users_rels_pages_id_idx";
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-06-14T08:49:19.434Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-06-14T08:49:19.726Z';
ALTER TABLE "blog_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'kontakt@mutter-teresa-chemnitz.de';
ALTER TABLE "_blog_v_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'kontakt@mutter-teresa-chemnitz.de';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-07-11T08:49:19.782Z';
ALTER TABLE "group_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'kontakt@mutter-teresa-chemnitz.de';
ALTER TABLE "_group_v_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'kontakt@mutter-teresa-chemnitz.de';
ALTER TABLE "pages_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'kontakt@mutter-teresa-chemnitz.de';
ALTER TABLE "_pages_v_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'kontakt@mutter-teresa-chemnitz.de';
ALTER TABLE "users_rels" DROP COLUMN "parish_id";
ALTER TABLE "users_rels" DROP COLUMN "pages_id";`)
}

View file

@ -51,6 +51,7 @@ import * as migration_20260605_130843_add_worship_language_other from './2026060
import * as migration_20260609_113259_parish_gallery_caption from './20260609_113259_parish_gallery_caption';
import * as migration_20260611_072650_add_contact_person_to_group from './20260611_072650_add_contact_person_to_group';
import * as migration_20260611_084920_add_image_with_text_block from './20260611_084920_add_image_with_text_block';
import * as migration_20260716_090032_add_user_parish_page_assignments from './20260716_090032_add_user_parish_page_assignments';
export const migrations = [
{
@ -316,6 +317,11 @@ export const migrations = [
{
up: migration_20260611_084920_add_image_with_text_block.up,
down: migration_20260611_084920_add_image_with_text_block.down,
name: '20260611_084920_add_image_with_text_block'
name: '20260611_084920_add_image_with_text_block',
},
{
up: migration_20260716_090032_add_user_parish_page_assignments.up,
down: migration_20260716_090032_add_user_parish_page_assignments.down,
name: '20260716_090032_add_user_parish_page_assignments'
},
];

View file

@ -1356,6 +1356,8 @@ export interface User {
name: string;
roles: 'user' | 'employee' | 'admin';
groups?: (string | Group)[] | null;
parishes?: (string | Parish)[] | null;
pages?: (string | Page)[] | null;
updatedAt: string;
createdAt: string;
email: string;
@ -1375,7 +1377,7 @@ export interface User {
password?: string | null;
}
/**
* This is a collection of automatically created search results. These results are used by the global site search and will be updated automatically as documents in the CMS are created or updated.
* Automatisch erzeugte Suchergebnisse. Sie werden von der Website-Suche verwendet und aktualisieren sich selbst, sobald Inhalte erstellt oder geändert werden.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "search".
@ -2607,6 +2609,8 @@ export interface UsersSelect<T extends boolean = true> {
name?: T;
roles?: T;
groups?: T;
parishes?: T;
pages?: T;
updatedAt?: T;
createdAt?: T;
email?: T;

View file

@ -47,6 +47,7 @@ import { siteConfig } from '@/config/site'
import { generateRecurringMassesTask } from '@/jobs/generateRecurringMasses'
import { generateEventOccurrencesTask } from '@/jobs/generateEventOccurrences'
import { searchPlugin } from '@payloadcms/plugin-search'
import { hide } from '@/collections/access/admin'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@ -226,6 +227,20 @@ export default buildConfig({
// read each referenced document's `slug` to build URLs like
// /gemeinde/[slug] and /gruppe/[slug].
searchOverrides: {
labels: {
singular: {
de: 'Suchergebnis',
},
plural: {
de: 'Suchergebnisse',
},
},
admin: {
hidden: hide,
description: {
de: 'Automatisch erzeugte Suchergebnisse. Sie werden von der Website-Suche verwendet und aktualisieren sich selbst, sobald Inhalte erstellt oder geändert werden.',
},
},
fields: ({ defaultFields }) =>
defaultFields.map((field) =>
'name' in field && field.name === 'doc' && field.type === 'relationship'