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.
This commit is contained in:
2026-01-31 13:03:16 +08:00
parent 2d3d144a66
commit d0e8c3bcff
9 changed files with 148 additions and 22 deletions

View File

@@ -1,5 +1,6 @@
import type { GlobalConfig } from 'payload' import type { GlobalConfig } from 'payload'
import { adminOnly } from '../access/adminOnly'
import { link } from '@/fields/link' import { link } from '@/fields/link'
import { revalidateFooter } from './hooks/revalidateFooter' import { revalidateFooter } from './hooks/revalidateFooter'
@@ -7,6 +8,7 @@ export const Footer: GlobalConfig = {
slug: 'footer', slug: 'footer',
access: { access: {
read: () => true, read: () => true,
update: adminOnly,
}, },
fields: [ fields: [
{ {

View File

@@ -1,5 +1,6 @@
import type { GlobalConfig } from 'payload' import type { GlobalConfig } from 'payload'
import { adminOnly } from '../access/adminOnly'
import { link } from '@/fields/link' import { link } from '@/fields/link'
import { revalidateHeader } from './hooks/revalidateHeader' import { revalidateHeader } from './hooks/revalidateHeader'
@@ -7,6 +8,7 @@ export const Header: GlobalConfig = {
slug: 'header', slug: 'header',
access: { access: {
read: () => true, read: () => true,
update: adminOnly,
}, },
fields: [ fields: [
{ {

View File

@@ -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'
}

View File

@@ -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'
}

View File

@@ -2,15 +2,16 @@ import type { CollectionConfig } from 'payload'
import { anyone } from '../access/anyone' import { anyone } from '../access/anyone'
import { authenticated } from '../access/authenticated' import { authenticated } from '../access/authenticated'
import { adminOrEditor } from '../access/adminOrEditor'
import { slugField } from '@/fields/slug' import { slugField } from '@/fields/slug'
export const Categories: CollectionConfig = { export const Categories: CollectionConfig = {
slug: 'categories', slug: 'categories',
access: { access: {
create: authenticated, create: adminOrEditor,
delete: authenticated, delete: adminOrEditor,
read: anyone, read: anyone,
update: authenticated, update: adminOrEditor,
}, },
admin: { admin: {
useAsTitle: 'title', useAsTitle: 'title',
@@ -20,6 +21,43 @@ export const Categories: CollectionConfig = {
name: 'title', name: 'title',
type: 'text', type: 'text',
required: true, 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(), ...slugField(),
], ],

View File

@@ -2,10 +2,10 @@ import type { CollectionConfig } from 'payload'
import { authenticated } from '../../access/authenticated' import { authenticated } from '../../access/authenticated'
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished' import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
import { adminOrEditor } from '../../access/adminOrEditor'
import { Archive } from '../../blocks/ArchiveBlock/config' import { Archive } from '../../blocks/ArchiveBlock/config'
import { CallToAction } from '../../blocks/CallToAction/config' import { CallToAction } from '../../blocks/CallToAction/config'
import { Content } from '../../blocks/Content/config' import { Content } from '../../blocks/Content/config'
import { FormBlock } from '../../blocks/Form/config'
import { MediaBlock } from '../../blocks/MediaBlock/config' import { MediaBlock } from '../../blocks/MediaBlock/config'
import { hero } from '@/heros/config' import { hero } from '@/heros/config'
import { slugField } from '@/fields/slug' import { slugField } from '@/fields/slug'
@@ -24,10 +24,10 @@ import {
export const Pages: CollectionConfig<'pages'> = { export const Pages: CollectionConfig<'pages'> = {
slug: 'pages', slug: 'pages',
access: { access: {
create: authenticated, create: adminOrEditor,
delete: authenticated, delete: adminOrEditor,
read: authenticatedOrPublished, read: authenticatedOrPublished,
update: authenticated, update: adminOrEditor,
}, },
// This config controls what's populated by default when a page is referenced // This config controls what's populated by default when a page is referenced
// https://payloadcms.com/docs/queries/select#defaultpopulate-collection-config-property // https://payloadcms.com/docs/queries/select#defaultpopulate-collection-config-property
@@ -75,7 +75,7 @@ export const Pages: CollectionConfig<'pages'> = {
{ {
name: 'layout', name: 'layout',
type: 'blocks', type: 'blocks',
blocks: [CallToAction, Content, MediaBlock, Archive, FormBlock], blocks: [CallToAction, Content, MediaBlock, Archive],
required: true, required: true,
admin: { admin: {
initCollapsed: true, initCollapsed: true,

View File

@@ -1,16 +1,16 @@
import type { CollectionConfig } from 'payload' import type { CollectionConfig } from 'payload'
import { authenticated } from '../../access/authenticated'
import { anyone } from '../../access/anyone' import { anyone } from '../../access/anyone'
import { adminOrEditor } from '../../access/adminOrEditor'
import { slugField } from '@/fields/slug' import { slugField } from '@/fields/slug'
export const Portfolio: CollectionConfig = { export const Portfolio: CollectionConfig = {
slug: 'portfolio', slug: 'portfolio',
access: { access: {
create: authenticated, create: adminOrEditor,
read: anyone, read: anyone,
update: authenticated, update: adminOrEditor,
delete: authenticated, delete: adminOrEditor,
}, },
admin: { admin: {
useAsTitle: 'title', useAsTitle: 'title',

View File

@@ -11,6 +11,7 @@ import {
import { authenticated } from '../../access/authenticated' import { authenticated } from '../../access/authenticated'
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished' import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
import { adminOrEditor } from '../../access/adminOrEditor'
import { Banner } from '../../blocks/Banner/config' import { Banner } from '../../blocks/Banner/config'
import { Code } from '../../blocks/Code/config' import { Code } from '../../blocks/Code/config'
import { MediaBlock } from '../../blocks/MediaBlock/config' import { MediaBlock } from '../../blocks/MediaBlock/config'
@@ -30,10 +31,10 @@ import { slugField } from '@/fields/slug'
export const Posts: CollectionConfig<'posts'> = { export const Posts: CollectionConfig<'posts'> = {
slug: 'posts', slug: 'posts',
access: { access: {
create: authenticated, create: adminOrEditor,
delete: authenticated, delete: adminOrEditor,
read: authenticatedOrPublished, read: authenticatedOrPublished,
update: authenticated, update: adminOrEditor,
}, },
// This config controls what's populated by default when a post is referenced // This config controls what's populated by default when a post is referenced
// https://payloadcms.com/docs/queries/select#defaultpopulate-collection-config-property // https://payloadcms.com/docs/queries/select#defaultpopulate-collection-config-property
@@ -84,6 +85,15 @@ export const Posts: CollectionConfig<'posts'> = {
type: 'upload', type: 'upload',
relationTo: 'media', relationTo: 'media',
}, },
{
name: 'ogImage',
type: 'upload',
relationTo: 'media',
label: '社群分享圖片',
admin: {
description: 'Facebook/LINE 分享時顯示的預覽圖,建議 1200x630px',
},
},
{ {
name: 'content', name: 'content',
type: 'richText', type: 'richText',
@@ -102,6 +112,16 @@ export const Posts: CollectionConfig<'posts'> = {
label: false, label: false,
required: true, required: true,
}, },
{
name: 'excerpt',
type: 'text',
label: '文章摘要',
admin: {
description: '顯示在文章列表頁,建議 150-200 字',
multiline: true,
},
maxLength: 200,
},
], ],
label: 'Content', label: 'Content',
}, },
@@ -132,6 +152,15 @@ export const Posts: CollectionConfig<'posts'> = {
hasMany: true, hasMany: true,
relationTo: 'categories', relationTo: 'categories',
}, },
{
name: 'showInFooter',
type: 'checkbox',
label: '顯示在頁腳',
defaultValue: false,
admin: {
position: 'sidebar',
},
},
], ],
label: 'Meta', label: 'Meta',
}, },
@@ -218,6 +247,20 @@ export const Posts: CollectionConfig<'posts'> = {
], ],
}, },
...slugField(), ...slugField(),
{
name: 'status',
type: 'select',
label: '文章狀態',
defaultValue: 'draft',
options: [
{ label: '草稿', value: 'draft' },
{ label: '審核中', value: 'review' },
{ label: '已發布', value: 'published' },
],
admin: {
position: 'sidebar',
},
},
], ],
hooks: { hooks: {
afterChange: [revalidatePost], afterChange: [revalidatePost],

View File

@@ -1,18 +1,18 @@
import type { CollectionConfig } from 'payload' import type { CollectionConfig } from 'payload'
import { authenticated } from '../../access/authenticated' import { adminOnly } from '../../access/adminOnly'
export const Users: CollectionConfig = { export const Users: CollectionConfig = {
slug: 'users', slug: 'users',
access: { access: {
admin: authenticated, admin: adminOnly,
create: authenticated, create: adminOnly,
delete: authenticated, delete: adminOnly,
read: authenticated, read: adminOnly,
update: authenticated, update: adminOnly,
}, },
admin: { admin: {
defaultColumns: ['name', 'email'], defaultColumns: ['name', 'email', 'role'],
useAsTitle: 'name', useAsTitle: 'name',
}, },
auth: true, auth: true,
@@ -21,6 +21,20 @@ export const Users: CollectionConfig = {
name: 'name', name: 'name',
type: 'text', type: 'text',
}, },
{
name: 'role',
type: 'select',
label: '角色',
defaultValue: 'editor',
required: true,
options: [
{ label: '管理員', value: 'admin' },
{ label: '編輯者', value: 'editor' },
],
admin: {
position: 'sidebar',
},
},
], ],
timestamps: true, timestamps: true,
} }