diff --git a/AGENTS.md b/AGENTS.md index ac853cf..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 @@ -1123,7 +1128,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/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/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/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..39b82e4 --- /dev/null +++ b/src/access/checkResourceAccess.ts @@ -0,0 +1,96 @@ +import type { AccessArgs, Where, CollectionConfig, GlobalConfig } 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 | SidebarGroupSlug | 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 + }) +} + +/** + * 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/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/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 0b95bbc..1db56fe 100644 --- a/src/collections/Media.ts +++ b/src/collections/Media.ts @@ -8,21 +8,23 @@ 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) 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', + slug: COLLECTIONS.MEDIA, folders: true, access: { + ...createCollectionAccess(COLLECTIONS.MEDIA, false), create: authenticated, delete: authenticated, - read: anyone, update: authenticated, }, fields: [ diff --git a/src/collections/ServiceAccounts.ts b/src/collections/ServiceAccounts.ts new file mode 100644 index 0000000..79ba358 --- /dev/null +++ b/src/collections/ServiceAccounts.ts @@ -0,0 +1,89 @@ +import type { CollectionConfig } from 'payload' +import { anyAdmin } from '@/access/anyAdmin' +import { USER_ROLE_LABELS, USER_ROLES } from '@/constants/userRoles' +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, + labels: COLLECTION_LABELS[COLLECTIONS.SERVICE_ACCOUNTS], + access: { + admin: anyAdmin, + 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: getSidebarGroupLabel(SIDEBAR_GROUPS.ADMIN), + }, + auth: { + useAPIKey: true, + disableLocalStrategy: 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: 'permissions', + type: 'array', + access: { + create: ({ req }) => anyAdmin({ req }), + update: ({ req }) => anyAdmin({ req }), + }, + fields: [ + { + name: 'resource', + type: 'select', + hasMany: false, + required: true, + options: PERMISSION_RESOURCE_OPTIONS.map(({ slug, label }) => ({ + value: slug, + label, + })), + }, + { + name: 'accessLevel', + type: 'radio', + required: true, + options: Object.values(PERMISSIONS).map((permission) => ({ + value: permission, + label: PERMISSION_LABELS[permission], + })), + }, + ], + }, + ], + timestamps: true, +} diff --git a/src/collections/Users/index.ts b/src/collections/Users/index.ts index fc76001..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('durianpy-website'), + 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..f19bdcf 100644 --- a/src/collections/durianpy-website/Events.ts +++ b/src/collections/durianpy-website/Events.ts @@ -1,12 +1,9 @@ -import { checkCollectionAccess } from '@/access/checkCollectionAccess' -import { AccessType } from '@/constants/accessTypes' -import { COLLECTION_LABELS, COLLECTIONS, getCollectionGroupLabel } from '@/constants/collections' -import type { AccessArgs, CollectionConfig } from 'payload' +import { createCollectionAccess } from '@/access/checkResourceAccess' +import { COLLECTION_LABELS, COLLECTIONS } from '@/constants/collections' +import { SIDEBAR_GROUPS, getSidebarGroupLabel } from '@/constants/sidebarGroup' +import type { CollectionConfig } from 'payload' import type { CollectionBeforeChangeHook } from 'payload' -const checkEventsAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkCollectionAccess(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({ @@ -16,6 +13,7 @@ const ensureSingleFeatured: CollectionBeforeChangeHook = async ({ data, req, ori id: { not_equals: originalDoc?.id }, }, limit: 0, + req, }) await Promise.all( @@ -24,6 +22,7 @@ const ensureSingleFeatured: CollectionBeforeChangeHook = async ({ data, req, ori collection: COLLECTIONS.DURIANPY_WEBSITE_EVENTS, id: doc.id, data: { isFeatured: false }, + req, }), ), ) @@ -34,19 +33,10 @@ 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: 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..73df58c 100644 --- a/src/collections/durianpy-website/SIGs.ts +++ b/src/collections/durianpy-website/SIGs.ts @@ -1,31 +1,16 @@ -import { anyone } from '@/access/anyone' -import { checkCollectionAccess } from '@/access/checkCollectionAccess' -import { AccessType } from '@/constants/accessTypes' -import { - COLLECTION_LABELS, - getCollectionGroupLabel, - COLLECTION_GROUPS, - COLLECTIONS, -} from '@/constants/collections' -import type { AccessArgs, CollectionConfig } from 'payload' - -const checkSIGsAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkCollectionAccess(access, COLLECTIONS.DURIANPY_WEBSITE_SIGS, accessType) +import { createCollectionAccess } from '@/access/checkResourceAccess' +import { COLLECTION_LABELS, COLLECTIONS } from '@/constants/collections' +import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' +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'], - 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..e3c5325 100644 --- a/src/collections/durianpy-website/Sponsors.ts +++ b/src/collections/durianpy-website/Sponsors.ts @@ -1,31 +1,16 @@ -import { anyone } from '@/access/anyone' -import { checkCollectionAccess } from '@/access/checkCollectionAccess' -import { AccessType } from '@/constants/accessTypes' -import { - COLLECTIONS, - COLLECTION_LABELS, - getCollectionGroupLabel, - COLLECTION_GROUPS, -} from '@/constants/collections' -import type { AccessArgs, CollectionConfig } from 'payload' - -const checkSponsorsAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkCollectionAccess(access, COLLECTIONS.DURIANPY_WEBSITE_SPONSORS, accessType) +import { createCollectionAccess } from '@/access/checkResourceAccess' +import { COLLECTIONS, COLLECTION_LABELS } from '@/constants/collections' +import { SIDEBAR_GROUPS, getSidebarGroupLabel } from '@/constants/sidebarGroup' +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'], - 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..c1918f8 100644 --- a/src/collections/durianpy-website/sample-website-collection.index.ts +++ b/src/collections/durianpy-website/sample-website-collection.index.ts @@ -1,26 +1,16 @@ -import { anyone } from '@/access/anyone' -import { checkCollectionAccess } from '@/access/checkCollectionAccess' -import { AccessType } from '@/constants/accessTypes' -import { COLLECTION_LABELS, getCollectionGroupLabel } from '@/constants/collections' -import type { AccessArgs, CollectionConfig } from 'payload' - -const checkSampleAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkCollectionAccess(access, 'sample', accessType) +import { createCollectionAccess } from '@/access/checkResourceAccess' +import { COLLECTIONS, COLLECTION_LABELS } from '@/constants/collections' +import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' +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'], - 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 fcf8efc..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 @@ -14,9 +14,8 @@ export const COLLECTIONS = { MEDIA: 'media', USERS: 'users', SAMPLE: 'sample', - DURIANPY_WEBSITE_EVENTS: 'durianpy-website-events', - DURIANPY_WEBSITE_SPONSORS: 'durianpy-website-sponsors', - DURIANPY_WEBSITE_SIGS: 'durianpy-website-sigs', + SERVICE_ACCOUNTS: 'service-accounts', + ...DURIANPY_WEBSITE_COLLECTIONS, } as const export type CollectionSlug = (typeof COLLECTIONS)[keyof typeof COLLECTIONS] @@ -26,72 +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: ['users'], -} 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 - } + [COLLECTIONS.SERVICE_ACCOUNTS]: { singular: 'Service Account', plural: 'Service Accounts' }, + ...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..7935148 --- /dev/null +++ b/src/constants/sidebarGroup.ts @@ -0,0 +1,41 @@ +import { COLLECTIONS, CollectionSlug } from './collections' +import { DURIANPY_WEBSITE_ITEMS } from './durianpy' +import { 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/endpoints/seed/index.ts b/src/endpoints/seed/index.ts index d42520e..2179fd8 100644 --- a/src/endpoints/seed/index.ts +++ b/src/endpoints/seed/index.ts @@ -1,9 +1,20 @@ 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 { 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' +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) @@ -31,25 +42,39 @@ 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: {} })), ) - 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 seedMedia({ payload, req }) + 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/globals/durianpy-website/CTASection.ts b/src/globals/durianpy-website/CTASection.ts index bd7c884..3d92122 100644 --- a/src/globals/durianpy-website/CTASection.ts +++ b/src/globals/durianpy-website/CTASection.ts @@ -1,26 +1,18 @@ -import { AccessArgs, GlobalConfig } from 'payload' +import { GlobalConfig } from 'payload' -import { checkCollectionAccess } from '@/access/checkCollectionAccess' -import { getCollectionGroupLabel } from '@/constants/collections' -import { anyone } from '@/access/anyone' -import { AccessType } from '@/constants/accessTypes' +import { createGlobalAccess } from '@/access/checkResourceAccess' import { GLOBAL_LABELS, GLOBALS } from '@/constants/globals' - -const checkCTASectionAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkCollectionAccess(access, GLOBALS.DURIANPY_WEBSITE_CTA_SECTION, accessType) +import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' 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: { - 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 0ed552c..fb79783 100644 --- a/src/globals/durianpy-website/Carousel.ts +++ b/src/globals/durianpy-website/Carousel.ts @@ -1,37 +1,25 @@ import { anyAdmin } from '@/access/anyAdmin' -import { checkCollectionAccess } from '@/access/checkCollectionAccess' -import { AccessType } from '@/constants/accessTypes' -import { getCollectionGroupLabel } from '@/constants/collections' +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) => - checkCollectionAccess(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].singular, + 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, }, 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..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 { checkCollectionAccess } from '@/access/checkCollectionAccess' -import { AccessType } from '@/constants/accessTypes' +import { createGlobalAccess } from '@/access/checkResourceAccess' import { FixedToolbarFeature, HeadingFeature, @@ -9,22 +7,16 @@ 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' - -const checkCodeOfConductAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkCollectionAccess(access, GLOBALS.DURIANPY_WEBSITE_CODE_OF_CONDUCT, accessType) +import type { GlobalConfig } from 'payload' +import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' 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'), - }, + access: createGlobalAccess(GLOBALS.DURIANPY_WEBSITE_CODE_OF_CONDUCT, true), 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..50bcf60 100644 --- a/src/globals/durianpy-website/HomepageConfig.ts +++ b/src/globals/durianpy-website/HomepageConfig.ts @@ -1,26 +1,18 @@ -import { GlobalConfig, AccessArgs } from 'payload' -import { checkCollectionAccess } from '@/access/checkCollectionAccess' -import { getCollectionGroupLabel } from '@/constants/collections' +import { GlobalConfig } from 'payload' +import { createGlobalAccess } from '@/access/checkResourceAccess' import { GLOBALS, GLOBAL_LABELS } from '@/constants/globals' - -type AccessType = 'create' | 'read' | 'update' | 'delete' - -const checkHomepageConfigAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkCollectionAccess(access, GLOBALS.DURIANPY_WEBSITE_HOMEPAGE_CONFIG, accessType) +import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' 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, }, - 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 0071315..66c2288 100644 --- a/src/globals/durianpy-website/StatisticsConfig.ts +++ b/src/globals/durianpy-website/StatisticsConfig.ts @@ -1,26 +1,18 @@ -import { GlobalConfig, AccessArgs } from 'payload' -import { checkCollectionAccess } from '@/access/checkCollectionAccess' -import { getCollectionGroupLabel } from '@/constants/collections' +import { GlobalConfig } from 'payload' +import { createGlobalAccess } from '@/access/checkResourceAccess' import { GLOBALS, GLOBAL_LABELS } from '@/constants/globals' - -type AccessType = 'create' | 'read' | 'update' | 'delete' - -const checkStatisticsConfigAccess = (accessType?: AccessType) => (access: AccessArgs) => - checkCollectionAccess(access, GLOBALS.DURIANPY_WEBSITE_STATISTICS_CONFIG, accessType) +import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' 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, }, - access: { - read: () => true, - update: checkStatisticsConfigAccess('update'), - }, + access: createGlobalAccess(GLOBALS.DURIANPY_WEBSITE_STATISTICS_CONFIG, true), fields: [ { name: 'metrics', diff --git a/src/payload-types.ts b/src/payload-types.ts index 72be1c6..77df88a 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". @@ -309,24 +330,25 @@ 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' | 'sample' + | '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; @@ -361,6 +383,43 @@ 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')[]; + permissions?: + | { + 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' + | 'sample' + | 'service-accounts' + | 'durianpy-website-events' + | 'durianpy-website-sponsors' + | 'durianpy-website-sigs'; + accessLevel: 'read' | 'read-write' | 'full-access'; + id?: string | null; + }[] + | null; + updatedAt: string; + createdAt: string; + enableAPIKey?: boolean | null; + apiKey?: string | null; + apiKeyIndex?: string | null; + collection: 'service-accounts'; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "durianpy-website-events". @@ -733,6 +792,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 +821,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 +839,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?: | { @@ -921,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; @@ -956,6 +1029,26 @@ 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; + permissions?: + | T + | { + resource?: T; + accessLevel?: T; + id?: T; + }; + updatedAt?: T; + createdAt?: T; + enableAPIKey?: T; + apiKey?: T; + apiKeyIndex?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "durianpy-website-events_select". @@ -1456,4 +1549,4 @@ export interface Auth { declare module 'payload' { export interface GeneratedTypes extends Config {} -} \ No newline at end of file +} 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 { 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/assets/durianpy-logo.png b/src/seed/assets/durianpy-logo.png new file mode 100644 index 0000000..fa36879 Binary files /dev/null and b/src/seed/assets/durianpy-logo.png differ 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/default/collections/Media.ts b/src/seed/default/collections/Media.ts new file mode 100644 index 0000000..d8fdb7a --- /dev/null +++ b/src/seed/default/collections/Media.ts @@ -0,0 +1,25 @@ +import fs from 'fs' +import path from 'path' +import { Payload, PayloadRequest } from 'payload' +import { COLLECTIONS } from '@/constants/collections' + +export async function seedMedia({ payload, req }: { payload: Payload; req?: PayloadRequest }) { + const imagePath = path.resolve(process.cwd(), 'src/seed/assets/durianpy-logo.png') + const imageBuffer = fs.readFileSync(imagePath) + + const mediaItem = await payload.create({ + collection: COLLECTIONS.MEDIA, + data: { + alt: 'DurianPy Logo', + }, + file: { + data: imageBuffer, + name: 'durianpy-logo.png', + mimetype: 'image/png', + size: imageBuffer.length, + }, + req, + }) + + return [mediaItem] +} diff --git a/src/seed/durianpy-website/collections/Events.ts b/src/seed/durianpy-website/collections/Events.ts new file mode 100644 index 0000000..32b2f60 --- /dev/null +++ b/src/seed/durianpy-website/collections/Events.ts @@ -0,0 +1,44 @@ +import { Payload, PayloadRequest } from 'payload' +import { COLLECTIONS } from '@/constants/collections' + +export async function seedEvents({ payload, req }: { payload: Payload; req?: PayloadRequest }) { + const { docs: mediaDocs } = await payload.find({ + collection: COLLECTIONS.MEDIA, + limit: 1, + req, + }) + const mediaId = mediaDocs[0]?.id + + 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, + coverImage: mediaId, + _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, + coverImage: mediaId, + _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', + }, + }) +} diff --git a/tests/unit/access/checkResourceAccess.spec.ts b/tests/unit/access/checkResourceAccess.spec.ts new file mode 100644 index 0000000..fd7a1be --- /dev/null +++ b/tests/unit/access/checkResourceAccess.spec.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +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' + +// 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 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 = { + 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) + }) + + 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 / 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: { + 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 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 = { + 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 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 = { + 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) + }) + + 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) + }) + }) +}) 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) }) })