feature: extended access for pages and parishes
This commit is contained in:
parent
f2b927a359
commit
fb229a76b0
10 changed files with 27773 additions and 27 deletions
|
|
@ -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(),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
59
src/collections/access/assigned.ts
Normal file
59
src/collections/access/assigned.ts
Normal 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
|
||||
}
|
||||
27611
src/migrations/20260716_090032_add_user_parish_page_assignments.json
Normal file
27611
src/migrations/20260716_090032_add_user_parish_page_assignments.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue