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:
@@ -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: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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: [
|
||||||
{
|
{
|
||||||
|
|||||||
13
apps/backend/src/access/adminOnly.ts
Normal file
13
apps/backend/src/access/adminOnly.ts
Normal 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'
|
||||||
|
}
|
||||||
14
apps/backend/src/access/adminOrEditor.ts
Normal file
14
apps/backend/src/access/adminOrEditor.ts
Normal 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'
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user