From 7cf4f46d9486a3989f74f3d8afa90f2ba22fae1d Mon Sep 17 00:00:00 2001 From: Anakin Skywalker Pactores Date: Wed, 29 Jul 2026 23:44:01 +0800 Subject: [PATCH 01/10] chore: add service account collection --- src/collections/ServiceAccounts.ts | 94 ++++++++++++++++++++ src/collections/Users/index.ts | 2 +- src/constants/collections.ts | 4 +- src/payload-types.ts | 135 +++++++++++++++++++++++++++-- src/payload.config.ts | 3 +- 5 files changed, 226 insertions(+), 12 deletions(-) create mode 100644 src/collections/ServiceAccounts.ts diff --git a/src/collections/ServiceAccounts.ts b/src/collections/ServiceAccounts.ts new file mode 100644 index 0000000..2310db6 --- /dev/null +++ b/src/collections/ServiceAccounts.ts @@ -0,0 +1,94 @@ +import type { CollectionConfig } from 'payload' +import { anyAdmin } from '@/access/anyAdmin' +import { USER_ROLE_LABELS, USER_ROLES } from '@/constants/userRoles' +import { + COLLECTIONS, + COLLECTION_FOR_PERMISSION_OPTIONS, + COLLECTION_LABELS, + getCollectionGroupLabel, +} from '@/constants/collections' +import { + COLLECTION_PERMISSION_LABELS, + COLLECTION_PERMISSIONS, +} from '@/constants/collectionPermissions' +import { adminOrSelf } from '@/access/adminOrSelf' + +export const ServiceAccounts: CollectionConfig = { + slug: COLLECTIONS.SERVICE_ACCOUNTS, + labels: COLLECTION_LABELS[COLLECTIONS.SERVICE_ACCOUNTS], + access: { + admin: ({ req: { user } }) => { + const allowedRoles = Object.values(USER_ROLES) + return Boolean(user && user.role.some((role) => allowedRoles.includes(role))) + }, + create: anyAdmin, + delete: anyAdmin, + read: adminOrSelf, + update: adminOrSelf, + }, + admin: { + defaultColumns: ['name', 'email', 'createdAt'], + useAsTitle: 'name', + hidden({ user }) { + if (!user) return true + return !user.role.includes(USER_ROLES.SUPER_ADMIN) && !user.role.includes(USER_ROLES.ADMIN) + }, + group: getCollectionGroupLabel('admin'), + }, + auth: { + useAPIKey: true, + }, + fields: [ + { + name: 'name', + type: 'text', + required: true, + label: 'Service Account Name', + }, + { + name: 'role', + type: 'select', + required: true, + saveToJWT: true, + hasMany: true, + access: { + create: ({ req }) => anyAdmin({ req }), + update: ({ req }) => anyAdmin({ req }), + }, + options: Object.values(USER_ROLES).map((value) => ({ + value, + label: USER_ROLE_LABELS[value], + })), + }, + { + name: 'allowedCollections', + type: 'array', + access: { + create: ({ req }) => anyAdmin({ req }), + update: ({ req }) => anyAdmin({ req }), + }, + fields: [ + { + name: 'collectionOrGroupSlug', + type: 'select', + hasMany: false, + required: true, + options: COLLECTION_FOR_PERMISSION_OPTIONS.map(({ slug, label }) => ({ + value: slug, + label, + })), + }, + { + name: 'permissions', + type: 'radio', + required: true, + options: Object.values(COLLECTION_PERMISSIONS).map((permission) => ({ + value: permission, + label: COLLECTION_PERMISSION_LABELS[permission], + })), + }, + ], + }, + ], + timestamps: true, +} diff --git a/src/collections/Users/index.ts b/src/collections/Users/index.ts index fc76001..cd9a425 100644 --- a/src/collections/Users/index.ts +++ b/src/collections/Users/index.ts @@ -51,7 +51,7 @@ export const Users: CollectionConfig = { if (!user) return true return !user.role.includes(USER_ROLES.SUPER_ADMIN) && !user.role.includes(USER_ROLES.ADMIN) }, - group: getCollectionGroupLabel('durianpy-website'), + group: getCollectionGroupLabel('admin'), }, auth: { cookies: { diff --git a/src/constants/collections.ts b/src/constants/collections.ts index fcf8efc..f121b00 100644 --- a/src/constants/collections.ts +++ b/src/constants/collections.ts @@ -14,6 +14,7 @@ export const COLLECTIONS = { MEDIA: 'media', USERS: 'users', SAMPLE: 'sample', + SERVICE_ACCOUNTS: 'service-accounts', DURIANPY_WEBSITE_EVENTS: 'durianpy-website-events', DURIANPY_WEBSITE_SPONSORS: 'durianpy-website-sponsors', DURIANPY_WEBSITE_SIGS: 'durianpy-website-sigs', @@ -29,6 +30,7 @@ export const COLLECTION_LABELS: Record = { 'durianpy-website': [COLLECTIONS.DURIANPY_WEBSITE_EVENTS, COLLECTIONS.DURIANPY_WEBSITE_SPONSORS], - admin: ['users'], + admin: ['users', 'service-accounts'], } as const export function getCollectionGroupItems(groupSlug: CollectionGroupSlug) { diff --git a/src/payload-types.ts b/src/payload-types.ts index 72be1c6..4415104 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -64,6 +64,7 @@ export type SupportedTimezones = export interface Config { auth: { users: UserAuthOperations; + 'service-accounts': ServiceAccountAuthOperations; }; blocks: {}; collections: { @@ -71,6 +72,7 @@ export interface Config { categories: Category; users: User; sample: Sample; + 'service-accounts': ServiceAccount; 'durianpy-website-events': DurianpyWebsiteEvent; 'durianpy-website-sponsors': DurianpyWebsiteSponsor; 'durianpy-website-sigs': DurianpyWebsiteSig; @@ -93,6 +95,7 @@ export interface Config { categories: CategoriesSelect | CategoriesSelect; users: UsersSelect | UsersSelect; sample: SampleSelect | SampleSelect; + 'service-accounts': ServiceAccountsSelect | ServiceAccountsSelect; 'durianpy-website-events': DurianpyWebsiteEventsSelect | DurianpyWebsiteEventsSelect; 'durianpy-website-sponsors': DurianpyWebsiteSponsorsSelect | DurianpyWebsiteSponsorsSelect; 'durianpy-website-sigs': DurianpyWebsiteSigsSelect | DurianpyWebsiteSigsSelect; @@ -127,7 +130,7 @@ export interface Config { widgets: { collections: CollectionsWidget; }; - user: User; + user: User | ServiceAccount; jobs: { tasks: { schedulePublish: TaskSchedulePublish; @@ -157,6 +160,24 @@ export interface UserAuthOperations { password: string; }; } +export interface ServiceAccountAuthOperations { + forgotPassword: { + email: string; + password: string; + }; + login: { + email: string; + password: string; + }; + registerFirstUser: { + email: string; + password: string; + }; + unlock: { + email: string; + password: string; + }; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "media". @@ -318,6 +339,7 @@ export interface User { | 'media' | 'users' | 'sample' + | 'service-accounts' | 'durianpy-website-events' | 'durianpy-website-sponsors' | 'durianpy-website-sigs' @@ -361,6 +383,53 @@ export interface Sample { createdAt: string; _status?: ('draft' | 'published') | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "service-accounts". + */ +export interface ServiceAccount { + id: string; + name: string; + role: ('super-admin' | 'admin' | 'writer' | 'reader')[]; + allowedCollections?: + | { + collectionOrGroupSlug: + | 'admin' + | 'durianpy-website' + | 'categories' + | 'media' + | 'users' + | 'sample' + | 'service-accounts' + | 'durianpy-website-events' + | 'durianpy-website-sponsors' + | 'durianpy-website-sigs'; + permissions: 'read' | 'read-write' | 'full-access'; + id?: string | null; + }[] + | null; + updatedAt: string; + createdAt: string; + enableAPIKey?: boolean | null; + apiKey?: string | null; + apiKeyIndex?: string | null; + email: string; + resetPasswordToken?: string | null; + resetPasswordExpiration?: string | null; + salt?: string | null; + hash?: string | null; + loginAttempts?: number | null; + lockUntil?: string | null; + sessions?: + | { + id: string; + createdAt?: string | null; + expiresAt: string; + }[] + | null; + password?: string | null; + collection: 'service-accounts'; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "durianpy-website-events". @@ -733,6 +802,10 @@ export interface PayloadLockedDocument { relationTo: 'sample'; value: string | Sample; } | null) + | ({ + relationTo: 'service-accounts'; + value: string | ServiceAccount; + } | null) | ({ relationTo: 'durianpy-website-events'; value: string | DurianpyWebsiteEvent; @@ -758,10 +831,15 @@ export interface PayloadLockedDocument { value: string | FolderInterface; } | null); globalSlug?: string | null; - user: { - relationTo: 'users'; - value: string | User; - }; + user: + | { + relationTo: 'users'; + value: string | User; + } + | { + relationTo: 'service-accounts'; + value: string | ServiceAccount; + }; updatedAt: string; createdAt: string; } @@ -771,10 +849,15 @@ export interface PayloadLockedDocument { */ export interface PayloadPreference { id: string; - user: { - relationTo: 'users'; - value: string | User; - }; + user: + | { + relationTo: 'users'; + value: string | User; + } + | { + relationTo: 'service-accounts'; + value: string | ServiceAccount; + }; key?: string | null; value?: | { @@ -956,6 +1039,40 @@ export interface SampleSelect { createdAt?: T; _status?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "service-accounts_select". + */ +export interface ServiceAccountsSelect { + name?: T; + role?: T; + allowedCollections?: + | T + | { + collectionOrGroupSlug?: T; + permissions?: T; + id?: T; + }; + updatedAt?: T; + createdAt?: T; + enableAPIKey?: T; + apiKey?: T; + apiKeyIndex?: T; + email?: T; + resetPasswordToken?: T; + resetPasswordExpiration?: T; + salt?: T; + hash?: T; + loginAttempts?: T; + lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "durianpy-website-events_select". diff --git a/src/payload.config.ts b/src/payload.config.ts index eb38f8a..3beaf73 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -10,6 +10,7 @@ import { s3Storage } from '@payloadcms/storage-s3' import { Categories } from './collections/Categories' import { Media } from './collections/Media' import { Users } from './collections/Users' +import { ServiceAccounts } from './collections/ServiceAccounts' import { plugins } from './plugins' import { defaultLexical } from '@/fields/defaultLexical' import { getServerSideURL, getServerSideOrigin } from './utilities/getURL' @@ -101,7 +102,7 @@ export default buildConfig({ }, }), }), - collections: [Media, Categories, Users, Sample, Events, Sponsors, SIGs], + collections: [Media, Categories, Users, Sample, ServiceAccounts, Events, Sponsors, SIGs], globals: [HomepageConfig, CTASection, StatisticsConfig, Carousel, CodeOfConduct], cors: [getServerSideOrigin()].filter(Boolean).map((url) => { try { From 73a02ecda6d2931047fdf8b3496d8cc1f574deb6 Mon Sep 17 00:00:00 2001 From: Anakin Skywalker Pactores Date: Tue, 4 Aug 2026 21:12:58 +0800 Subject: [PATCH 02/10] chore: refactor service account collection --- src/collections/ServiceAccounts.ts | 8 +++----- src/constants/collections.ts | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/collections/ServiceAccounts.ts b/src/collections/ServiceAccounts.ts index 2310db6..f656642 100644 --- a/src/collections/ServiceAccounts.ts +++ b/src/collections/ServiceAccounts.ts @@ -4,6 +4,7 @@ import { USER_ROLE_LABELS, USER_ROLES } from '@/constants/userRoles' import { COLLECTIONS, COLLECTION_FOR_PERMISSION_OPTIONS, + COLLECTION_GROUPS, COLLECTION_LABELS, getCollectionGroupLabel, } from '@/constants/collections' @@ -17,10 +18,7 @@ export const ServiceAccounts: CollectionConfig = { slug: COLLECTIONS.SERVICE_ACCOUNTS, labels: COLLECTION_LABELS[COLLECTIONS.SERVICE_ACCOUNTS], access: { - admin: ({ req: { user } }) => { - const allowedRoles = Object.values(USER_ROLES) - return Boolean(user && user.role.some((role) => allowedRoles.includes(role))) - }, + admin: anyAdmin, create: anyAdmin, delete: anyAdmin, read: adminOrSelf, @@ -33,7 +31,7 @@ export const ServiceAccounts: CollectionConfig = { if (!user) return true return !user.role.includes(USER_ROLES.SUPER_ADMIN) && !user.role.includes(USER_ROLES.ADMIN) }, - group: getCollectionGroupLabel('admin'), + group: getCollectionGroupLabel(COLLECTION_GROUPS.ADMIN), }, auth: { useAPIKey: true, diff --git a/src/constants/collections.ts b/src/constants/collections.ts index f121b00..dc0e1dd 100644 --- a/src/constants/collections.ts +++ b/src/constants/collections.ts @@ -59,7 +59,7 @@ export function getCollectionGroupLabel(groupSlug: CollectionGroupSlug) { export const COLLECTION_GROUP_ITEMS: Record = { 'durianpy-website': [COLLECTIONS.DURIANPY_WEBSITE_EVENTS, COLLECTIONS.DURIANPY_WEBSITE_SPONSORS], - admin: ['users', 'service-accounts'], + admin: [COLLECTIONS.USERS, COLLECTIONS.SERVICE_ACCOUNTS], } as const export function getCollectionGroupItems(groupSlug: CollectionGroupSlug) { From ed6eee61a4329765bdb29c02ca894585fa809813 Mon Sep 17 00:00:00 2001 From: seangaaab Date: Mon, 10 Aug 2026 16:15:46 +0000 Subject: [PATCH 03/10] refactor: rearranged constants and utility functions --- package-lock.json | 33 ------- src/access/checkCollectionAccess.ts | 54 ------------ src/access/checkResourceAccess.ts | 50 +++++++++++ src/app/(payload)/admin/importMap.js | 86 +++++++++++++++---- src/collections/Media.ts | 3 +- src/collections/ServiceAccounts.ts | 33 ++++--- src/collections/Users/index.ts | 28 +++--- src/collections/durianpy-website/Events.ts | 11 ++- src/collections/durianpy-website/SIGs.ts | 14 ++- src/collections/durianpy-website/Sponsors.ts | 14 ++- .../sample-website-collection.index.ts | 9 +- src/constants/accessTypes.ts | 18 ++++ src/constants/collectionPermissions.ts | 32 ------- src/constants/collections.ts | 75 +--------------- src/constants/durianpy.ts | 51 +++++++++++ src/constants/globals.ts | 31 ++----- src/constants/permissions.ts | 47 ++++++++++ src/constants/sidebarGroup.ts | 41 +++++++++ src/globals/durianpy-website/CTASection.ts | 10 +-- src/globals/durianpy-website/Carousel.ts | 10 +-- src/globals/durianpy-website/CodeOfConduct.ts | 10 +-- .../durianpy-website/HomepageConfig.ts | 10 +-- .../durianpy-website/StatisticsConfig.ts | 10 +-- src/payload-types.ts | 72 ++++++---------- 24 files changed, 383 insertions(+), 369 deletions(-) delete mode 100644 src/access/checkCollectionAccess.ts create mode 100644 src/access/checkResourceAccess.ts delete mode 100644 src/constants/collectionPermissions.ts create mode 100644 src/constants/durianpy.ts create mode 100644 src/constants/permissions.ts create mode 100644 src/constants/sidebarGroup.ts diff --git a/package-lock.json b/package-lock.json index c1a7d38..0e405c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3114,9 +3114,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3134,9 +3131,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3154,9 +3148,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3174,9 +3165,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3194,9 +3182,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3214,9 +3199,6 @@ "ia32" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3234,9 +3216,6 @@ "ia32" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3254,9 +3233,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3274,9 +3250,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3294,9 +3267,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3314,9 +3284,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/src/access/checkCollectionAccess.ts b/src/access/checkCollectionAccess.ts deleted file mode 100644 index 064a1c5..0000000 --- a/src/access/checkCollectionAccess.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { AccessArgs } from 'payload' -import type { User } from '@/payload-types' -import { - getCollectionGroupItems, - getCollectionSlugType, - type CollectionGroupSlug, - type CollectionSlug, -} from '@/constants/collections' -import { anyAdmin } from './anyAdmin' -import { AccessType } from '@/constants/accessTypes' -import { COLLECTION_PERMISSION_TO_ACCESS_TYPES } from '@/constants/collectionPermissions' -import { GlobalSlug } from '@/constants/globals' - -export function checkCollectionAccess( - { req }: AccessArgs, - collectionSlug: CollectionSlug | GlobalSlug, - accessType?: AccessType, -) { - const user = req.user - - if (!user) { - return false - } - - if (anyAdmin({ req })) { - return true - } - - const allowedCollections = user.allowedCollections || [] - - if (allowedCollections.length === 0 || !accessType) { - return false - } - - return allowedCollections.some((assignment) => { - const assignedSlug = assignment.collectionOrGroupSlug - const slugType = getCollectionSlugType(assignedSlug) - let isApplicable = false - - if (slugType === 'group') { - const groupItems = getCollectionGroupItems(assignedSlug as CollectionGroupSlug) - isApplicable = groupItems.includes(collectionSlug as CollectionSlug) - } else if (slugType === 'collection') { - isApplicable = assignedSlug === collectionSlug - } - - if (isApplicable) { - const grantedAccessTypes = COLLECTION_PERMISSION_TO_ACCESS_TYPES[assignment.permissions] - return grantedAccessTypes.includes(accessType) - } - - return false - }) -} diff --git a/src/access/checkResourceAccess.ts b/src/access/checkResourceAccess.ts new file mode 100644 index 0000000..696624c --- /dev/null +++ b/src/access/checkResourceAccess.ts @@ -0,0 +1,50 @@ +import type { AccessArgs } from 'payload' +import type { User } from '@/payload-types' +import { anyAdmin } from './anyAdmin' +import { AccessType, getSlugType } from '@/constants/accessTypes' +import { PERMISSION_TO_ACCESS_TYPES } from '@/constants/permissions' +import { GlobalSlug } from '@/constants/globals' +import { CollectionSlug } from '@/constants/collections' +import { getSidebarGroupItems, SidebarGroupSlug } from '@/constants/sidebarGroup' + +export function checkResourceAccess( + { req }: AccessArgs, + resourceSlug: CollectionSlug | GlobalSlug, + accessType?: AccessType, +) { + const user = req.user + + if (!user) { + return false + } + + if (anyAdmin({ req })) { + return true + } + + const permissions = user.permissions || [] + + if (permissions.length === 0 || !accessType) { + return false + } + + return permissions.some((assignment) => { + const assignedSlug = assignment.resource + const slugType = getSlugType(assignedSlug) + let isApplicable = false + + if (slugType === 'group') { + const groupItems = getSidebarGroupItems(assignedSlug as SidebarGroupSlug) + isApplicable = groupItems.includes(resourceSlug as CollectionSlug) + } else if (slugType === 'collection') { + isApplicable = assignedSlug === resourceSlug + } + + if (isApplicable) { + const grantedAccessTypes = PERMISSION_TO_ACCESS_TYPES[assignment.accessLevel] + return grantedAccessTypes.includes(accessType) + } + + return false + }) +} diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index f30b09e..27874a9 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -13,27 +13,77 @@ import { FolderField as FolderField_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@pa import { SlugField as SlugField_2b8867833a34864a02ddf429b0728a40 } from '@payloadcms/next/client' import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { FolderTypeField as FolderTypeField_2b8867833a34864a02ddf429b0728a40 } from '@payloadcms/next/client' +import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { default as default_1a7510af427896d367a49dbf838d2de6 } from '@/components/BeforeDashboard' import { default as default_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' export const importMap = { - "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, - "@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, - "@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e, - "@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/next/rsc#FolderTableCell": FolderTableCell_f9c02e79a4aed9a3924487c0cd4cafb1, - "@payloadcms/next/rsc#FolderField": FolderField_f9c02e79a4aed9a3924487c0cd4cafb1, - "@payloadcms/next/client#SlugField": SlugField_2b8867833a34864a02ddf429b0728a40, - "@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, - "@payloadcms/next/client#FolderTypeField": FolderTypeField_2b8867833a34864a02ddf429b0728a40, - "@/components/BeforeDashboard#default": default_1a7510af427896d367a49dbf838d2de6, - "@/components/BeforeLogin#default": default_8a7ab0eb7ab5c511aba12e68480bfe5e, - "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 + '@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell': + RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, + '@payloadcms/richtext-lexical/rsc#RscEntryLexicalField': + RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, + '@payloadcms/richtext-lexical/rsc#LexicalDiffComponent': + LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e, + '@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient': + InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient': + FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#ParagraphFeatureClient': + ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#UnderlineFeatureClient': + UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#BoldFeatureClient': + BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#ItalicFeatureClient': + ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#LinkFeatureClient': + LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/next/rsc#FolderTableCell': FolderTableCell_f9c02e79a4aed9a3924487c0cd4cafb1, + '@payloadcms/next/rsc#FolderField': FolderField_f9c02e79a4aed9a3924487c0cd4cafb1, + '@payloadcms/next/client#SlugField': SlugField_2b8867833a34864a02ddf429b0728a40, + '@payloadcms/richtext-lexical/client#HeadingFeatureClient': + HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/next/client#FolderTypeField': FolderTypeField_2b8867833a34864a02ddf429b0728a40, + '@payloadcms/richtext-lexical/client#StrikethroughFeatureClient': + StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient': + HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#UploadFeatureClient': + UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#BlockquoteFeatureClient': + BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#RelationshipFeatureClient': + RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#ChecklistFeatureClient': + ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#OrderedListFeatureClient': + OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#UnorderedListFeatureClient': + UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#IndentFeatureClient': + IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#AlignFeatureClient': + AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#InlineCodeFeatureClient': + InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#SuperscriptFeatureClient': + SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#SubscriptFeatureClient': + SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@/components/BeforeDashboard#default': default_1a7510af427896d367a49dbf838d2de6, + '@/components/BeforeLogin#default': default_8a7ab0eb7ab5c511aba12e68480bfe5e, + '@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1, } diff --git a/src/collections/Media.ts b/src/collections/Media.ts index 0b95bbc..07ea622 100644 --- a/src/collections/Media.ts +++ b/src/collections/Media.ts @@ -14,7 +14,8 @@ import { authenticated } from '../access/authenticated' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) -const isProduction = process.env.NODE_ENV === 'production' || process.env.ENVIRONMENT === 'production' +const isProduction = + process.env.NODE_ENV === 'production' || process.env.ENVIRONMENT === 'production' export const Media: CollectionConfig = { slug: 'media', diff --git a/src/collections/ServiceAccounts.ts b/src/collections/ServiceAccounts.ts index f656642..79ba358 100644 --- a/src/collections/ServiceAccounts.ts +++ b/src/collections/ServiceAccounts.ts @@ -1,18 +1,14 @@ import type { CollectionConfig } from 'payload' import { anyAdmin } from '@/access/anyAdmin' import { USER_ROLE_LABELS, USER_ROLES } from '@/constants/userRoles' -import { - COLLECTIONS, - COLLECTION_FOR_PERMISSION_OPTIONS, - COLLECTION_GROUPS, - COLLECTION_LABELS, - getCollectionGroupLabel, -} from '@/constants/collections' -import { - COLLECTION_PERMISSION_LABELS, - COLLECTION_PERMISSIONS, -} from '@/constants/collectionPermissions' +import { COLLECTIONS, COLLECTION_LABELS } from '@/constants/collections' import { adminOrSelf } from '@/access/adminOrSelf' +import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' +import { + PERMISSION_LABELS, + PERMISSION_RESOURCE_OPTIONS, + PERMISSIONS, +} from '@/constants/permissions' export const ServiceAccounts: CollectionConfig = { slug: COLLECTIONS.SERVICE_ACCOUNTS, @@ -31,10 +27,11 @@ export const ServiceAccounts: CollectionConfig = { if (!user) return true return !user.role.includes(USER_ROLES.SUPER_ADMIN) && !user.role.includes(USER_ROLES.ADMIN) }, - group: getCollectionGroupLabel(COLLECTION_GROUPS.ADMIN), + group: getSidebarGroupLabel(SIDEBAR_GROUPS.ADMIN), }, auth: { useAPIKey: true, + disableLocalStrategy: true, }, fields: [ { @@ -59,7 +56,7 @@ export const ServiceAccounts: CollectionConfig = { })), }, { - name: 'allowedCollections', + name: 'permissions', type: 'array', access: { create: ({ req }) => anyAdmin({ req }), @@ -67,22 +64,22 @@ export const ServiceAccounts: CollectionConfig = { }, fields: [ { - name: 'collectionOrGroupSlug', + name: 'resource', type: 'select', hasMany: false, required: true, - options: COLLECTION_FOR_PERMISSION_OPTIONS.map(({ slug, label }) => ({ + options: PERMISSION_RESOURCE_OPTIONS.map(({ slug, label }) => ({ value: slug, label, })), }, { - name: 'permissions', + name: 'accessLevel', type: 'radio', required: true, - options: Object.values(COLLECTION_PERMISSIONS).map((permission) => ({ + options: Object.values(PERMISSIONS).map((permission) => ({ value: permission, - label: COLLECTION_PERMISSION_LABELS[permission], + label: PERMISSION_LABELS[permission], })), }, ], diff --git a/src/collections/Users/index.ts b/src/collections/Users/index.ts index cd9a425..bbd7416 100644 --- a/src/collections/Users/index.ts +++ b/src/collections/Users/index.ts @@ -1,16 +1,16 @@ import type { CollectionConfig } from 'payload' import { anyAdmin } from '@/access/anyAdmin' import { USER_ROLE_LABELS, USER_ROLES } from '@/constants/userRoles' -import { COLLECTION_FOR_PERMISSION_OPTIONS, getCollectionGroupLabel } from '@/constants/collections' import WelcomeEmail from '@/email/templates/WelcomeEmail' import type { CollectionAfterChangeHook } from 'payload' import type { User } from '@/payload-types' import { - COLLECTION_PERMISSION_LABELS, - COLLECTION_PERMISSIONS, -} from '@/constants/collectionPermissions' + PERMISSION_LABELS, + PERMISSION_RESOURCE_OPTIONS, + PERMISSIONS, +} from '@/constants/permissions' import { adminOrSelf } from '@/access/adminOrSelf' -import { GLOBALS, GLOBAL_LABELS } from '@/constants/globals' +import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' const sendEmailOnUserCreate: CollectionAfterChangeHook = ({ doc, operation, req }) => { if (operation === 'create') { @@ -51,7 +51,7 @@ export const Users: CollectionConfig = { if (!user) return true return !user.role.includes(USER_ROLES.SUPER_ADMIN) && !user.role.includes(USER_ROLES.ADMIN) }, - group: getCollectionGroupLabel('admin'), + group: getSidebarGroupLabel(SIDEBAR_GROUPS.ADMIN), }, auth: { cookies: { @@ -91,7 +91,7 @@ export const Users: CollectionConfig = { })), }, { - name: 'allowedCollections', + name: 'permissions', type: 'array', access: { create: ({ req }) => anyAdmin({ req }), @@ -99,28 +99,24 @@ export const Users: CollectionConfig = { }, fields: [ { - name: 'collectionOrGroupSlug', + name: 'resource', type: 'select', hasMany: false, required: true, options: [ - ...COLLECTION_FOR_PERMISSION_OPTIONS.map(({ slug, label }) => ({ + ...PERMISSION_RESOURCE_OPTIONS.map(({ slug, label }) => ({ value: slug, label, })), - ...Object.values(GLOBALS).map((slug) => ({ - value: slug, - label: GLOBAL_LABELS[slug].plural, - })), ], }, { - name: 'permissions', + name: 'accessLevel', type: 'radio', required: true, - options: Object.values(COLLECTION_PERMISSIONS).map((permission) => ({ + options: Object.values(PERMISSIONS).map((permission) => ({ value: permission, - label: COLLECTION_PERMISSION_LABELS[permission], + label: PERMISSION_LABELS[permission], })), }, ], diff --git a/src/collections/durianpy-website/Events.ts b/src/collections/durianpy-website/Events.ts index c6ec825..8a2619e 100644 --- a/src/collections/durianpy-website/Events.ts +++ b/src/collections/durianpy-website/Events.ts @@ -1,11 +1,12 @@ -import { checkCollectionAccess } from '@/access/checkCollectionAccess' +import { checkResourceAccess } from '@/access/checkResourceAccess' import { AccessType } from '@/constants/accessTypes' -import { COLLECTION_LABELS, COLLECTIONS, getCollectionGroupLabel } from '@/constants/collections' +import { COLLECTION_LABELS, COLLECTIONS } from '@/constants/collections' +import { SIDEBAR_GROUPS, getSidebarGroupLabel } from '@/constants/sidebarGroup' import type { AccessArgs, CollectionConfig } from 'payload' import type { CollectionBeforeChangeHook } from 'payload' const checkEventsAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkCollectionAccess(access, COLLECTIONS.DURIANPY_WEBSITE_EVENTS, accessType) + checkResourceAccess(access, COLLECTIONS.DURIANPY_WEBSITE_EVENTS, accessType) const ensureSingleFeatured: CollectionBeforeChangeHook = async ({ data, req, originalDoc }) => { if (data.isFeatured && data._status === 'published') { @@ -16,6 +17,7 @@ const ensureSingleFeatured: CollectionBeforeChangeHook = async ({ data, req, ori id: { not_equals: originalDoc?.id }, }, limit: 0, + req, }) await Promise.all( @@ -24,6 +26,7 @@ const ensureSingleFeatured: CollectionBeforeChangeHook = async ({ data, req, ori collection: COLLECTIONS.DURIANPY_WEBSITE_EVENTS, id: doc.id, data: { isFeatured: false }, + req, }), ), ) @@ -46,7 +49,7 @@ export const Events: CollectionConfig = { }, admin: { defaultColumns: ['title', 'date', 'location', 'isFeatured'], - group: getCollectionGroupLabel('durianpy-website'), + group: getSidebarGroupLabel(SIDEBAR_GROUPS.DURIANPY_WEBSITE), }, versions: { drafts: { diff --git a/src/collections/durianpy-website/SIGs.ts b/src/collections/durianpy-website/SIGs.ts index 1f92d96..f27a8bb 100644 --- a/src/collections/durianpy-website/SIGs.ts +++ b/src/collections/durianpy-website/SIGs.ts @@ -1,16 +1,12 @@ import { anyone } from '@/access/anyone' -import { checkCollectionAccess } from '@/access/checkCollectionAccess' +import { checkResourceAccess } from '@/access/checkResourceAccess' import { AccessType } from '@/constants/accessTypes' -import { - COLLECTION_LABELS, - getCollectionGroupLabel, - COLLECTION_GROUPS, - COLLECTIONS, -} from '@/constants/collections' +import { COLLECTION_LABELS, COLLECTIONS } from '@/constants/collections' +import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' import type { AccessArgs, CollectionConfig } from 'payload' const checkSIGsAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkCollectionAccess(access, COLLECTIONS.DURIANPY_WEBSITE_SIGS, accessType) + checkResourceAccess(access, COLLECTIONS.DURIANPY_WEBSITE_SIGS, accessType) export const SIGs: CollectionConfig = { slug: COLLECTIONS.DURIANPY_WEBSITE_SIGS, @@ -25,7 +21,7 @@ export const SIGs: CollectionConfig = { admin: { defaultColumns: ['title', 'icon', 'isActive', 'createdAt'], - group: getCollectionGroupLabel(COLLECTION_GROUPS.DURIANPY_WEBSITE), + group: getSidebarGroupLabel(SIDEBAR_GROUPS.DURIANPY_WEBSITE), }, versions: { drafts: { diff --git a/src/collections/durianpy-website/Sponsors.ts b/src/collections/durianpy-website/Sponsors.ts index de5ea5d..7faaa59 100644 --- a/src/collections/durianpy-website/Sponsors.ts +++ b/src/collections/durianpy-website/Sponsors.ts @@ -1,16 +1,12 @@ import { anyone } from '@/access/anyone' -import { checkCollectionAccess } from '@/access/checkCollectionAccess' +import { checkResourceAccess } from '@/access/checkResourceAccess' import { AccessType } from '@/constants/accessTypes' -import { - COLLECTIONS, - COLLECTION_LABELS, - getCollectionGroupLabel, - COLLECTION_GROUPS, -} from '@/constants/collections' +import { COLLECTIONS, COLLECTION_LABELS } from '@/constants/collections' +import { SIDEBAR_GROUPS, getSidebarGroupLabel } from '@/constants/sidebarGroup' import type { AccessArgs, CollectionConfig } from 'payload' const checkSponsorsAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkCollectionAccess(access, COLLECTIONS.DURIANPY_WEBSITE_SPONSORS, accessType) + checkResourceAccess(access, COLLECTIONS.DURIANPY_WEBSITE_SPONSORS, accessType) export const Sponsors: CollectionConfig = { slug: COLLECTIONS.DURIANPY_WEBSITE_SPONSORS, @@ -25,7 +21,7 @@ export const Sponsors: CollectionConfig = { admin: { defaultColumns: ['name', 'logo', 'websiteUrl', 'tier'], - group: getCollectionGroupLabel(COLLECTION_GROUPS.DURIANPY_WEBSITE), + group: getSidebarGroupLabel(SIDEBAR_GROUPS.DURIANPY_WEBSITE), }, versions: { drafts: true, diff --git a/src/collections/durianpy-website/sample-website-collection.index.ts b/src/collections/durianpy-website/sample-website-collection.index.ts index 63430d0..641981c 100644 --- a/src/collections/durianpy-website/sample-website-collection.index.ts +++ b/src/collections/durianpy-website/sample-website-collection.index.ts @@ -1,11 +1,12 @@ import { anyone } from '@/access/anyone' -import { checkCollectionAccess } from '@/access/checkCollectionAccess' +import { checkResourceAccess } from '@/access/checkResourceAccess' import { AccessType } from '@/constants/accessTypes' -import { COLLECTION_LABELS, getCollectionGroupLabel } from '@/constants/collections' +import { COLLECTION_LABELS } from '@/constants/collections' +import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' import type { AccessArgs, CollectionConfig } from 'payload' const checkSampleAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkCollectionAccess(access, 'sample', accessType) + checkResourceAccess(access, 'sample', accessType) export const Sample: CollectionConfig = { slug: 'sample', @@ -20,7 +21,7 @@ export const Sample: CollectionConfig = { admin: { defaultColumns: ['firstName'], - group: getCollectionGroupLabel('durianpy-website'), + group: getSidebarGroupLabel(SIDEBAR_GROUPS.DURIANPY_WEBSITE), }, versions: { drafts: { diff --git a/src/constants/accessTypes.ts b/src/constants/accessTypes.ts index fb3facc..fc63f22 100644 --- a/src/constants/accessTypes.ts +++ b/src/constants/accessTypes.ts @@ -1,3 +1,7 @@ +import { COLLECTIONS, CollectionSlug } from './collections' +import { GLOBALS, GlobalSlug } from './globals' +import { SidebarGroupSlug, SIDEBAR_GROUPS } from './sidebarGroup' + export const ACCESS_TYPES = { ADMIN: 'admin', CREATE: 'create', @@ -7,3 +11,17 @@ export const ACCESS_TYPES = { } as const export type AccessType = (typeof ACCESS_TYPES)[keyof typeof ACCESS_TYPES] + +export function getSlugType(slug: CollectionSlug | SidebarGroupSlug | GlobalSlug) { + if (Object.values(SIDEBAR_GROUPS).includes(slug as SidebarGroupSlug)) { + return 'group' + } else if ( + [...Object.values(COLLECTIONS), ...Object.values(GLOBALS)].includes( + slug as CollectionSlug | GlobalSlug, + ) + ) { + return 'collection' + } else { + return + } +} diff --git a/src/constants/collectionPermissions.ts b/src/constants/collectionPermissions.ts deleted file mode 100644 index 83bed0d..0000000 --- a/src/constants/collectionPermissions.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ACCESS_TYPES, AccessType } from './accessTypes' - -export const COLLECTION_PERMISSIONS = { - READ: 'read', - READ_WRITE: 'read-write', - FULL_ACCESS: 'full-access', -} as const - -export type CollectionPermission = - (typeof COLLECTION_PERMISSIONS)[keyof typeof COLLECTION_PERMISSIONS] - -export const COLLECTION_PERMISSION_LABELS: Record = { - [COLLECTION_PERMISSIONS.READ]: 'Read', - [COLLECTION_PERMISSIONS.READ_WRITE]: 'Read & Write', - [COLLECTION_PERMISSIONS.FULL_ACCESS]: 'Full Access', -} - -export const COLLECTION_PERMISSION_TO_ACCESS_TYPES: Record = { - [COLLECTION_PERMISSIONS.READ]: [ACCESS_TYPES.READ], - [COLLECTION_PERMISSIONS.READ_WRITE]: [ - ACCESS_TYPES.READ, - ACCESS_TYPES.CREATE, - ACCESS_TYPES.UPDATE, - ], - [COLLECTION_PERMISSIONS.FULL_ACCESS]: [ - ACCESS_TYPES.ADMIN, - ACCESS_TYPES.CREATE, - ACCESS_TYPES.DELETE, - ACCESS_TYPES.READ, - ACCESS_TYPES.UPDATE, - ], -} diff --git a/src/constants/collections.ts b/src/constants/collections.ts index dc0e1dd..94ccd43 100644 --- a/src/constants/collections.ts +++ b/src/constants/collections.ts @@ -4,7 +4,7 @@ * in code to avoid hardcoding strings throughout the codebase */ -import { GLOBALS, type GlobalSlug } from './globals' +import { DURIANPY_WEBSITE_COLLECTIONS, DURIANPY_WEBSITE_COLLECTIONS_LABELS } from './durianpy' /** * The enum for collection slugs @@ -15,9 +15,7 @@ export const COLLECTIONS = { USERS: 'users', SAMPLE: 'sample', SERVICE_ACCOUNTS: 'service-accounts', - DURIANPY_WEBSITE_EVENTS: 'durianpy-website-events', - DURIANPY_WEBSITE_SPONSORS: 'durianpy-website-sponsors', - DURIANPY_WEBSITE_SIGS: 'durianpy-website-sigs', + ...DURIANPY_WEBSITE_COLLECTIONS, } as const export type CollectionSlug = (typeof COLLECTIONS)[keyof typeof COLLECTIONS] @@ -27,73 +25,6 @@ export const COLLECTION_LABELS: Record - -export const COLLECTION_GROUPS_LABEL: Record = { - 'durianpy-website': 'DurianPy Website', - admin: 'Admin', -} as const - -export function getCollectionGroupLabel(groupSlug: CollectionGroupSlug) { - return COLLECTION_GROUPS_LABEL[groupSlug] -} - -export const COLLECTION_GROUP_ITEMS: Record = { - 'durianpy-website': [COLLECTIONS.DURIANPY_WEBSITE_EVENTS, COLLECTIONS.DURIANPY_WEBSITE_SPONSORS], - admin: [COLLECTIONS.USERS, COLLECTIONS.SERVICE_ACCOUNTS], -} as const - -export function getCollectionGroupItems(groupSlug: CollectionGroupSlug) { - return COLLECTION_GROUP_ITEMS[groupSlug] -} - -export type PermissionOption = { - slug: CollectionSlug | CollectionGroupSlug | GlobalSlug - label: string -} - -export const COLLECTION_FOR_PERMISSION_OPTIONS: PermissionOption[] = [ - ...Object.values(COLLECTION_GROUPS) - .filter((x) => x !== undefined) - .map((x) => ({ - slug: x, - label: COLLECTION_GROUPS_LABEL[x], - })), - ...Object.values(COLLECTIONS).map((x) => ({ - slug: x, - label: COLLECTION_LABELS[x].plural, - })), -] as const - -export function getCollectionSlugType(slug: CollectionSlug | CollectionGroupSlug | GlobalSlug) { - if (Object.values(COLLECTION_GROUPS).includes(slug as CollectionGroupSlug)) { - return 'group' - } else if ( - [...Object.values(COLLECTIONS), ...Object.values(GLOBALS)].includes( - slug as CollectionSlug | GlobalSlug, - ) - ) { - return 'collection' - } else { - return - } + ...DURIANPY_WEBSITE_COLLECTIONS_LABELS, } diff --git a/src/constants/durianpy.ts b/src/constants/durianpy.ts new file mode 100644 index 0000000..94371c0 --- /dev/null +++ b/src/constants/durianpy.ts @@ -0,0 +1,51 @@ +export const DURIANPY_WEBSITE_GLOBALS = { + DURIANPY_WEBSITE_HOMEPAGE_CONFIG: 'durianpy-website-homepage-config', + DURIANPY_WEBSITE_STATISTICS_CONFIG: 'durianpy-website-statistics-config', + DURIANPY_WEBSITE_CTA_SECTION: 'durianpy-website-cta-section', + DURIANPY_WEBSITE_CAROUSEL: 'durianpy-website-carousel', + DURIANPY_WEBSITE_CODE_OF_CONDUCT: 'durianpy-website-code-of-conduct', +} as const + +export type DurianpyWebsiteGlobalSlug = + (typeof DURIANPY_WEBSITE_GLOBALS)[keyof typeof DURIANPY_WEBSITE_GLOBALS] + +export const DURIANPY_WEBSITE_GLOBALS_LABELS: Record = { + [DURIANPY_WEBSITE_GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG]: 'Homepage Config', + [DURIANPY_WEBSITE_GLOBALS.DURIANPY_WEBSITE_STATISTICS_CONFIG]: 'Statistics Config', + [DURIANPY_WEBSITE_GLOBALS.DURIANPY_WEBSITE_CTA_SECTION]: 'CTA Section', + [DURIANPY_WEBSITE_GLOBALS.DURIANPY_WEBSITE_CAROUSEL]: 'Carousel', + [DURIANPY_WEBSITE_GLOBALS.DURIANPY_WEBSITE_CODE_OF_CONDUCT]: 'Code of Conduct', +} + +export const DURIANPY_WEBSITE_COLLECTIONS = { + DURIANPY_WEBSITE_EVENTS: 'durianpy-website-events', + DURIANPY_WEBSITE_SPONSORS: 'durianpy-website-sponsors', + DURIANPY_WEBSITE_SIGS: 'durianpy-website-sigs', +} as const + +export type DurianpyWebsiteCollectionSlug = + (typeof DURIANPY_WEBSITE_COLLECTIONS)[keyof typeof DURIANPY_WEBSITE_COLLECTIONS] + +export const DURIANPY_WEBSITE_COLLECTIONS_LABELS: Record< + DurianpyWebsiteCollectionSlug, + { singular: string; plural: string } +> = { + [DURIANPY_WEBSITE_COLLECTIONS.DURIANPY_WEBSITE_EVENTS]: { + singular: 'Event', + plural: 'Events', + }, + [DURIANPY_WEBSITE_COLLECTIONS.DURIANPY_WEBSITE_SPONSORS]: { + singular: 'Sponsor', + plural: 'Sponsors', + }, + [DURIANPY_WEBSITE_COLLECTIONS.DURIANPY_WEBSITE_SIGS]: { + singular: 'SIG', + plural: 'SIGs', + }, +} + +export const DURIANPY_WEBSITE_ITEMS: (DurianpyWebsiteGlobalSlug | DurianpyWebsiteCollectionSlug)[] = + [ + ...Object.values(DURIANPY_WEBSITE_GLOBALS), + ...Object.values(DURIANPY_WEBSITE_COLLECTIONS), + ] as const diff --git a/src/constants/globals.ts b/src/constants/globals.ts index f8bdb88..89d70f8 100644 --- a/src/constants/globals.ts +++ b/src/constants/globals.ts @@ -1,32 +1,11 @@ +import { DURIANPY_WEBSITE_GLOBALS, DURIANPY_WEBSITE_GLOBALS_LABELS } from './durianpy' + export const GLOBALS = { - DURIANPY_WEBSITE_HOMEPAGE_CONFIG: 'durianpy-website-homepage-config', - DURIANPY_WEBSITE_STATISTICS_CONFIG: 'durianpy-website-statistics-config', - DURIANPY_WEBSITE_CTA_SECTION: 'durianpy-website-cta-section', - DURIANPY_WEBSITE_CAROUSEL: 'durianpy-website-carousel', - DURIANPY_WEBSITE_CODE_OF_CONDUCT: 'durianpy-website-code-of-conduct', + ...DURIANPY_WEBSITE_GLOBALS, } as const export type GlobalSlug = (typeof GLOBALS)[keyof typeof GLOBALS] -export const GLOBAL_LABELS: Record = { - [GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG]: { - singular: 'Homepage Config', - plural: 'Homepage Configs', - }, - [GLOBALS.DURIANPY_WEBSITE_STATISTICS_CONFIG]: { - singular: 'Statistics Config', - plural: 'Statistics Configs', - }, - [GLOBALS.DURIANPY_WEBSITE_CTA_SECTION]: { - singular: 'CTA Section', - plural: 'CTA Sections', - }, - [GLOBALS.DURIANPY_WEBSITE_CAROUSEL]: { - singular: 'Carousel', - plural: 'Carousels', - }, - [GLOBALS.DURIANPY_WEBSITE_CODE_OF_CONDUCT]: { - singular: 'Code of Conduct', - plural: 'Codes of Conduct', - }, +export const GLOBAL_LABELS: Record = { + ...DURIANPY_WEBSITE_GLOBALS_LABELS, } diff --git a/src/constants/permissions.ts b/src/constants/permissions.ts new file mode 100644 index 0000000..9464dab --- /dev/null +++ b/src/constants/permissions.ts @@ -0,0 +1,47 @@ +import { ACCESS_TYPES, AccessType } from './accessTypes' +import { COLLECTIONS, COLLECTION_LABELS } from './collections' +import { GLOBAL_LABELS, GLOBALS } from './globals' +import { PermissionOption, SIDEBAR_GROUPS, SIDEBAR_GROUPS_LABEL } from './sidebarGroup' + +export const PERMISSIONS = { + READ: 'read', + READ_WRITE: 'read-write', + FULL_ACCESS: 'full-access', +} as const + +export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS] + +export const PERMISSION_LABELS: Record = { + [PERMISSIONS.READ]: 'Read', + [PERMISSIONS.READ_WRITE]: 'Read & Write', + [PERMISSIONS.FULL_ACCESS]: 'Full Access', +} + +export const PERMISSION_TO_ACCESS_TYPES: Record = { + [PERMISSIONS.READ]: [ACCESS_TYPES.READ], + [PERMISSIONS.READ_WRITE]: [ACCESS_TYPES.READ, ACCESS_TYPES.CREATE, ACCESS_TYPES.UPDATE], + [PERMISSIONS.FULL_ACCESS]: [ + ACCESS_TYPES.ADMIN, + ACCESS_TYPES.CREATE, + ACCESS_TYPES.DELETE, + ACCESS_TYPES.READ, + ACCESS_TYPES.UPDATE, + ], +} + +export const PERMISSION_RESOURCE_OPTIONS: PermissionOption[] = [ + ...Object.values(SIDEBAR_GROUPS) + .filter((x) => x !== undefined) + .map((x) => ({ + slug: x, + label: SIDEBAR_GROUPS_LABEL[x], + })), + ...Object.values(GLOBALS).map((x) => ({ + slug: x, + label: GLOBAL_LABELS[x], + })), + ...Object.values(COLLECTIONS).map((x) => ({ + slug: x, + label: COLLECTION_LABELS[x].plural, + })), +] as const diff --git a/src/constants/sidebarGroup.ts b/src/constants/sidebarGroup.ts new file mode 100644 index 0000000..adcc80b --- /dev/null +++ b/src/constants/sidebarGroup.ts @@ -0,0 +1,41 @@ +import { COLLECTION_LABELS, COLLECTIONS, CollectionSlug } from './collections' +import { DURIANPY_WEBSITE_ITEMS } from './durianpy' +import { GLOBALS, GlobalSlug } from './globals' + +/** + * The enum for collection groups. Collections are usually grouped by project/website. + * This is used to group collections in the admin sidebar and for giving access to a collection group. + */ +export const SIDEBAR_GROUPS = { + ADMIN: 'admin', + DEFAULT: undefined, // Default puts the collection into the sidebar directly + DURIANPY_WEBSITE: 'durianpy-website', +} as const + +export type SidebarGroupSlug = Exclude< + (typeof SIDEBAR_GROUPS)[keyof typeof SIDEBAR_GROUPS], + undefined +> + +export const SIDEBAR_GROUPS_LABEL: Record = { + 'durianpy-website': 'DurianPy Website', + admin: 'Admin', +} as const + +export function getSidebarGroupLabel(groupSlug: SidebarGroupSlug) { + return SIDEBAR_GROUPS_LABEL[groupSlug] +} + +export const SIDEBAR_GROUP_ITEMS: Record = { + 'durianpy-website': DURIANPY_WEBSITE_ITEMS, + admin: [COLLECTIONS.USERS, COLLECTIONS.SERVICE_ACCOUNTS], +} as const + +export function getSidebarGroupItems(groupSlug: SidebarGroupSlug) { + return SIDEBAR_GROUP_ITEMS[groupSlug] +} + +export type PermissionOption = { + slug: CollectionSlug | SidebarGroupSlug | GlobalSlug + label: string +} diff --git a/src/globals/durianpy-website/CTASection.ts b/src/globals/durianpy-website/CTASection.ts index bd7c884..24136a8 100644 --- a/src/globals/durianpy-website/CTASection.ts +++ b/src/globals/durianpy-website/CTASection.ts @@ -1,20 +1,20 @@ import { AccessArgs, GlobalConfig } from 'payload' -import { checkCollectionAccess } from '@/access/checkCollectionAccess' -import { getCollectionGroupLabel } from '@/constants/collections' +import { checkResourceAccess } from '@/access/checkResourceAccess' import { anyone } from '@/access/anyone' import { AccessType } from '@/constants/accessTypes' import { GLOBAL_LABELS, GLOBALS } from '@/constants/globals' +import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' const checkCTASectionAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkCollectionAccess(access, GLOBALS.DURIANPY_WEBSITE_CTA_SECTION, accessType) + checkResourceAccess(access, GLOBALS.DURIANPY_WEBSITE_CTA_SECTION, accessType) export const CTASection: GlobalConfig = { slug: GLOBALS.DURIANPY_WEBSITE_CTA_SECTION, - label: GLOBAL_LABELS[GLOBALS.DURIANPY_WEBSITE_CTA_SECTION].singular, + label: GLOBAL_LABELS[GLOBALS.DURIANPY_WEBSITE_CTA_SECTION], admin: { - group: getCollectionGroupLabel('durianpy-website'), + group: getSidebarGroupLabel(SIDEBAR_GROUPS.DURIANPY_WEBSITE), }, access: { diff --git a/src/globals/durianpy-website/Carousel.ts b/src/globals/durianpy-website/Carousel.ts index 0ed552c..d90eeac 100644 --- a/src/globals/durianpy-website/Carousel.ts +++ b/src/globals/durianpy-website/Carousel.ts @@ -1,20 +1,20 @@ import { anyAdmin } from '@/access/anyAdmin' -import { checkCollectionAccess } from '@/access/checkCollectionAccess' +import { checkResourceAccess } from '@/access/checkResourceAccess' import { AccessType } from '@/constants/accessTypes' -import { getCollectionGroupLabel } from '@/constants/collections' import { GLOBALS, GLOBAL_LABELS } from '@/constants/globals' +import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' import type { User } from '@/payload-types' import type { AccessArgs, GlobalConfig } from 'payload' const checkCarouselAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkCollectionAccess(access, GLOBALS.DURIANPY_WEBSITE_CAROUSEL, accessType) + checkResourceAccess(access, GLOBALS.DURIANPY_WEBSITE_CAROUSEL, accessType) const adminAccess = (access: AccessArgs) => Boolean(checkCarouselAccess('admin')(access) && anyAdmin(access)) export const Carousel: GlobalConfig = { slug: GLOBALS.DURIANPY_WEBSITE_CAROUSEL, - label: GLOBAL_LABELS[GLOBALS.DURIANPY_WEBSITE_CAROUSEL].singular, + label: GLOBAL_LABELS[GLOBALS.DURIANPY_WEBSITE_CAROUSEL], access: { read: (access) => { if (adminAccess(access)) { @@ -31,7 +31,7 @@ export const Carousel: GlobalConfig = { update: adminAccess, }, admin: { - group: getCollectionGroupLabel('durianpy-website'), + group: getSidebarGroupLabel(SIDEBAR_GROUPS.DURIANPY_WEBSITE), }, versions: { drafts: true, diff --git a/src/globals/durianpy-website/CodeOfConduct.ts b/src/globals/durianpy-website/CodeOfConduct.ts index befdbc1..cc3a253 100644 --- a/src/globals/durianpy-website/CodeOfConduct.ts +++ b/src/globals/durianpy-website/CodeOfConduct.ts @@ -1,5 +1,5 @@ import { anyone } from '@/access/anyone' -import { checkCollectionAccess } from '@/access/checkCollectionAccess' +import { checkResourceAccess } from '@/access/checkResourceAccess' import { AccessType } from '@/constants/accessTypes' import { FixedToolbarFeature, @@ -9,22 +9,22 @@ import { StrikethroughFeature, } from '@payloadcms/richtext-lexical' import { GLOBALS, GLOBAL_LABELS } from '@/constants/globals' -import { getCollectionGroupLabel, COLLECTION_GROUPS } from '@/constants/collections' import type { AccessArgs, GlobalConfig } from 'payload' +import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' const checkCodeOfConductAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkCollectionAccess(access, GLOBALS.DURIANPY_WEBSITE_CODE_OF_CONDUCT, accessType) + checkResourceAccess(access, GLOBALS.DURIANPY_WEBSITE_CODE_OF_CONDUCT, accessType) export const CodeOfConduct: GlobalConfig = { slug: GLOBALS.DURIANPY_WEBSITE_CODE_OF_CONDUCT, - label: GLOBAL_LABELS[GLOBALS.DURIANPY_WEBSITE_CODE_OF_CONDUCT].singular, + label: GLOBAL_LABELS[GLOBALS.DURIANPY_WEBSITE_CODE_OF_CONDUCT], access: { read: anyone, update: checkCodeOfConductAccess('update'), }, admin: { - group: getCollectionGroupLabel(COLLECTION_GROUPS.DURIANPY_WEBSITE), + group: getSidebarGroupLabel(SIDEBAR_GROUPS.DURIANPY_WEBSITE), }, versions: { diff --git a/src/globals/durianpy-website/HomepageConfig.ts b/src/globals/durianpy-website/HomepageConfig.ts index 87b235a..78d2fe2 100644 --- a/src/globals/durianpy-website/HomepageConfig.ts +++ b/src/globals/durianpy-website/HomepageConfig.ts @@ -1,18 +1,18 @@ import { GlobalConfig, AccessArgs } from 'payload' -import { checkCollectionAccess } from '@/access/checkCollectionAccess' -import { getCollectionGroupLabel } from '@/constants/collections' +import { checkResourceAccess } from '@/access/checkResourceAccess' import { GLOBALS, GLOBAL_LABELS } from '@/constants/globals' +import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' type AccessType = 'create' | 'read' | 'update' | 'delete' const checkHomepageConfigAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkCollectionAccess(access, GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG, accessType) + checkResourceAccess(access, GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG, accessType) export const HomepageConfig: GlobalConfig = { slug: GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG, - label: GLOBAL_LABELS[GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG].singular, + label: GLOBAL_LABELS[GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG], admin: { - group: getCollectionGroupLabel('durianpy-website'), + group: getSidebarGroupLabel(SIDEBAR_GROUPS.DURIANPY_WEBSITE), }, versions: { drafts: true, diff --git a/src/globals/durianpy-website/StatisticsConfig.ts b/src/globals/durianpy-website/StatisticsConfig.ts index 0071315..cd5c506 100644 --- a/src/globals/durianpy-website/StatisticsConfig.ts +++ b/src/globals/durianpy-website/StatisticsConfig.ts @@ -1,18 +1,18 @@ import { GlobalConfig, AccessArgs } from 'payload' -import { checkCollectionAccess } from '@/access/checkCollectionAccess' -import { getCollectionGroupLabel } from '@/constants/collections' +import { checkResourceAccess } from '@/access/checkResourceAccess' import { GLOBALS, GLOBAL_LABELS } from '@/constants/globals' +import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' type AccessType = 'create' | 'read' | 'update' | 'delete' const checkStatisticsConfigAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkCollectionAccess(access, GLOBALS.DURIANPY_WEBSITE_STATISTICS_CONFIG, accessType) + checkResourceAccess(access, GLOBALS.DURIANPY_WEBSITE_STATISTICS_CONFIG, accessType) export const StatisticsConfig: GlobalConfig = { slug: GLOBALS.DURIANPY_WEBSITE_STATISTICS_CONFIG, - label: GLOBAL_LABELS[GLOBALS.DURIANPY_WEBSITE_STATISTICS_CONFIG].singular, + label: GLOBAL_LABELS[GLOBALS.DURIANPY_WEBSITE_STATISTICS_CONFIG], admin: { - group: getCollectionGroupLabel('durianpy-website'), + group: getSidebarGroupLabel(SIDEBAR_GROUPS.DURIANPY_WEBSITE), }, versions: { drafts: true, diff --git a/src/payload-types.ts b/src/payload-types.ts index 4415104..77df88a 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -330,11 +330,16 @@ export interface User { firstName: string; lastName: string; role: ('super-admin' | 'admin' | 'writer' | 'reader')[]; - allowedCollections?: + permissions?: | { - collectionOrGroupSlug: + resource: | 'admin' | 'durianpy-website' + | 'durianpy-website-homepage-config' + | 'durianpy-website-statistics-config' + | 'durianpy-website-cta-section' + | 'durianpy-website-carousel' + | 'durianpy-website-code-of-conduct' | 'categories' | 'media' | 'users' @@ -342,13 +347,8 @@ export interface User { | 'service-accounts' | 'durianpy-website-events' | 'durianpy-website-sponsors' - | 'durianpy-website-sigs' - | 'durianpy-website-homepage-config' - | 'durianpy-website-statistics-config' - | 'durianpy-website-cta-section' - | 'durianpy-website-carousel' - | 'durianpy-website-code-of-conduct'; - permissions: 'read' | 'read-write' | 'full-access'; + | 'durianpy-website-sigs'; + accessLevel: 'read' | 'read-write' | 'full-access'; id?: string | null; }[] | null; @@ -391,11 +391,16 @@ export interface ServiceAccount { id: string; name: string; role: ('super-admin' | 'admin' | 'writer' | 'reader')[]; - allowedCollections?: + permissions?: | { - collectionOrGroupSlug: + resource: | 'admin' | 'durianpy-website' + | 'durianpy-website-homepage-config' + | 'durianpy-website-statistics-config' + | 'durianpy-website-cta-section' + | 'durianpy-website-carousel' + | 'durianpy-website-code-of-conduct' | 'categories' | 'media' | 'users' @@ -404,7 +409,7 @@ export interface ServiceAccount { | 'durianpy-website-events' | 'durianpy-website-sponsors' | 'durianpy-website-sigs'; - permissions: 'read' | 'read-write' | 'full-access'; + accessLevel: 'read' | 'read-write' | 'full-access'; id?: string | null; }[] | null; @@ -413,21 +418,6 @@ export interface ServiceAccount { enableAPIKey?: boolean | null; apiKey?: string | null; apiKeyIndex?: string | null; - email: string; - resetPasswordToken?: string | null; - resetPasswordExpiration?: string | null; - salt?: string | null; - hash?: string | null; - loginAttempts?: number | null; - lockUntil?: string | null; - sessions?: - | { - id: string; - createdAt?: string | null; - expiresAt: string; - }[] - | null; - password?: string | null; collection: 'service-accounts'; } /** @@ -1004,11 +994,11 @@ export interface UsersSelect { firstName?: T; lastName?: T; role?: T; - allowedCollections?: + permissions?: | T | { - collectionOrGroupSlug?: T; - permissions?: T; + resource?: T; + accessLevel?: T; id?: T; }; updatedAt?: T; @@ -1046,11 +1036,11 @@ export interface SampleSelect { export interface ServiceAccountsSelect { name?: T; role?: T; - allowedCollections?: + permissions?: | T | { - collectionOrGroupSlug?: T; - permissions?: T; + resource?: T; + accessLevel?: T; id?: T; }; updatedAt?: T; @@ -1058,20 +1048,6 @@ export interface ServiceAccountsSelect { enableAPIKey?: T; apiKey?: T; apiKeyIndex?: T; - email?: T; - resetPasswordToken?: T; - resetPasswordExpiration?: T; - salt?: T; - hash?: T; - loginAttempts?: T; - lockUntil?: T; - sessions?: - | T - | { - id?: T; - createdAt?: T; - expiresAt?: T; - }; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -1573,4 +1549,4 @@ export interface Auth { declare module 'payload' { export interface GeneratedTypes extends Config {} -} \ No newline at end of file +} From 1697f48376a135ba0ceb14c7d4be4f08f4482126 Mon Sep 17 00:00:00 2001 From: seangaaab Date: Mon, 10 Aug 2026 16:18:47 +0000 Subject: [PATCH 04/10] feat: added seed functions for durianpy website collections and globals --- src/endpoints/seed/index.ts | 39 ++++++++--- src/seed/admin/collections/ServiceAccounts.ts | 36 ++++++++++ src/seed/{ => admin}/collections/Users.ts | 5 +- src/seed/collections/Forms.ts | 14 ---- src/seed/collections/Media.ts | 69 ------------------- .../{ => default}/collections/Categories.ts | 7 +- .../durianpy-website/collections/Events.ts | 35 ++++++++++ src/seed/durianpy-website/collections/SIGs.ts | 43 ++++++++++++ .../durianpy-website/collections/Sponsors.ts | 40 +++++++++++ .../sample-website-collection.index.ts | 28 ++++++++ .../durianpy-website/globals/CTASection.ts | 32 +++++++++ src/seed/durianpy-website/globals/Carousel.ts | 21 ++++++ .../durianpy-website/globals/CodeOfConduct.ts | 45 ++++++++++++ .../globals/HomepageConfig.ts | 28 ++++++++ .../globals/StatisticsConfig.ts | 22 ++++++ 15 files changed, 368 insertions(+), 96 deletions(-) create mode 100644 src/seed/admin/collections/ServiceAccounts.ts rename src/seed/{ => admin}/collections/Users.ts (76%) delete mode 100644 src/seed/collections/Forms.ts delete mode 100644 src/seed/collections/Media.ts rename src/seed/{ => default}/collections/Categories.ts (67%) create mode 100644 src/seed/durianpy-website/collections/Events.ts create mode 100644 src/seed/durianpy-website/collections/SIGs.ts create mode 100644 src/seed/durianpy-website/collections/Sponsors.ts create mode 100644 src/seed/durianpy-website/collections/sample-website-collection.index.ts create mode 100644 src/seed/durianpy-website/globals/CTASection.ts create mode 100644 src/seed/durianpy-website/globals/Carousel.ts create mode 100644 src/seed/durianpy-website/globals/CodeOfConduct.ts create mode 100644 src/seed/durianpy-website/globals/HomepageConfig.ts create mode 100644 src/seed/durianpy-website/globals/StatisticsConfig.ts diff --git a/src/endpoints/seed/index.ts b/src/endpoints/seed/index.ts index d42520e..92e1b22 100644 --- a/src/endpoints/seed/index.ts +++ b/src/endpoints/seed/index.ts @@ -1,9 +1,19 @@ import type { CollectionSlug, Payload, PayloadRequest } from 'payload' -import { seedUsers } from '../../seed/collections/Users' -import { seedMedia } from '../../seed/collections/Media' -import { seedCategories } from '../../seed/collections/Categories' -import { seedForms } from '../../seed/collections/Forms' +import { seedUsers } from '../../seed/admin/collections/Users' +import { seedServiceAccounts } from '../../seed/admin/collections/ServiceAccounts' +import { seedCategories } from '../../seed/default/collections/Categories' +import { seedEvents } from '../../seed/durianpy-website/collections/Events' +import { seedSIGs } from '../../seed/durianpy-website/collections/SIGs' +import { seedSponsors } from '../../seed/durianpy-website/collections/Sponsors' +import { seedSample } from '../../seed/durianpy-website/collections/sample-website-collection.index' + +import { seedCTASection } from '../../seed/durianpy-website/globals/CTASection' +import { seedCarousel } from '../../seed/durianpy-website/globals/Carousel' +import { seedCodeOfConduct } from '../../seed/durianpy-website/globals/CodeOfConduct' +import { seedHomepageConfig } from '../../seed/durianpy-website/globals/HomepageConfig' +import { seedStatisticsConfig } from '../../seed/durianpy-website/globals/StatisticsConfig' + import { COLLECTIONS } from '@/constants/collections' const collections: CollectionSlug[] = Object.values(COLLECTIONS).map((x) => x) @@ -44,12 +54,25 @@ export const seed = async ({ .map((collection) => payload.db.deleteVersions({ collection, req, where: {} })), ) - await seedUsers({ payload }) - await seedMedia({ payload }) + payload.logger.info(`— Seeding Admin...`) + await seedUsers({ payload, req }) + await seedServiceAccounts({ payload, req }) + + payload.logger.info(`— Seeding Default...`) + await seedCategories({ payload, req }) - await seedCategories({ payload }) + payload.logger.info(`— Seeding DurianPy Website Collections...`) + await seedEvents({ payload, req }) + await seedSIGs({ payload, req }) + await seedSponsors({ payload, req }) + await seedSample({ payload, req }) - await seedForms({ payload }) + payload.logger.info(`— Seeding DurianPy Website Globals...`) + await seedCTASection({ payload, req }) + await seedCarousel({ payload, req }) + await seedCodeOfConduct({ payload, req }) + await seedHomepageConfig({ payload, req }) + await seedStatisticsConfig({ payload, req }) payload.logger.info('Seeded database successfully!') } diff --git a/src/seed/admin/collections/ServiceAccounts.ts b/src/seed/admin/collections/ServiceAccounts.ts new file mode 100644 index 0000000..337fc64 --- /dev/null +++ b/src/seed/admin/collections/ServiceAccounts.ts @@ -0,0 +1,36 @@ +import { Payload, PayloadRequest } from 'payload' +import { COLLECTIONS } from '@/constants/collections' +import { USER_ROLES } from '@/constants/userRoles' + +export async function seedServiceAccounts({ + payload, + req, +}: { + payload: Payload + req?: PayloadRequest +}) { + const serviceAccounts = [ + { + name: 'GitHub Actions Bot', + role: [USER_ROLES.SUPER_ADMIN], + enableAPIKey: true, + }, + { + name: 'Integration Service', + role: [USER_ROLES.ADMIN], + enableAPIKey: true, + }, + ] + + const docs = await Promise.all( + serviceAccounts.map((account) => + payload.create({ + collection: COLLECTIONS.SERVICE_ACCOUNTS, + data: account, + req, + }), + ), + ) + + return docs +} diff --git a/src/seed/collections/Users.ts b/src/seed/admin/collections/Users.ts similarity index 76% rename from src/seed/collections/Users.ts rename to src/seed/admin/collections/Users.ts index 134e655..c035d98 100644 --- a/src/seed/collections/Users.ts +++ b/src/seed/admin/collections/Users.ts @@ -1,6 +1,6 @@ -import { Payload } from 'payload' +import { Payload, PayloadRequest } from 'payload' -export async function seedUsers({ payload }: { payload: Payload }) { +export async function seedUsers({ payload, req }: { payload: Payload; req?: PayloadRequest }) { payload.logger.info(`— Seeding demo author and user...`) await payload.delete({ @@ -23,6 +23,7 @@ export async function seedUsers({ payload }: { payload: Payload }) { role: ['admin'], password: 'password', }, + req, }) return demoAuthor diff --git a/src/seed/collections/Forms.ts b/src/seed/collections/Forms.ts deleted file mode 100644 index ff190a5..0000000 --- a/src/seed/collections/Forms.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Payload } from 'payload' -import { contactForm as contactFormData } from '../../endpoints/seed/contact-form' - -export async function seedForms({ payload }: { payload: Payload }) { - payload.logger.info(`— Seeding contact form...`) - - const contactForm = await payload.create({ - collection: 'forms', - depth: 0, - data: contactFormData, - }) - - return contactForm -} diff --git a/src/seed/collections/Media.ts b/src/seed/collections/Media.ts deleted file mode 100644 index bedec1c..0000000 --- a/src/seed/collections/Media.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Payload, File } from 'payload' -import { image1 } from '../../endpoints/seed/image-1' -import { image2 } from '../../endpoints/seed/image-2' -import { imageHero1 } from '../../endpoints/seed/image-hero-1' - -async function fetchFileByURL(url: string): Promise { - const res = await fetch(url, { - credentials: 'include', - method: 'GET', - }) - - if (!res.ok) { - throw new Error(`Failed to fetch file from ${url}, status: ${res.status}`) - } - - const data = await res.arrayBuffer() - - return { - name: url.split('/').pop() || `file-${Date.now()}`, - data: Buffer.from(data), - mimetype: `image/${url.split('.').pop()}`, - size: data.byteLength, - } -} - -export async function seedMedia({ payload }: { payload: Payload }) { - payload.logger.info(`— Seeding media...`) - - const [image1Buffer, image2Buffer, image3Buffer, hero1Buffer] = await Promise.all([ - fetchFileByURL( - 'https://raw.githubusercontent.com/payloadcms/payload/refs/heads/main/templates/website/src/endpoints/seed/image-post1.webp', - ), - fetchFileByURL( - 'https://raw.githubusercontent.com/payloadcms/payload/refs/heads/main/templates/website/src/endpoints/seed/image-post2.webp', - ), - fetchFileByURL( - 'https://raw.githubusercontent.com/payloadcms/payload/refs/heads/main/templates/website/src/endpoints/seed/image-post3.webp', - ), - fetchFileByURL( - 'https://raw.githubusercontent.com/payloadcms/payload/refs/heads/main/templates/website/src/endpoints/seed/image-hero1.webp', - ), - ]) - - const image1Doc = await payload.create({ - collection: 'media', - data: image1, - file: image1Buffer, - }) - - const image2Doc = await payload.create({ - collection: 'media', - data: image2, - file: image2Buffer, - }) - - const image3Doc = await payload.create({ - collection: 'media', - data: image2, - file: image3Buffer, - }) - - const imageHomeDoc = await payload.create({ - collection: 'media', - data: imageHero1, - file: hero1Buffer, - }) - - return { image1Doc, image2Doc, image3Doc, imageHomeDoc } -} diff --git a/src/seed/collections/Categories.ts b/src/seed/default/collections/Categories.ts similarity index 67% rename from src/seed/collections/Categories.ts rename to src/seed/default/collections/Categories.ts index b9dab89..992e433 100644 --- a/src/seed/collections/Categories.ts +++ b/src/seed/default/collections/Categories.ts @@ -1,8 +1,8 @@ -import { Payload } from 'payload' +import { Payload, PayloadRequest } from 'payload' -export async function seedCategories({ payload }: { payload: Payload }) { +export async function seedCategories({ payload, req }: { payload: Payload; req?: PayloadRequest }) { const categories = ['Technology', 'News', 'Finance', 'Design', 'Software', 'Engineering'] - + const docs = await Promise.all( categories.map((category) => payload.create({ @@ -11,6 +11,7 @@ export async function seedCategories({ payload }: { payload: Payload }) { title: category, slug: category, }, + req, }), ), ) diff --git a/src/seed/durianpy-website/collections/Events.ts b/src/seed/durianpy-website/collections/Events.ts new file mode 100644 index 0000000..a22aba4 --- /dev/null +++ b/src/seed/durianpy-website/collections/Events.ts @@ -0,0 +1,35 @@ +import { Payload, PayloadRequest } from 'payload' +import { COLLECTIONS } from '@/constants/collections' + +export async function seedEvents({ payload, req }: { payload: Payload; req?: PayloadRequest }) { + const events = [ + { + title: 'DurianPy Davao Meetup 2025', + date: new Date('2025-10-15T18:00:00Z').toISOString(), + location: 'Davao City, Philippines', + registrationLink: 'https://example.com/register/durianpy-meetup', + isFeatured: true, + _status: 'published', + }, + { + title: 'Python Web Development Workshop', + date: new Date('2025-11-20T09:00:00Z').toISOString(), + location: 'Virtual', + registrationLink: 'https://example.com/register/python-web', + isFeatured: false, + _status: 'published', + }, + ] as const + + const docs = await Promise.all( + events.map((event) => + payload.create({ + collection: COLLECTIONS.DURIANPY_WEBSITE_EVENTS, + data: event, + req, + }), + ), + ) + + return docs +} diff --git a/src/seed/durianpy-website/collections/SIGs.ts b/src/seed/durianpy-website/collections/SIGs.ts new file mode 100644 index 0000000..fddd85e --- /dev/null +++ b/src/seed/durianpy-website/collections/SIGs.ts @@ -0,0 +1,43 @@ +import { Payload, PayloadRequest } from 'payload' +import { COLLECTIONS } from '@/constants/collections' + +export async function seedSIGs({ payload, req }: { payload: Payload; req?: PayloadRequest }) { + const { docs: mediaDocs } = await payload.find({ + collection: COLLECTIONS.MEDIA, + limit: 1, + req, + }) + const mediaId = mediaDocs[0]?.id + + const sigs = [ + { + title: 'Web Development SIG', + isActive: true, + icon: mediaId, + _status: 'published', + }, + { + title: 'Data Science SIG', + isActive: true, + icon: mediaId, + _status: 'published', + }, + ] as const + + const docs = await Promise.all( + sigs.map((sig) => + payload.create({ + collection: COLLECTIONS.DURIANPY_WEBSITE_SIGS, + data: { + title: sig.title, + isActive: sig.isActive, + icon: sig.icon, + _status: sig._status, + }, + req, + }), + ), + ) + + return docs +} diff --git a/src/seed/durianpy-website/collections/Sponsors.ts b/src/seed/durianpy-website/collections/Sponsors.ts new file mode 100644 index 0000000..8e66fb7 --- /dev/null +++ b/src/seed/durianpy-website/collections/Sponsors.ts @@ -0,0 +1,40 @@ +import { Payload, PayloadRequest } from 'payload' +import { COLLECTIONS } from '@/constants/collections' + +export async function seedSponsors({ payload, req }: { payload: Payload; req?: PayloadRequest }) { + const { docs: mediaDocs } = await payload.find({ + collection: COLLECTIONS.MEDIA, + limit: 1, + req, + }) + const mediaId = mediaDocs[0]?.id + + const sponsors = [ + { + name: 'TechCorp', + websiteUrl: 'https://techcorp.example.com', + tier: 'gold', + logo: mediaId, + _status: 'published', + }, + { + name: 'Local Devs', + websiteUrl: 'https://localdevs.example.com', + tier: 'community', + logo: mediaId, + _status: 'published', + }, + ] as const + + const docs = await Promise.all( + sponsors.map((sponsor) => + payload.create({ + collection: COLLECTIONS.DURIANPY_WEBSITE_SPONSORS, + data: sponsor, + req, + }), + ), + ) + + return docs +} diff --git a/src/seed/durianpy-website/collections/sample-website-collection.index.ts b/src/seed/durianpy-website/collections/sample-website-collection.index.ts new file mode 100644 index 0000000..9078474 --- /dev/null +++ b/src/seed/durianpy-website/collections/sample-website-collection.index.ts @@ -0,0 +1,28 @@ +import { Payload, PayloadRequest } from 'payload' + +export async function seedSample({ payload, req }: { payload: Payload; req?: PayloadRequest }) { + const samples = [ + { + firstName: 'John', + lastName: 'Doe', + _status: 'published', + }, + { + firstName: 'Jane', + lastName: 'Smith', + _status: 'published', + }, + ] as const + + const docs = await Promise.all( + samples.map((sample) => + payload.create({ + collection: 'sample', + data: sample, + req, + }), + ), + ) + + return docs +} diff --git a/src/seed/durianpy-website/globals/CTASection.ts b/src/seed/durianpy-website/globals/CTASection.ts new file mode 100644 index 0000000..d9c62c2 --- /dev/null +++ b/src/seed/durianpy-website/globals/CTASection.ts @@ -0,0 +1,32 @@ +import { Payload, PayloadRequest } from 'payload' +import { GLOBALS } from '@/constants/globals' + +export async function seedCTASection({ payload, req }: { payload: Payload; req?: PayloadRequest }) { + const { docs: mediaDocs } = await payload.find({ + collection: 'media', + limit: 1, + req, + }) + const mediaId = mediaDocs[0]?.id + + await payload.updateGlobal({ + slug: GLOBALS.DURIANPY_WEBSITE_CTA_SECTION, + data: { + cards: [ + { + link: 'https://durianpy.org/join', + whiteText: 'Join our', + yellowText: 'Community', + icon: mediaId, + }, + { + link: 'https://durianpy.org/sponsor', + whiteText: 'Become a', + yellowText: 'Sponsor', + icon: mediaId, + }, + ], + _status: 'published', + }, + }) +} diff --git a/src/seed/durianpy-website/globals/Carousel.ts b/src/seed/durianpy-website/globals/Carousel.ts new file mode 100644 index 0000000..bff06e9 --- /dev/null +++ b/src/seed/durianpy-website/globals/Carousel.ts @@ -0,0 +1,21 @@ +import { Payload, PayloadRequest } from 'payload' +import { GLOBALS } from '@/constants/globals' + +export async function seedCarousel({ payload, req }: { payload: Payload; req?: PayloadRequest }) { + const { docs: mediaDocs } = await payload.find({ + collection: 'media', + limit: 1, + req, + }) + const mediaId = mediaDocs[0]?.id + + await payload.updateGlobal({ + slug: GLOBALS.DURIANPY_WEBSITE_CAROUSEL, + data: { + title: 'Our Community', + subtitle: 'See what we are up to in Davao', + photos: [{ image: mediaId }, { image: mediaId }], + _status: 'published', + }, + }) +} diff --git a/src/seed/durianpy-website/globals/CodeOfConduct.ts b/src/seed/durianpy-website/globals/CodeOfConduct.ts new file mode 100644 index 0000000..60f7508 --- /dev/null +++ b/src/seed/durianpy-website/globals/CodeOfConduct.ts @@ -0,0 +1,45 @@ +import { Payload, PayloadRequest } from 'payload' +import { GLOBALS } from '@/constants/globals' + +export async function seedCodeOfConduct({ + payload, + req, +}: { + payload: Payload + req?: PayloadRequest +}) { + await payload.updateGlobal({ + slug: GLOBALS.DURIANPY_WEBSITE_CODE_OF_CONDUCT, + data: { + reportFormUrl: 'https://forms.gle/example', + content: { + root: { + type: 'root', + format: '', + indent: 0, + version: 1, + children: [ + { + type: 'paragraph', + format: '', + indent: 0, + version: 1, + children: [ + { + mode: 'normal', + text: 'DurianPy is dedicated to providing a harassment-free community experience for everyone.', + type: 'text', + style: '', + detail: 0, + format: 0, + version: 1, + }, + ], + }, + ], + }, + }, + _status: 'published', + }, + }) +} diff --git a/src/seed/durianpy-website/globals/HomepageConfig.ts b/src/seed/durianpy-website/globals/HomepageConfig.ts new file mode 100644 index 0000000..aec4797 --- /dev/null +++ b/src/seed/durianpy-website/globals/HomepageConfig.ts @@ -0,0 +1,28 @@ +import { Payload, PayloadRequest } from 'payload' +import { GLOBALS } from '@/constants/globals' + +export async function seedHomepageConfig({ + payload, + req, +}: { + payload: Payload + req?: PayloadRequest +}) { + const { docs: mediaDocs } = await payload.find({ + collection: 'media', + limit: 1, + req, + }) + const mediaId = mediaDocs[0]?.id + + await payload.updateGlobal({ + slug: GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG, + data: { + heroImageDesktop: mediaId, + heroImageMobile: mediaId, + heroTitle: 'Welcome to DurianPy', + heroSubtitle: 'The Python Community of Davao', + _status: 'published', + }, + }) +} diff --git a/src/seed/durianpy-website/globals/StatisticsConfig.ts b/src/seed/durianpy-website/globals/StatisticsConfig.ts new file mode 100644 index 0000000..22dae95 --- /dev/null +++ b/src/seed/durianpy-website/globals/StatisticsConfig.ts @@ -0,0 +1,22 @@ +import { Payload, PayloadRequest } from 'payload' +import { GLOBALS } from '@/constants/globals' + +export async function seedStatisticsConfig({ + payload, + req, +}: { + payload: Payload + req?: PayloadRequest +}) { + await payload.updateGlobal({ + slug: GLOBALS.DURIANPY_WEBSITE_STATISTICS_CONFIG, + data: { + metrics: [ + { label: 'Active Members', value: 350, large: true }, + { label: 'Events Hosted', value: 24, large: false }, + { label: 'Sponsors', value: 10, large: false }, + ], + _status: 'published', + }, + }) +} From dad2fdd5aff2af8f5ccbace7f6cd8b8271e612ef Mon Sep 17 00:00:00 2001 From: seangaaab Date: Mon, 10 Aug 2026 16:28:15 +0000 Subject: [PATCH 05/10] style: format some files based on prettier --- AGENTS.md | 1 - CHANGELOG.md | 35 +++++++++++---------- next.config.ts | 3 +- tests/unit/utilities/formatDateTime.spec.ts | 6 ++-- 4 files changed, 23 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ac853cf..6479be0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1123,7 +1123,6 @@ For deeper exploration of specific topics, refer to the context files located in - Best practices 13. **`components.md`** - Custom Components - - Component types (Root, Collection, Global, Field) - Server vs Client Components - Component paths and definition diff --git a/CHANGELOG.md b/CHANGELOG.md index a0a0da2..217796e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,24 +3,25 @@ ## What's Changed + ### 🏡 Other Changes -* feat: dockerize CMS config by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/1 -* chore: add volume mapping for public directory in development configuration by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/2 -* feat: update user model and implement access management checks by @seangaaab in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/3 -* feat: implement github action for checks and image push by @seangaaab in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/4 -* chore: harden docker image, add sast checking to ci, and optimize gh actions by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/5 -* chore: s3 and api gateway fixes for file upload by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/6 -* docs: repository documentations by @seangaaab in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/8 -* feat: documentations pt2 by @seangaaab in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/9 -* chore: prepare pr templates and pre-commit hooks by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/10 -* feat: remove unused files and update collection access logic by @seangaaab in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/11 -* chore: remove unnecessary dockerfiles by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/12 -* docs: include mandatory visual evidence section in PR template by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/13 -* feat: update workflow to include test cases and initialize unit tests by @seangaaab in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/14 -* ci: enforce cicd approval workflow by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/26 -* chore(build-push-ecr.yml): delete unnecessary file by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/27 -* feat: update ci cd by @seangaaab in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/28 -* chore: test git autotagging by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/30 +- feat: dockerize CMS config by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/1 +- chore: add volume mapping for public directory in development configuration by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/2 +- feat: update user model and implement access management checks by @seangaaab in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/3 +- feat: implement github action for checks and image push by @seangaaab in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/4 +- chore: harden docker image, add sast checking to ci, and optimize gh actions by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/5 +- chore: s3 and api gateway fixes for file upload by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/6 +- docs: repository documentations by @seangaaab in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/8 +- feat: documentations pt2 by @seangaaab in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/9 +- chore: prepare pr templates and pre-commit hooks by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/10 +- feat: remove unused files and update collection access logic by @seangaaab in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/11 +- chore: remove unnecessary dockerfiles by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/12 +- docs: include mandatory visual evidence section in PR template by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/13 +- feat: update workflow to include test cases and initialize unit tests by @seangaaab in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/14 +- ci: enforce cicd approval workflow by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/26 +- chore(build-push-ecr.yml): delete unnecessary file by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/27 +- feat: update ci cd by @seangaaab in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/28 +- chore: test git autotagging by @ASPactores in https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/pull/30 **Full Changelog**: https://github.com/DurianPy-Davao-Python-User-Group/durianpy-cms/commits/v0.0.1 diff --git a/next.config.ts b/next.config.ts index 1930689..e4c0f3f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -13,7 +13,8 @@ const NEXT_PUBLIC_SERVER_URL = ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` : process.env.__NEXT_PRIVATE_ORIGIN || 'http://localhost:3000') -const isProduction = process.env.NODE_ENV === 'production' || process.env.ENVIRONMENT === 'production' +const isProduction = + process.env.NODE_ENV === 'production' || process.env.ENVIRONMENT === 'production' const nextConfig: NextConfig = { output: 'standalone', diff --git a/tests/unit/utilities/formatDateTime.spec.ts b/tests/unit/utilities/formatDateTime.spec.ts index b043636..511e153 100644 --- a/tests/unit/utilities/formatDateTime.spec.ts +++ b/tests/unit/utilities/formatDateTime.spec.ts @@ -11,17 +11,17 @@ describe('formatDateTime', () => { const testDate = new Date(2023, 10, 15) // Nov 15, 2023 expect(formatDateTime(testDate.toISOString())).toBe('11/15/2023') }) - + it('should default to current date if timestamp is empty', () => { const now = new Date() const months = now.getMonth() const days = now.getDate() - + const MM = months + 1 < 10 ? `0${months + 1}` : months + 1 const DD = days < 10 ? `0${days}` : days const YYYY = now.getFullYear() const expected = `${MM}/${DD}/${YYYY}` - + expect(formatDateTime('')).toBe(expected) }) }) From 7405d0ac9b51e309e220dfe056a3077d1b623c3f Mon Sep 17 00:00:00 2001 From: seangaaab Date: Mon, 10 Aug 2026 16:29:02 +0000 Subject: [PATCH 06/10] test(checkResourceAccess): added unit test --- tests/unit/access/checkResourceAccess.spec.ts | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tests/unit/access/checkResourceAccess.spec.ts diff --git a/tests/unit/access/checkResourceAccess.spec.ts b/tests/unit/access/checkResourceAccess.spec.ts new file mode 100644 index 0000000..340c7e8 --- /dev/null +++ b/tests/unit/access/checkResourceAccess.spec.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { checkResourceAccess } from '@/access/checkResourceAccess' +import { anyAdmin } from '@/access/anyAdmin' +import { ACCESS_TYPES } from '@/constants/accessTypes' +import { PERMISSIONS } from '@/constants/permissions' +import { COLLECTIONS } from '@/constants/collections' +import { SIDEBAR_GROUPS } from '@/constants/sidebarGroup' +import type { AccessArgs } from 'payload' +import type { User } from '@/payload-types' + +// Mock the anyAdmin utility +vi.mock('@/access/anyAdmin', () => ({ + anyAdmin: vi.fn(), +})) + +describe('checkResourceAccess', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return false if req.user is undefined', () => { + const args = { req: {} } as AccessArgs + expect(checkResourceAccess(args, COLLECTIONS.USERS, ACCESS_TYPES.READ)).toBe(false) + }) + + it('should return true if user is anyAdmin', () => { + vi.mocked(anyAdmin).mockReturnValue(true) + const args = { req: { user: { id: 1 } } } as unknown as AccessArgs + expect(checkResourceAccess(args, COLLECTIONS.USERS, ACCESS_TYPES.READ)).toBe(true) + }) + + it('should return false if user has no permissions', () => { + vi.mocked(anyAdmin).mockReturnValue(false) + const args = { req: { user: { id: 1 } } } as unknown as AccessArgs + expect(checkResourceAccess(args, COLLECTIONS.USERS, ACCESS_TYPES.READ)).toBe(false) + }) + + it('should return false if accessType is not provided', () => { + vi.mocked(anyAdmin).mockReturnValue(false) + const args = { + req: { + user: { + id: 1, + permissions: [{ resource: COLLECTIONS.USERS, accessLevel: PERMISSIONS.READ }], + }, + }, + } as unknown as AccessArgs + expect(checkResourceAccess(args, COLLECTIONS.USERS)).toBe(false) + }) + + describe('slugType === group', () => { + it('should return true if group contains the resource and access level grants the access type', () => { + vi.mocked(anyAdmin).mockReturnValue(false) + const args = { + req: { + user: { + id: 1, + permissions: [{ resource: SIDEBAR_GROUPS.ADMIN, accessLevel: PERMISSIONS.READ_WRITE }], + }, + }, + } as unknown as AccessArgs + // USERS is in SIDEBAR_GROUPS.ADMIN + expect(checkResourceAccess(args, COLLECTIONS.USERS, ACCESS_TYPES.CREATE)).toBe(true) + expect(checkResourceAccess(args, COLLECTIONS.USERS, ACCESS_TYPES.UPDATE)).toBe(true) + expect(checkResourceAccess(args, COLLECTIONS.USERS, ACCESS_TYPES.READ)).toBe(true) + }) + + it('should return false if group contains the resource but access level does not grant the access type', () => { + vi.mocked(anyAdmin).mockReturnValue(false) + const args = { + req: { + user: { + id: 1, + permissions: [{ resource: SIDEBAR_GROUPS.ADMIN, accessLevel: PERMISSIONS.READ }], + }, + }, + } as unknown as AccessArgs + // USERS is in SIDEBAR_GROUPS.ADMIN but only READ is granted + expect(checkResourceAccess(args, COLLECTIONS.USERS, ACCESS_TYPES.CREATE)).toBe(false) + }) + + it('should return false if group does not contain the resource', () => { + vi.mocked(anyAdmin).mockReturnValue(false) + const args = { + req: { + user: { + id: 1, + permissions: [ + { resource: SIDEBAR_GROUPS.DURIANPY_WEBSITE, accessLevel: PERMISSIONS.FULL_ACCESS }, + ], + }, + }, + } as unknown as AccessArgs + // USERS is NOT in SIDEBAR_GROUPS.DURIANPY_WEBSITE + expect(checkResourceAccess(args, COLLECTIONS.USERS, ACCESS_TYPES.READ)).toBe(false) + }) + }) + + describe('slugType === collection', () => { + it('should return true if resource matches exactly and access level grants the access type', () => { + vi.mocked(anyAdmin).mockReturnValue(false) + const args = { + req: { + user: { + id: 1, + permissions: [{ resource: COLLECTIONS.USERS, accessLevel: PERMISSIONS.READ }], + }, + }, + } as unknown as AccessArgs + expect(checkResourceAccess(args, COLLECTIONS.USERS, ACCESS_TYPES.READ)).toBe(true) + }) + + it('should return false if resource matches exactly but access level does not grant the access type', () => { + vi.mocked(anyAdmin).mockReturnValue(false) + const args = { + req: { + user: { + id: 1, + permissions: [{ resource: COLLECTIONS.USERS, accessLevel: PERMISSIONS.READ }], + }, + }, + } as unknown as AccessArgs + expect(checkResourceAccess(args, COLLECTIONS.USERS, ACCESS_TYPES.UPDATE)).toBe(false) + }) + + it('should return false if resource does not match exactly', () => { + vi.mocked(anyAdmin).mockReturnValue(false) + const args = { + req: { + user: { + id: 1, + permissions: [{ resource: COLLECTIONS.MEDIA, accessLevel: PERMISSIONS.FULL_ACCESS }], + }, + }, + } as unknown as AccessArgs + expect(checkResourceAccess(args, COLLECTIONS.USERS, ACCESS_TYPES.READ)).toBe(false) + }) + }) + + it('should return false if slugType is undefined or unhandled', () => { + vi.mocked(anyAdmin).mockReturnValue(false) + const args = { + req: { + user: { + id: 1, + permissions: [{ resource: 'invalid-resource', accessLevel: PERMISSIONS.FULL_ACCESS }], + }, + }, + } as unknown as AccessArgs + expect(checkResourceAccess(args, COLLECTIONS.USERS, ACCESS_TYPES.READ)).toBe(false) + }) +}) From 0995a2977a091d4aa33f7f0d770f6fe41115caea Mon Sep 17 00:00:00 2001 From: seangaaab Date: Sat, 15 Aug 2026 06:20:14 +0000 Subject: [PATCH 07/10] refactor: made collection access checker reusable --- package.json | 2 + src/access/checkResourceAccess.ts | 48 +++++- src/collections/Categories.ts | 7 +- src/collections/Media.ts | 7 +- src/collections/durianpy-website/Events.ts | 19 +-- src/collections/durianpy-website/SIGs.ts | 17 +-- src/collections/durianpy-website/Sponsors.ts | 17 +-- .../sample-website-collection.index.ts | 19 +-- src/globals/durianpy-website/CTASection.ts | 14 +- src/globals/durianpy-website/Carousel.ts | 22 +-- src/globals/durianpy-website/CodeOfConduct.ts | 14 +- .../durianpy-website/HomepageConfig.ts | 14 +- .../durianpy-website/StatisticsConfig.ts | 14 +- tests/unit/access/checkResourceAccess.spec.ts | 137 +++++++++++++++++- 14 files changed, 221 insertions(+), 130 deletions(-) diff --git a/package.json b/package.json index f88242c..b271e96 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "build": "cross-env NODE_OPTIONS=--no-deprecation next build", "dev": "cross-env NODE_OPTIONS=--no-deprecation next dev", "dev:prod": "cross-env NODE_OPTIONS=--no-deprecation rm -rf .next && npm run build && npm run start", + "format": "prettier --write .", + "format:check": "prettier --check .", "generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap", "generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types", "ii": "cross-env NODE_OPTIONS=--no-deprecation npm install", diff --git a/src/access/checkResourceAccess.ts b/src/access/checkResourceAccess.ts index 696624c..1ca7894 100644 --- a/src/access/checkResourceAccess.ts +++ b/src/access/checkResourceAccess.ts @@ -1,4 +1,4 @@ -import type { AccessArgs } from 'payload' +import type { AccessArgs, Where, CollectionConfig, GlobalConfig } from 'payload' import type { User } from '@/payload-types' import { anyAdmin } from './anyAdmin' import { AccessType, getSlugType } from '@/constants/accessTypes' @@ -48,3 +48,49 @@ export function checkResourceAccess( return false }) } + +/** + * Verifies read access for a resource and enforces draft visibility rules. + * Service accounts can only access drafts if they are also granted write (update) access. + */ +export function checkReadAccess( + accessArgs: AccessArgs, + resourceSlug: CollectionSlug | GlobalSlug, + hasDrafts: boolean = false, +): boolean | Where { + const hasRead = checkResourceAccess(accessArgs, resourceSlug, 'read') + if (!hasRead) return false + + if (hasDrafts) { + const hasUpdate = checkResourceAccess(accessArgs, resourceSlug, 'update') + if (hasUpdate) return true + return { _status: { equals: 'published' } } + } + + return true +} + +/** + * Factory function to create standard access control methods for a Collection. + */ +export const createCollectionAccess = ( + resourceSlug: CollectionSlug, + hasDrafts = false, +): CollectionConfig['access'] => ({ + admin: (access) => checkResourceAccess(access, resourceSlug, 'admin') as boolean, + create: (access) => checkResourceAccess(access, resourceSlug, 'create') as boolean, + delete: (access) => checkResourceAccess(access, resourceSlug, 'delete') as boolean, + read: (access) => checkReadAccess(access, resourceSlug, hasDrafts), + update: (access) => checkResourceAccess(access, resourceSlug, 'update') as boolean, +}) + +/** + * Factory function to create standard access control methods for a Global. + */ +export const createGlobalAccess = ( + resourceSlug: GlobalSlug, + hasDrafts = false, +): GlobalConfig['access'] => ({ + read: (access) => checkReadAccess(access, resourceSlug, hasDrafts), + update: (access) => checkResourceAccess(access, resourceSlug, 'update') as boolean, +}) diff --git a/src/collections/Categories.ts b/src/collections/Categories.ts index cca3fc1..86471ab 100644 --- a/src/collections/Categories.ts +++ b/src/collections/Categories.ts @@ -1,15 +1,16 @@ import type { CollectionConfig } from 'payload' -import { anyone } from '../access/anyone' +import { createCollectionAccess } from '@/access/checkResourceAccess' +import { COLLECTIONS } from '@/constants/collections' import { authenticated } from '../access/authenticated' import { slugField } from 'payload' export const Categories: CollectionConfig = { - slug: 'categories', + slug: COLLECTIONS.CATEGORIES, access: { + ...createCollectionAccess(COLLECTIONS.CATEGORIES, false), create: authenticated, delete: authenticated, - read: anyone, update: authenticated, }, admin: { diff --git a/src/collections/Media.ts b/src/collections/Media.ts index 07ea622..1db56fe 100644 --- a/src/collections/Media.ts +++ b/src/collections/Media.ts @@ -8,7 +8,8 @@ import { import path from 'path' import { fileURLToPath } from 'url' -import { anyone } from '../access/anyone' +import { createCollectionAccess } from '@/access/checkResourceAccess' +import { COLLECTIONS } from '@/constants/collections' import { authenticated } from '../access/authenticated' const filename = fileURLToPath(import.meta.url) @@ -18,12 +19,12 @@ const isProduction = process.env.NODE_ENV === 'production' || process.env.ENVIRONMENT === 'production' export const Media: CollectionConfig = { - slug: 'media', + slug: COLLECTIONS.MEDIA, folders: true, access: { + ...createCollectionAccess(COLLECTIONS.MEDIA, false), create: authenticated, delete: authenticated, - read: anyone, update: authenticated, }, fields: [ diff --git a/src/collections/durianpy-website/Events.ts b/src/collections/durianpy-website/Events.ts index 8a2619e..f19bdcf 100644 --- a/src/collections/durianpy-website/Events.ts +++ b/src/collections/durianpy-website/Events.ts @@ -1,13 +1,9 @@ -import { checkResourceAccess } from '@/access/checkResourceAccess' -import { AccessType } from '@/constants/accessTypes' +import { createCollectionAccess } from '@/access/checkResourceAccess' import { COLLECTION_LABELS, COLLECTIONS } from '@/constants/collections' import { SIDEBAR_GROUPS, getSidebarGroupLabel } from '@/constants/sidebarGroup' -import type { AccessArgs, CollectionConfig } from 'payload' +import type { CollectionConfig } from 'payload' import type { CollectionBeforeChangeHook } from 'payload' -const checkEventsAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkResourceAccess(access, COLLECTIONS.DURIANPY_WEBSITE_EVENTS, accessType) - const ensureSingleFeatured: CollectionBeforeChangeHook = async ({ data, req, originalDoc }) => { if (data.isFeatured && data._status === 'published') { const { docs } = await req.payload.find({ @@ -37,16 +33,7 @@ const ensureSingleFeatured: CollectionBeforeChangeHook = async ({ data, req, ori export const Events: CollectionConfig = { slug: COLLECTIONS.DURIANPY_WEBSITE_EVENTS, labels: COLLECTION_LABELS[COLLECTIONS.DURIANPY_WEBSITE_EVENTS], - access: { - admin: checkEventsAccess('admin'), - create: checkEventsAccess('create'), - delete: checkEventsAccess('delete'), - read: ({ req }) => { - if (req.user) return true - return { _status: { equals: 'published' } } - }, - update: checkEventsAccess('update'), - }, + access: createCollectionAccess(COLLECTIONS.DURIANPY_WEBSITE_EVENTS, true), admin: { defaultColumns: ['title', 'date', 'location', 'isFeatured'], group: getSidebarGroupLabel(SIDEBAR_GROUPS.DURIANPY_WEBSITE), diff --git a/src/collections/durianpy-website/SIGs.ts b/src/collections/durianpy-website/SIGs.ts index f27a8bb..73df58c 100644 --- a/src/collections/durianpy-website/SIGs.ts +++ b/src/collections/durianpy-website/SIGs.ts @@ -1,23 +1,12 @@ -import { anyone } from '@/access/anyone' -import { checkResourceAccess } from '@/access/checkResourceAccess' -import { AccessType } from '@/constants/accessTypes' +import { createCollectionAccess } from '@/access/checkResourceAccess' import { COLLECTION_LABELS, COLLECTIONS } from '@/constants/collections' import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' -import type { AccessArgs, CollectionConfig } from 'payload' - -const checkSIGsAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkResourceAccess(access, COLLECTIONS.DURIANPY_WEBSITE_SIGS, accessType) +import type { CollectionConfig } from 'payload' export const SIGs: CollectionConfig = { slug: COLLECTIONS.DURIANPY_WEBSITE_SIGS, labels: COLLECTION_LABELS[COLLECTIONS.DURIANPY_WEBSITE_SIGS], - access: { - admin: checkSIGsAccess('admin'), - create: checkSIGsAccess('create'), - delete: checkSIGsAccess('delete'), - read: anyone, - update: checkSIGsAccess('update'), - }, + access: createCollectionAccess(COLLECTIONS.DURIANPY_WEBSITE_SIGS, true), admin: { defaultColumns: ['title', 'icon', 'isActive', 'createdAt'], diff --git a/src/collections/durianpy-website/Sponsors.ts b/src/collections/durianpy-website/Sponsors.ts index 7faaa59..e3c5325 100644 --- a/src/collections/durianpy-website/Sponsors.ts +++ b/src/collections/durianpy-website/Sponsors.ts @@ -1,23 +1,12 @@ -import { anyone } from '@/access/anyone' -import { checkResourceAccess } from '@/access/checkResourceAccess' -import { AccessType } from '@/constants/accessTypes' +import { createCollectionAccess } from '@/access/checkResourceAccess' import { COLLECTIONS, COLLECTION_LABELS } from '@/constants/collections' import { SIDEBAR_GROUPS, getSidebarGroupLabel } from '@/constants/sidebarGroup' -import type { AccessArgs, CollectionConfig } from 'payload' - -const checkSponsorsAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkResourceAccess(access, COLLECTIONS.DURIANPY_WEBSITE_SPONSORS, accessType) +import type { CollectionConfig } from 'payload' export const Sponsors: CollectionConfig = { slug: COLLECTIONS.DURIANPY_WEBSITE_SPONSORS, labels: COLLECTION_LABELS[COLLECTIONS.DURIANPY_WEBSITE_SPONSORS], - access: { - admin: checkSponsorsAccess('admin'), - create: checkSponsorsAccess('create'), - delete: checkSponsorsAccess('delete'), - read: anyone, - update: checkSponsorsAccess('update'), - }, + access: createCollectionAccess(COLLECTIONS.DURIANPY_WEBSITE_SPONSORS, true), admin: { defaultColumns: ['name', 'logo', 'websiteUrl', 'tier'], diff --git a/src/collections/durianpy-website/sample-website-collection.index.ts b/src/collections/durianpy-website/sample-website-collection.index.ts index 641981c..c1918f8 100644 --- a/src/collections/durianpy-website/sample-website-collection.index.ts +++ b/src/collections/durianpy-website/sample-website-collection.index.ts @@ -1,23 +1,12 @@ -import { anyone } from '@/access/anyone' -import { checkResourceAccess } from '@/access/checkResourceAccess' -import { AccessType } from '@/constants/accessTypes' -import { COLLECTION_LABELS } from '@/constants/collections' +import { createCollectionAccess } from '@/access/checkResourceAccess' +import { COLLECTIONS, COLLECTION_LABELS } from '@/constants/collections' import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' -import type { AccessArgs, CollectionConfig } from 'payload' - -const checkSampleAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkResourceAccess(access, 'sample', accessType) +import type { CollectionConfig } from 'payload' export const Sample: CollectionConfig = { slug: 'sample', labels: COLLECTION_LABELS.sample, - access: { - admin: checkSampleAccess('admin'), - create: checkSampleAccess('create'), - delete: checkSampleAccess('delete'), - read: anyone, - update: checkSampleAccess('update'), - }, + access: createCollectionAccess(COLLECTIONS.SAMPLE, true), admin: { defaultColumns: ['firstName'], diff --git a/src/globals/durianpy-website/CTASection.ts b/src/globals/durianpy-website/CTASection.ts index 24136a8..3d92122 100644 --- a/src/globals/durianpy-website/CTASection.ts +++ b/src/globals/durianpy-website/CTASection.ts @@ -1,14 +1,9 @@ -import { AccessArgs, GlobalConfig } from 'payload' +import { GlobalConfig } from 'payload' -import { checkResourceAccess } from '@/access/checkResourceAccess' -import { anyone } from '@/access/anyone' -import { AccessType } from '@/constants/accessTypes' +import { createGlobalAccess } from '@/access/checkResourceAccess' import { GLOBAL_LABELS, GLOBALS } from '@/constants/globals' import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' -const checkCTASectionAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkResourceAccess(access, GLOBALS.DURIANPY_WEBSITE_CTA_SECTION, accessType) - export const CTASection: GlobalConfig = { slug: GLOBALS.DURIANPY_WEBSITE_CTA_SECTION, label: GLOBAL_LABELS[GLOBALS.DURIANPY_WEBSITE_CTA_SECTION], @@ -17,10 +12,7 @@ export const CTASection: GlobalConfig = { group: getSidebarGroupLabel(SIDEBAR_GROUPS.DURIANPY_WEBSITE), }, - access: { - read: anyone, - update: checkCTASectionAccess('update'), - }, + access: createGlobalAccess(GLOBALS.DURIANPY_WEBSITE_CTA_SECTION, true), versions: { drafts: true, diff --git a/src/globals/durianpy-website/Carousel.ts b/src/globals/durianpy-website/Carousel.ts index d90eeac..fb79783 100644 --- a/src/globals/durianpy-website/Carousel.ts +++ b/src/globals/durianpy-website/Carousel.ts @@ -1,32 +1,20 @@ import { anyAdmin } from '@/access/anyAdmin' -import { checkResourceAccess } from '@/access/checkResourceAccess' -import { AccessType } from '@/constants/accessTypes' +import { createGlobalAccess, checkResourceAccess } from '@/access/checkResourceAccess' import { GLOBALS, GLOBAL_LABELS } from '@/constants/globals' import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' import type { User } from '@/payload-types' import type { AccessArgs, GlobalConfig } from 'payload' -const checkCarouselAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkResourceAccess(access, GLOBALS.DURIANPY_WEBSITE_CAROUSEL, accessType) - const adminAccess = (access: AccessArgs) => - Boolean(checkCarouselAccess('admin')(access) && anyAdmin(access)) + Boolean( + checkResourceAccess(access, GLOBALS.DURIANPY_WEBSITE_CAROUSEL, 'admin') && anyAdmin(access), + ) export const Carousel: GlobalConfig = { slug: GLOBALS.DURIANPY_WEBSITE_CAROUSEL, label: GLOBAL_LABELS[GLOBALS.DURIANPY_WEBSITE_CAROUSEL], access: { - read: (access) => { - if (adminAccess(access)) { - return true - } - - return { - _status: { - equals: 'published', - }, - } - }, + ...createGlobalAccess(GLOBALS.DURIANPY_WEBSITE_CAROUSEL, true), readVersions: adminAccess, update: adminAccess, }, diff --git a/src/globals/durianpy-website/CodeOfConduct.ts b/src/globals/durianpy-website/CodeOfConduct.ts index cc3a253..50fedf9 100644 --- a/src/globals/durianpy-website/CodeOfConduct.ts +++ b/src/globals/durianpy-website/CodeOfConduct.ts @@ -1,6 +1,4 @@ -import { anyone } from '@/access/anyone' -import { checkResourceAccess } from '@/access/checkResourceAccess' -import { AccessType } from '@/constants/accessTypes' +import { createGlobalAccess } from '@/access/checkResourceAccess' import { FixedToolbarFeature, HeadingFeature, @@ -9,20 +7,14 @@ import { StrikethroughFeature, } from '@payloadcms/richtext-lexical' import { GLOBALS, GLOBAL_LABELS } from '@/constants/globals' -import type { AccessArgs, GlobalConfig } from 'payload' +import type { GlobalConfig } from 'payload' import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' -const checkCodeOfConductAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkResourceAccess(access, GLOBALS.DURIANPY_WEBSITE_CODE_OF_CONDUCT, accessType) - export const CodeOfConduct: GlobalConfig = { slug: GLOBALS.DURIANPY_WEBSITE_CODE_OF_CONDUCT, label: GLOBAL_LABELS[GLOBALS.DURIANPY_WEBSITE_CODE_OF_CONDUCT], - access: { - read: anyone, - update: checkCodeOfConductAccess('update'), - }, + access: createGlobalAccess(GLOBALS.DURIANPY_WEBSITE_CODE_OF_CONDUCT, true), admin: { group: getSidebarGroupLabel(SIDEBAR_GROUPS.DURIANPY_WEBSITE), }, diff --git a/src/globals/durianpy-website/HomepageConfig.ts b/src/globals/durianpy-website/HomepageConfig.ts index 78d2fe2..50bcf60 100644 --- a/src/globals/durianpy-website/HomepageConfig.ts +++ b/src/globals/durianpy-website/HomepageConfig.ts @@ -1,13 +1,8 @@ -import { GlobalConfig, AccessArgs } from 'payload' -import { checkResourceAccess } from '@/access/checkResourceAccess' +import { GlobalConfig } from 'payload' +import { createGlobalAccess } from '@/access/checkResourceAccess' import { GLOBALS, GLOBAL_LABELS } from '@/constants/globals' import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' -type AccessType = 'create' | 'read' | 'update' | 'delete' - -const checkHomepageConfigAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkResourceAccess(access, GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG, accessType) - export const HomepageConfig: GlobalConfig = { slug: GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG, label: GLOBAL_LABELS[GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG], @@ -17,10 +12,7 @@ export const HomepageConfig: GlobalConfig = { versions: { drafts: true, }, - access: { - read: () => true, - update: checkHomepageConfigAccess('update'), - }, + access: createGlobalAccess(GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG, true), fields: [ { diff --git a/src/globals/durianpy-website/StatisticsConfig.ts b/src/globals/durianpy-website/StatisticsConfig.ts index cd5c506..66c2288 100644 --- a/src/globals/durianpy-website/StatisticsConfig.ts +++ b/src/globals/durianpy-website/StatisticsConfig.ts @@ -1,13 +1,8 @@ -import { GlobalConfig, AccessArgs } from 'payload' -import { checkResourceAccess } from '@/access/checkResourceAccess' +import { GlobalConfig } from 'payload' +import { createGlobalAccess } from '@/access/checkResourceAccess' import { GLOBALS, GLOBAL_LABELS } from '@/constants/globals' import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' -type AccessType = 'create' | 'read' | 'update' | 'delete' - -const checkStatisticsConfigAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkResourceAccess(access, GLOBALS.DURIANPY_WEBSITE_STATISTICS_CONFIG, accessType) - export const StatisticsConfig: GlobalConfig = { slug: GLOBALS.DURIANPY_WEBSITE_STATISTICS_CONFIG, label: GLOBAL_LABELS[GLOBALS.DURIANPY_WEBSITE_STATISTICS_CONFIG], @@ -17,10 +12,7 @@ export const StatisticsConfig: GlobalConfig = { versions: { drafts: true, }, - access: { - read: () => true, - update: checkStatisticsConfigAccess('update'), - }, + access: createGlobalAccess(GLOBALS.DURIANPY_WEBSITE_STATISTICS_CONFIG, true), fields: [ { name: 'metrics', diff --git a/tests/unit/access/checkResourceAccess.spec.ts b/tests/unit/access/checkResourceAccess.spec.ts index 340c7e8..fd7a1be 100644 --- a/tests/unit/access/checkResourceAccess.spec.ts +++ b/tests/unit/access/checkResourceAccess.spec.ts @@ -1,9 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { checkResourceAccess } from '@/access/checkResourceAccess' +import { checkResourceAccess, checkReadAccess } from '@/access/checkResourceAccess' import { anyAdmin } from '@/access/anyAdmin' import { ACCESS_TYPES } from '@/constants/accessTypes' import { PERMISSIONS } from '@/constants/permissions' import { COLLECTIONS } from '@/constants/collections' +import { GLOBALS } from '@/constants/globals' import { SIDEBAR_GROUPS } from '@/constants/sidebarGroup' import type { AccessArgs } from 'payload' import type { User } from '@/payload-types' @@ -65,6 +66,30 @@ describe('checkResourceAccess', () => { expect(checkResourceAccess(args, COLLECTIONS.USERS, ACCESS_TYPES.READ)).toBe(true) }) + it('should return true if group contains the global resource and access level grants the access type', () => { + vi.mocked(anyAdmin).mockReturnValue(false) + const args = { + req: { + user: { + id: 1, + permissions: [ + { resource: SIDEBAR_GROUPS.DURIANPY_WEBSITE, accessLevel: PERMISSIONS.READ_WRITE }, + ], + }, + }, + } as unknown as AccessArgs + // DURIANPY_WEBSITE_HOMEPAGE_CONFIG is in SIDEBAR_GROUPS.DURIANPY_WEBSITE + expect( + checkResourceAccess(args, GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG, ACCESS_TYPES.CREATE), + ).toBe(true) + expect( + checkResourceAccess(args, GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG, ACCESS_TYPES.UPDATE), + ).toBe(true) + expect( + checkResourceAccess(args, GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG, ACCESS_TYPES.READ), + ).toBe(true) + }) + it('should return false if group contains the resource but access level does not grant the access type', () => { vi.mocked(anyAdmin).mockReturnValue(false) const args = { @@ -94,10 +119,26 @@ describe('checkResourceAccess', () => { // USERS is NOT in SIDEBAR_GROUPS.DURIANPY_WEBSITE expect(checkResourceAccess(args, COLLECTIONS.USERS, ACCESS_TYPES.READ)).toBe(false) }) + + it('should return false if group does not contain the global resource', () => { + vi.mocked(anyAdmin).mockReturnValue(false) + const args = { + req: { + user: { + id: 1, + permissions: [{ resource: SIDEBAR_GROUPS.ADMIN, accessLevel: PERMISSIONS.FULL_ACCESS }], + }, + }, + } as unknown as AccessArgs + // DURIANPY_WEBSITE_HOMEPAGE_CONFIG is NOT in SIDEBAR_GROUPS.ADMIN + expect( + checkResourceAccess(args, GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG, ACCESS_TYPES.READ), + ).toBe(false) + }) }) - describe('slugType === collection', () => { - it('should return true if resource matches exactly and access level grants the access type', () => { + describe('slugType === collection / global', () => { + it('should return true if collection resource matches exactly and access level grants the access type', () => { vi.mocked(anyAdmin).mockReturnValue(false) const args = { req: { @@ -110,6 +151,23 @@ describe('checkResourceAccess', () => { expect(checkResourceAccess(args, COLLECTIONS.USERS, ACCESS_TYPES.READ)).toBe(true) }) + it('should return true if global resource matches exactly and access level grants the access type', () => { + vi.mocked(anyAdmin).mockReturnValue(false) + const args = { + req: { + user: { + id: 1, + permissions: [ + { resource: GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG, accessLevel: PERMISSIONS.READ }, + ], + }, + }, + } as unknown as AccessArgs + expect( + checkResourceAccess(args, GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG, ACCESS_TYPES.READ), + ).toBe(true) + }) + it('should return false if resource matches exactly but access level does not grant the access type', () => { vi.mocked(anyAdmin).mockReturnValue(false) const args = { @@ -123,6 +181,23 @@ describe('checkResourceAccess', () => { expect(checkResourceAccess(args, COLLECTIONS.USERS, ACCESS_TYPES.UPDATE)).toBe(false) }) + it('should return false if global resource matches exactly but access level does not grant the access type', () => { + vi.mocked(anyAdmin).mockReturnValue(false) + const args = { + req: { + user: { + id: 1, + permissions: [ + { resource: GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG, accessLevel: PERMISSIONS.READ }, + ], + }, + }, + } as unknown as AccessArgs + expect( + checkResourceAccess(args, GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG, ACCESS_TYPES.UPDATE), + ).toBe(false) + }) + it('should return false if resource does not match exactly', () => { vi.mocked(anyAdmin).mockReturnValue(false) const args = { @@ -149,4 +224,60 @@ describe('checkResourceAccess', () => { } as unknown as AccessArgs expect(checkResourceAccess(args, COLLECTIONS.USERS, ACCESS_TYPES.READ)).toBe(false) }) + + describe('checkReadAccess', () => { + it('should return false if user has no read access', () => { + vi.mocked(anyAdmin).mockReturnValue(false) + const args = { req: { user: { id: 1, permissions: [] } } } as unknown as AccessArgs + expect(checkReadAccess(args, COLLECTIONS.USERS)).toBe(false) + }) + + it('should return true if user has read access and drafts are not enabled', () => { + vi.mocked(anyAdmin).mockReturnValue(false) + const args = { + req: { + user: { + id: 1, + permissions: [{ resource: COLLECTIONS.CATEGORIES, accessLevel: PERMISSIONS.READ }], + }, + }, + } as unknown as AccessArgs + expect(checkReadAccess(args, COLLECTIONS.CATEGORIES, false)).toBe(true) + }) + + it('should return { _status: { equals: "published" } } if user has read but no update access, and drafts are enabled', () => { + vi.mocked(anyAdmin).mockReturnValue(false) + const args = { + req: { + user: { + id: 1, + permissions: [ + { resource: COLLECTIONS.DURIANPY_WEBSITE_EVENTS, accessLevel: PERMISSIONS.READ }, + ], + }, + }, + } as unknown as AccessArgs + expect(checkReadAccess(args, COLLECTIONS.DURIANPY_WEBSITE_EVENTS, true)).toEqual({ + _status: { equals: 'published' }, + }) + }) + + it('should return true if user has read and update access, and drafts are enabled', () => { + vi.mocked(anyAdmin).mockReturnValue(false) + const args = { + req: { + user: { + id: 1, + permissions: [ + { + resource: COLLECTIONS.DURIANPY_WEBSITE_EVENTS, + accessLevel: PERMISSIONS.READ_WRITE, + }, + ], + }, + }, + } as unknown as AccessArgs + expect(checkReadAccess(args, COLLECTIONS.DURIANPY_WEBSITE_EVENTS, true)).toBe(true) + }) + }) }) From 98974239f6fef72c64951b90a9f1ff8d4226f563 Mon Sep 17 00:00:00 2001 From: seangaaab Date: Sun, 16 Aug 2026 02:39:34 +0000 Subject: [PATCH 08/10] fix: remove unused imports and extended argument type for checkResourceAccess --- src/access/checkResourceAccess.ts | 2 +- src/constants/sidebarGroup.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/access/checkResourceAccess.ts b/src/access/checkResourceAccess.ts index 1ca7894..39b82e4 100644 --- a/src/access/checkResourceAccess.ts +++ b/src/access/checkResourceAccess.ts @@ -9,7 +9,7 @@ import { getSidebarGroupItems, SidebarGroupSlug } from '@/constants/sidebarGroup export function checkResourceAccess( { req }: AccessArgs, - resourceSlug: CollectionSlug | GlobalSlug, + resourceSlug: CollectionSlug | SidebarGroupSlug | GlobalSlug, accessType?: AccessType, ) { const user = req.user diff --git a/src/constants/sidebarGroup.ts b/src/constants/sidebarGroup.ts index adcc80b..7935148 100644 --- a/src/constants/sidebarGroup.ts +++ b/src/constants/sidebarGroup.ts @@ -1,6 +1,6 @@ -import { COLLECTION_LABELS, COLLECTIONS, CollectionSlug } from './collections' +import { COLLECTIONS, CollectionSlug } from './collections' import { DURIANPY_WEBSITE_ITEMS } from './durianpy' -import { GLOBALS, GlobalSlug } from './globals' +import { GlobalSlug } from './globals' /** * The enum for collection groups. Collections are usually grouped by project/website. From ed0ab564335c51118e01bb238d3e80d4527c2655 Mon Sep 17 00:00:00 2001 From: seangaaab Date: Sun, 16 Aug 2026 02:42:43 +0000 Subject: [PATCH 09/10] docs: update agents.md to use checkResourceAccess.ts util --- AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 6479be0..205bc29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -244,6 +244,10 @@ hooks: { ## Access Control +### Resource Access Checking + +ALWAYS utilize `src/access/checkResourceAccess.ts` for resource access checking. The only exception is for specific resources (like `users` or service accounts) where it is strictly for admins to create. For all other resources, you must use `checkResourceAccess.ts`. + ### Collection-Level Access ```typescript @@ -1019,6 +1023,7 @@ export const myPlugin = 3. Extract hooks to `hooks/` directory 4. Use reusable field factories for common patterns 5. Document complex access control with comments +6. Always write seed functions after creating a new resource ## Common Gotchas From da7d257c9a6451f77b093d1f85ed11e56987e539 Mon Sep 17 00:00:00 2001 From: Anakin Skywalker Pactores Date: Tue, 18 Aug 2026 00:41:32 +0800 Subject: [PATCH 10/10] chore: add seeder functions q --- src/endpoints/seed/index.ts | 8 +++--- src/seed/assets/durianpy-logo.png | Bin 0 -> 10547 bytes src/seed/default/collections/Media.ts | 25 ++++++++++++++++++ .../durianpy-website/collections/Events.ts | 9 +++++++ 4 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 src/seed/assets/durianpy-logo.png create mode 100644 src/seed/default/collections/Media.ts diff --git a/src/endpoints/seed/index.ts b/src/endpoints/seed/index.ts index 92e1b22..2179fd8 100644 --- a/src/endpoints/seed/index.ts +++ b/src/endpoints/seed/index.ts @@ -3,6 +3,7 @@ import type { CollectionSlug, Payload, PayloadRequest } from 'payload' import { seedUsers } from '../../seed/admin/collections/Users' import { seedServiceAccounts } from '../../seed/admin/collections/ServiceAccounts' import { seedCategories } from '../../seed/default/collections/Categories' +import { seedMedia } from '../../seed/default/collections/Media' import { seedEvents } from '../../seed/durianpy-website/collections/Events' import { seedSIGs } from '../../seed/durianpy-website/collections/SIGs' import { seedSponsors } from '../../seed/durianpy-website/collections/Sponsors' @@ -41,16 +42,16 @@ export const seed = async ({ await Promise.all( collections.map((collection) => { if (collection === 'users') { - return + return Promise.resolve() } - payload.db.deleteMany({ collection, req, where: {} }) + return payload.db.deleteMany({ collection, req, where: {} }) }), ) await Promise.all( collections - .filter((collection) => Boolean(payload.collections[collection].config.versions)) + .filter((collection) => Boolean(payload.collections[collection]?.config?.versions)) .map((collection) => payload.db.deleteVersions({ collection, req, where: {} })), ) @@ -59,6 +60,7 @@ export const seed = async ({ await seedServiceAccounts({ payload, req }) payload.logger.info(`— Seeding Default...`) + await seedMedia({ payload, req }) await seedCategories({ payload, req }) payload.logger.info(`— Seeding DurianPy Website Collections...`) diff --git a/src/seed/assets/durianpy-logo.png b/src/seed/assets/durianpy-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..fa3687977b67825b990f49fa041f33f9ebde543a GIT binary patch literal 10547 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 g|1