Audit fixes: VIP self-grant, draft leak, onboarding flag, hero personalization, gated downloads
Deploy GitHub Pages / deploy (push) Canceled after 0s

This commit is contained in:
AHMED
2026-10-09 16:20:55 +03:00
parent c34611dc36
commit 5ee706ebd6
150 changed files with 24054 additions and 0 deletions
+116
View File
@@ -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