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 { 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: [
{

View File

@@ -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: [
{

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 { 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(),
],

View File

@@ -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,

View File

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

View File

@@ -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],

View File

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