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 { CollectionConfig } from 'payload'
|
||||||
import { 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 { ParagraphBlock } from '@/collections/blocks/Paragraph'
|
||||||
import { GalleryBlock } from '@/collections/blocks/Gallery'
|
import { GalleryBlock } from '@/collections/blocks/Gallery'
|
||||||
import { ContactformBlock } from '@/collections/blocks/Contactform'
|
import { ContactformBlock } from '@/collections/blocks/Contactform'
|
||||||
|
|
@ -105,6 +106,8 @@ export const Groups: CollectionConfig = {
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
useAsTitle: 'name',
|
useAsTitle: 'name',
|
||||||
|
hidden: hideUnlessAssigned('groups'),
|
||||||
|
baseFilter: filterToAssigned('groups'),
|
||||||
livePreview: {
|
livePreview: {
|
||||||
url: ({ data }) => `/api/draft?url=/gruppe/${data.slug}`,
|
url: ({ data }) => `/api/draft?url=/gruppe/${data.slug}`,
|
||||||
},
|
},
|
||||||
|
|
@ -117,24 +120,7 @@ export const Groups: CollectionConfig = {
|
||||||
access: {
|
access: {
|
||||||
read: isPublishedPublic(),
|
read: isPublishedPublic(),
|
||||||
create: isAdminOrEmployee(),
|
create: isAdminOrEmployee(),
|
||||||
update: ({ req, id }) => {
|
update: isStaffOrAssigned('groups'),
|
||||||
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
|
|
||||||
},
|
|
||||||
delete: isAdminOrEmployee(),
|
delete: isAdminOrEmployee(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { CollectionConfig } from 'payload'
|
import { CollectionConfig } from 'payload'
|
||||||
import { revalidateTag } from 'next/cache'
|
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 { ParagraphBlock } from '@/collections/blocks/Paragraph'
|
||||||
import { DocumentBlock } from '@/collections/blocks/Document'
|
import { DocumentBlock } from '@/collections/blocks/Document'
|
||||||
import { ContactformBlock } from '@/collections/blocks/Contactform'
|
import { ContactformBlock } from '@/collections/blocks/Contactform'
|
||||||
|
|
@ -119,7 +120,8 @@ export const Pages: CollectionConfig = {
|
||||||
],
|
],
|
||||||
admin: {
|
admin: {
|
||||||
useAsTitle: 'title',
|
useAsTitle: 'title',
|
||||||
hidden: hide,
|
hidden: hideUnlessAssigned('pages'),
|
||||||
|
baseFilter: filterToAssigned('pages'),
|
||||||
livePreview: {
|
livePreview: {
|
||||||
url: ({ data }) => `/api/draft?url=/${data.slug}`,
|
url: ({ data }) => `/api/draft?url=/${data.slug}`,
|
||||||
},
|
},
|
||||||
|
|
@ -132,7 +134,7 @@ export const Pages: CollectionConfig = {
|
||||||
access: {
|
access: {
|
||||||
read: isPublishedPublic(),
|
read: isPublishedPublic(),
|
||||||
create: isAdminOrEmployee(),
|
create: isAdminOrEmployee(),
|
||||||
update: isAdminOrEmployee(),
|
update: isStaffOrAssigned('pages'),
|
||||||
delete: isAdminOrEmployee(),
|
delete: isAdminOrEmployee(),
|
||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { CollectionConfig } from 'payload'
|
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 { ParagraphBlock } from '@/collections/blocks/Paragraph'
|
||||||
import { DocumentBlock } from '@/collections/blocks/Document'
|
import { DocumentBlock } from '@/collections/blocks/Document'
|
||||||
import { DonationBlock } from '@/collections/blocks/Donation'
|
import { DonationBlock } from '@/collections/blocks/Donation'
|
||||||
|
|
@ -181,7 +182,8 @@ export const Parish: CollectionConfig = {
|
||||||
],
|
],
|
||||||
admin: {
|
admin: {
|
||||||
useAsTitle: 'name',
|
useAsTitle: 'name',
|
||||||
hidden: hide,
|
hidden: hideUnlessAssigned('parishes'),
|
||||||
|
baseFilter: filterToAssigned('parishes'),
|
||||||
livePreview: {
|
livePreview: {
|
||||||
url: ({ data }) => `/api/draft?url=/gemeinde/${data.slug}`,
|
url: ({ data }) => `/api/draft?url=/gemeinde/${data.slug}`,
|
||||||
},
|
},
|
||||||
|
|
@ -194,7 +196,7 @@ export const Parish: CollectionConfig = {
|
||||||
access: {
|
access: {
|
||||||
read: isPublishedPublic(),
|
read: isPublishedPublic(),
|
||||||
create: isAdmin(),
|
create: isAdmin(),
|
||||||
update: isAdminOrEmployee(),
|
update: isStaffOrAssigned('parishes'),
|
||||||
delete: isAdmin(),
|
delete: isAdmin(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,13 +63,33 @@ export const Users: CollectionConfig = {
|
||||||
{
|
{
|
||||||
name: 'groups',
|
name: 'groups',
|
||||||
label: {
|
label: {
|
||||||
de: 'Mitgliedschaft',
|
de: 'Gruppen (Bearbeitungsrechte)',
|
||||||
},
|
},
|
||||||
type: 'relationship',
|
type: 'relationship',
|
||||||
relationTo: 'group',
|
relationTo: 'group',
|
||||||
hasMany: true,
|
hasMany: true,
|
||||||
maxDepth: 0,
|
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: {
|
access: {
|
||||||
read: isAdminOrEmployee(),
|
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_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_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_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 = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -316,6 +317,11 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260611_084920_add_image_with_text_block.up,
|
up: migration_20260611_084920_add_image_with_text_block.up,
|
||||||
down: migration_20260611_084920_add_image_with_text_block.down,
|
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;
|
name: string;
|
||||||
roles: 'user' | 'employee' | 'admin';
|
roles: 'user' | 'employee' | 'admin';
|
||||||
groups?: (string | Group)[] | null;
|
groups?: (string | Group)[] | null;
|
||||||
|
parishes?: (string | Parish)[] | null;
|
||||||
|
pages?: (string | Page)[] | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -1375,7 +1377,7 @@ export interface User {
|
||||||
password?: string | null;
|
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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "search".
|
* via the `definition` "search".
|
||||||
|
|
@ -2607,6 +2609,8 @@ export interface UsersSelect<T extends boolean = true> {
|
||||||
name?: T;
|
name?: T;
|
||||||
roles?: T;
|
roles?: T;
|
||||||
groups?: T;
|
groups?: T;
|
||||||
|
parishes?: T;
|
||||||
|
pages?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
email?: T;
|
email?: T;
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import { siteConfig } from '@/config/site'
|
||||||
import { generateRecurringMassesTask } from '@/jobs/generateRecurringMasses'
|
import { generateRecurringMassesTask } from '@/jobs/generateRecurringMasses'
|
||||||
import { generateEventOccurrencesTask } from '@/jobs/generateEventOccurrences'
|
import { generateEventOccurrencesTask } from '@/jobs/generateEventOccurrences'
|
||||||
import { searchPlugin } from '@payloadcms/plugin-search'
|
import { searchPlugin } from '@payloadcms/plugin-search'
|
||||||
|
import { hide } from '@/collections/access/admin'
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(filename)
|
const dirname = path.dirname(filename)
|
||||||
|
|
@ -226,6 +227,20 @@ export default buildConfig({
|
||||||
// read each referenced document's `slug` to build URLs like
|
// read each referenced document's `slug` to build URLs like
|
||||||
// /gemeinde/[slug] and /gruppe/[slug].
|
// /gemeinde/[slug] and /gruppe/[slug].
|
||||||
searchOverrides: {
|
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 }) =>
|
fields: ({ defaultFields }) =>
|
||||||
defaultFields.map((field) =>
|
defaultFields.map((field) =>
|
||||||
'name' in field && field.name === 'doc' && field.type === 'relationship'
|
'name' in field && field.name === 'doc' && field.type === 'relationship'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue