From d0e8c3bcff5ec74d2edb05ed5537f78a13a263b2 Mon Sep 17 00:00:00 2001 From: pkupuk Date: Sat, 31 Jan 2026 13:03:16 +0800 Subject: [PATCH] Add role-based access control with admin/editor roles Create adminOnly and adminOrEditor access functions. Add role field to Users collection (admin/editor, default: editor). Update access control across all collections and globals to enforce role-based permissions. --- apps/backend/src/Footer/config.ts | 2 + apps/backend/src/Header/config.ts | 2 + apps/backend/src/access/adminOnly.ts | 13 +++++ apps/backend/src/access/adminOrEditor.ts | 14 ++++++ apps/backend/src/collections/Categories.ts | 44 +++++++++++++++-- apps/backend/src/collections/Pages/index.ts | 10 ++-- .../src/collections/Portfolio/index.ts | 8 +-- apps/backend/src/collections/Posts/index.ts | 49 +++++++++++++++++-- apps/backend/src/collections/Users/index.ts | 28 ++++++++--- 9 files changed, 148 insertions(+), 22 deletions(-) create mode 100644 apps/backend/src/access/adminOnly.ts create mode 100644 apps/backend/src/access/adminOrEditor.ts diff --git a/apps/backend/src/Footer/config.ts b/apps/backend/src/Footer/config.ts index 6716d41..909a825 100644 --- a/apps/backend/src/Footer/config.ts +++ b/apps/backend/src/Footer/config.ts @@ -1,5 +1,6 @@ import type { GlobalConfig } from 'payload' +import { adminOnly } from '../access/adminOnly' import { link } from '@/fields/link' import { revalidateFooter } from './hooks/revalidateFooter' @@ -7,6 +8,7 @@ export const Footer: GlobalConfig = { slug: 'footer', access: { read: () => true, + update: adminOnly, }, fields: [ { diff --git a/apps/backend/src/Header/config.ts b/apps/backend/src/Header/config.ts index 58fe89c..9075ce9 100644 --- a/apps/backend/src/Header/config.ts +++ b/apps/backend/src/Header/config.ts @@ -1,5 +1,6 @@ import type { GlobalConfig } from 'payload' +import { adminOnly } from '../access/adminOnly' import { link } from '@/fields/link' import { revalidateHeader } from './hooks/revalidateHeader' @@ -7,6 +8,7 @@ export const Header: GlobalConfig = { slug: 'header', access: { read: () => true, + update: adminOnly, }, fields: [ { diff --git a/apps/backend/src/access/adminOnly.ts b/apps/backend/src/access/adminOnly.ts new file mode 100644 index 0000000..b413b53 --- /dev/null +++ b/apps/backend/src/access/adminOnly.ts @@ -0,0 +1,13 @@ +import type { Access } from 'payload' + +/** + * 僅允許 Admin 角色訪問 + * + * 用例: + * - Users collection (敏感操作) + * - Globals (Header/Footer) + * - System settings + */ +export const adminOnly: Access = ({ req: { user } }) => { + return user?.role === 'admin' +} diff --git a/apps/backend/src/access/adminOrEditor.ts b/apps/backend/src/access/adminOrEditor.ts new file mode 100644 index 0000000..7ba00eb --- /dev/null +++ b/apps/backend/src/access/adminOrEditor.ts @@ -0,0 +1,14 @@ +import type { Access } from 'payload' + +/** + * 允許 Admin 或 Editor 角色訪問 + * + * 用例: + * - Posts/Pages collection (內容管理) + * - Categories collection (內容分類) + * - Portfolio collection (作品管理) + */ +export const adminOrEditor: Access = ({ req: { user } }) => { + if (!user) return false + return user?.role === 'admin' || user?.role === 'editor' +} diff --git a/apps/backend/src/collections/Categories.ts b/apps/backend/src/collections/Categories.ts index 19d2b0c..7e8fcf9 100644 --- a/apps/backend/src/collections/Categories.ts +++ b/apps/backend/src/collections/Categories.ts @@ -2,15 +2,16 @@ import type { CollectionConfig } from 'payload' import { anyone } from '../access/anyone' import { authenticated } from '../access/authenticated' +import { adminOrEditor } from '../access/adminOrEditor' import { slugField } from '@/fields/slug' export const Categories: CollectionConfig = { slug: 'categories', access: { - create: authenticated, - delete: authenticated, + create: adminOrEditor, + delete: adminOrEditor, read: anyone, - update: authenticated, + update: adminOrEditor, }, admin: { useAsTitle: 'title', @@ -20,6 +21,43 @@ export const Categories: CollectionConfig = { name: 'title', type: 'text', required: true, + label: '分類名稱(中文)', + }, + { + name: 'nameEn', + type: 'text', + label: '英文名稱', + admin: { + description: '用於 URL 或國際化', + }, + }, + { + name: 'order', + type: 'number', + label: '排序順序', + defaultValue: 0, + admin: { + position: 'sidebar', + description: '數字越小越靠前', + }, + }, + { + name: 'textColor', + type: 'text', + label: '文字顏色', + defaultValue: '#000000', + admin: { + description: '十六進制顏色碼,例如 #000000', + }, + }, + { + name: 'backgroundColor', + type: 'text', + label: '背景顏色', + defaultValue: '#ffffff', + admin: { + description: '十六進制顏色碼,例如 #ffffff', + }, }, ...slugField(), ], diff --git a/apps/backend/src/collections/Pages/index.ts b/apps/backend/src/collections/Pages/index.ts index c81ffa8..4360687 100644 --- a/apps/backend/src/collections/Pages/index.ts +++ b/apps/backend/src/collections/Pages/index.ts @@ -2,10 +2,10 @@ import type { CollectionConfig } from 'payload' import { authenticated } from '../../access/authenticated' import { authenticatedOrPublished } from '../../access/authenticatedOrPublished' +import { adminOrEditor } from '../../access/adminOrEditor' import { Archive } from '../../blocks/ArchiveBlock/config' import { CallToAction } from '../../blocks/CallToAction/config' import { Content } from '../../blocks/Content/config' -import { FormBlock } from '../../blocks/Form/config' import { MediaBlock } from '../../blocks/MediaBlock/config' import { hero } from '@/heros/config' import { slugField } from '@/fields/slug' @@ -24,10 +24,10 @@ import { export const Pages: CollectionConfig<'pages'> = { slug: 'pages', access: { - create: authenticated, - delete: authenticated, + create: adminOrEditor, + delete: adminOrEditor, read: authenticatedOrPublished, - update: authenticated, + update: adminOrEditor, }, // This config controls what's populated by default when a page is referenced // https://payloadcms.com/docs/queries/select#defaultpopulate-collection-config-property @@ -75,7 +75,7 @@ export const Pages: CollectionConfig<'pages'> = { { name: 'layout', type: 'blocks', - blocks: [CallToAction, Content, MediaBlock, Archive, FormBlock], + blocks: [CallToAction, Content, MediaBlock, Archive], required: true, admin: { initCollapsed: true, diff --git a/apps/backend/src/collections/Portfolio/index.ts b/apps/backend/src/collections/Portfolio/index.ts index 2714028..69d6a20 100644 --- a/apps/backend/src/collections/Portfolio/index.ts +++ b/apps/backend/src/collections/Portfolio/index.ts @@ -1,16 +1,16 @@ import type { CollectionConfig } from 'payload' -import { authenticated } from '../../access/authenticated' import { anyone } from '../../access/anyone' +import { adminOrEditor } from '../../access/adminOrEditor' import { slugField } from '@/fields/slug' export const Portfolio: CollectionConfig = { slug: 'portfolio', access: { - create: authenticated, + create: adminOrEditor, read: anyone, - update: authenticated, - delete: authenticated, + update: adminOrEditor, + delete: adminOrEditor, }, admin: { useAsTitle: 'title', diff --git a/apps/backend/src/collections/Posts/index.ts b/apps/backend/src/collections/Posts/index.ts index 3ccaeda..8e5ecde 100644 --- a/apps/backend/src/collections/Posts/index.ts +++ b/apps/backend/src/collections/Posts/index.ts @@ -11,6 +11,7 @@ import { import { authenticated } from '../../access/authenticated' import { authenticatedOrPublished } from '../../access/authenticatedOrPublished' +import { adminOrEditor } from '../../access/adminOrEditor' import { Banner } from '../../blocks/Banner/config' import { Code } from '../../blocks/Code/config' import { MediaBlock } from '../../blocks/MediaBlock/config' @@ -30,10 +31,10 @@ import { slugField } from '@/fields/slug' export const Posts: CollectionConfig<'posts'> = { slug: 'posts', access: { - create: authenticated, - delete: authenticated, + create: adminOrEditor, + delete: adminOrEditor, read: authenticatedOrPublished, - update: authenticated, + update: adminOrEditor, }, // This config controls what's populated by default when a post is referenced // https://payloadcms.com/docs/queries/select#defaultpopulate-collection-config-property @@ -84,6 +85,15 @@ export const Posts: CollectionConfig<'posts'> = { type: 'upload', relationTo: 'media', }, + { + name: 'ogImage', + type: 'upload', + relationTo: 'media', + label: '社群分享圖片', + admin: { + description: 'Facebook/LINE 分享時顯示的預覽圖,建議 1200x630px', + }, + }, { name: 'content', type: 'richText', @@ -102,6 +112,16 @@ export const Posts: CollectionConfig<'posts'> = { label: false, required: true, }, + { + name: 'excerpt', + type: 'text', + label: '文章摘要', + admin: { + description: '顯示在文章列表頁,建議 150-200 字', + multiline: true, + }, + maxLength: 200, + }, ], label: 'Content', }, @@ -132,6 +152,15 @@ export const Posts: CollectionConfig<'posts'> = { hasMany: true, relationTo: 'categories', }, + { + name: 'showInFooter', + type: 'checkbox', + label: '顯示在頁腳', + defaultValue: false, + admin: { + position: 'sidebar', + }, + }, ], label: 'Meta', }, @@ -218,6 +247,20 @@ export const Posts: CollectionConfig<'posts'> = { ], }, ...slugField(), + { + name: 'status', + type: 'select', + label: '文章狀態', + defaultValue: 'draft', + options: [ + { label: '草稿', value: 'draft' }, + { label: '審核中', value: 'review' }, + { label: '已發布', value: 'published' }, + ], + admin: { + position: 'sidebar', + }, + }, ], hooks: { afterChange: [revalidatePost], diff --git a/apps/backend/src/collections/Users/index.ts b/apps/backend/src/collections/Users/index.ts index f0555a7..7fa860c 100644 --- a/apps/backend/src/collections/Users/index.ts +++ b/apps/backend/src/collections/Users/index.ts @@ -1,18 +1,18 @@ import type { CollectionConfig } from 'payload' -import { authenticated } from '../../access/authenticated' +import { adminOnly } from '../../access/adminOnly' export const Users: CollectionConfig = { slug: 'users', access: { - admin: authenticated, - create: authenticated, - delete: authenticated, - read: authenticated, - update: authenticated, + admin: adminOnly, + create: adminOnly, + delete: adminOnly, + read: adminOnly, + update: adminOnly, }, admin: { - defaultColumns: ['name', 'email'], + defaultColumns: ['name', 'email', 'role'], useAsTitle: 'name', }, auth: true, @@ -21,6 +21,20 @@ export const Users: CollectionConfig = { name: 'name', type: 'text', }, + { + name: 'role', + type: 'select', + label: '角色', + defaultValue: 'editor', + required: true, + options: [ + { label: '管理員', value: 'admin' }, + { label: '編輯者', value: 'editor' }, + ], + admin: { + position: 'sidebar', + }, + }, ], timestamps: true, }