Audit fixes: VIP self-grant, draft leak, onboarding flag, hero personalization, gated downloads
Deploy GitHub Pages / deploy (push) Canceled after 0s
Deploy GitHub Pages / deploy (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import type { AccessArgs, CollectionConfig } from 'payload'
|
||||
|
||||
const isAdmin = (args: AccessArgs) =>
|
||||
(args.req.user as { role?: string } | null | undefined)?.role === 'admin'
|
||||
|
||||
export const CATEGORIES = ['وكلاء', 'نماذج', 'استرجاع', 'سلامة', 'أدوات', 'أعمال'] as const
|
||||
|
||||
const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'slug', 'category', 'access', 'status', 'publishedAt'],
|
||||
},
|
||||
access: {
|
||||
admin: isAdmin,
|
||||
read: ({ req }) => {
|
||||
if (req.user?.role === 'admin') return true
|
||||
// Public: only published posts (per-doc filtering happens in the hook below)
|
||||
return { status: { equals: 'published' } }
|
||||
},
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: { description: 'Latin slug, used in /posts/[slug]' },
|
||||
},
|
||||
{ name: 'dek', type: 'textarea', required: true, label: 'Standfirst' },
|
||||
{
|
||||
name: 'access',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'free',
|
||||
options: [
|
||||
{ label: 'مجاني', value: 'free' },
|
||||
{ label: 'VIP', value: 'vip' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'teaser',
|
||||
type: 'textarea',
|
||||
label: 'Teaser (free preview for VIP posts)',
|
||||
admin: { condition: (_data, siblingData: { access?: string } | undefined) => siblingData?.access === 'vip' },
|
||||
validate: (value: unknown, { siblingData }: { siblingData?: { access?: string } }) => {
|
||||
if (siblingData?.access === 'vip' && (!value || String(value).trim() === ''))
|
||||
return 'Teaser is required for VIP posts'
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
required: true,
|
||||
access: {
|
||||
// Never serve VIP body to non-VIP clients — defense in depth
|
||||
// (the paywall page also never queries it for them).
|
||||
read: ((args: { doc?: { access?: string }; req: { user?: { role?: string } | null } }) => {
|
||||
if (!args.doc) return true
|
||||
if (args.doc.access !== 'vip') return true
|
||||
return args.req.user?.role === 'vip' || args.req.user?.role === 'admin'
|
||||
}) as never, // field-permission signature is structurally compatible; TS can't infer it here
|
||||
},
|
||||
},
|
||||
{
|
||||
// Per-post work files. Deliberately not field-gated: ids/filenames are
|
||||
// harmless metadata — the bytes are gated by GET /api/files/[id] +
|
||||
// each file's own `access` (see collections/Files.ts).
|
||||
name: 'attachments',
|
||||
type: 'upload',
|
||||
relationTo: 'files',
|
||||
hasMany: true,
|
||||
label: 'ملفات العمل (VIP)',
|
||||
},
|
||||
{
|
||||
name: 'category',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: CATEGORIES.map((c) => ({ label: c, value: c })),
|
||||
},
|
||||
{
|
||||
name: 'level',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'مبتدئ',
|
||||
options: [
|
||||
{ label: 'مبتدئ', value: 'مبتدئ' },
|
||||
{ label: 'متوسط', value: 'متوسط' },
|
||||
{ label: 'متقدم', value: 'متقدم' },
|
||||
],
|
||||
},
|
||||
{ name: 'views', type: 'number', defaultValue: 0, admin: { readOnly: true, position: 'sidebar' } },
|
||||
{ name: 'likes', type: 'number', defaultValue: 0, admin: { readOnly: true, position: 'sidebar' } },
|
||||
{ name: 'cover', type: 'upload', relationTo: 'media', required: true },
|
||||
{ name: 'readingMinutes', type: 'number', required: true, defaultValue: 5 },
|
||||
{ name: 'publishedAt', type: 'date', admin: { position: 'sidebar' } },
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'draft',
|
||||
options: [
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Published', value: 'published' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default Posts
|
||||
Reference in New Issue
Block a user