import type { AccessArgs, CollectionConfig } from 'payload' const isAdmin = (args: AccessArgs) => (args.req.user as { role?: string } | null | undefined)?.role === 'admin' export const ROLES = ['free', 'vip', 'admin'] as const const Users: CollectionConfig = { slug: 'users', auth: { maxLoginAttempts: 5, lockTime: 15 * 60 * 1000 }, admin: { useAsTitle: 'email', defaultColumns: ['email', 'role', 'createdAt'], }, access: { admin: isAdmin, // Only self and admins can read full user docs read: ({ req, id }) => req.user?.role === 'admin' || (id != null && req.user?.id === id), // Public open for self-signup; beforeChange forces role 'free' for non-admins create: () => true, update: ({ req, id }) => req.user?.role === 'admin' || req.user?.id === id, delete: isAdmin, }, hooks: { // Admin accounts never lock out (owner decision 2026-10-09): clear any lock // before the login flow reads it. db.updateOne bypasses hooks on purpose — // incrementLoginAttempts writes the same way, so this cannot be intercepted // in beforeChange. beforeOperation: [ async ({ args, operation, req }) => { if (operation !== 'login') return const email = typeof args?.data?.email === 'string' ? args.data.email.toLowerCase().trim() : null if (!email) return const { docs } = await req.payload.find({ collection: 'users', where: { email: { equals: email } }, limit: 1, depth: 0, overrideAccess: true, }) const user = docs[0] as { id: number; role?: string } | undefined if (user?.role !== 'admin') return await req.payload.db.updateOne({ collection: 'users', id: user.id, data: { lockUntil: null, loginAttempts: 0 }, }) }, ], // Signup must never mint VIP/admin accounts or set Stripe IDs. Local-API // activation (payment success) passes context.systemActivation to bypass. beforeChange: [ ({ data, req, operation, context, originalDoc }) => { const trusted = req.user?.role === 'admin' || context?.systemActivation === true if (!trusted) { delete data.stripeCustomerId delete data.verificationToken delete data.emailVerified // `onboarded` is intentionally self-writable: the member wizard // PATCHes { level, onboarded: true } on its own account. Signup // still cannot pre-set it — the create branch below forces false. if (operation === 'create') { data.role = 'free' data.emailVerified = false data.onboarded = false if (data.termsVersion) data.termsAcceptedAt = new Date().toISOString() } else if ('role' in data) { // keep current role — non-admins can never change it, but the // field must stay present or required-validation fails data.role = originalDoc.role } } return data }, ], }, fields: [ { name: 'fullName', type: 'text', label: 'Full Name', }, { name: 'termsVersion', type: 'text', label: 'Terms version', admin: { hidden: true } }, { name: 'termsAcceptedAt', type: 'date', label: 'Terms accepted at', admin: { hidden: true } }, { name: 'interest', type: 'select', label: 'Interest (from member onboarding)', options: [ { label: 'الوكلاء وتنسيقهم', value: 'الوكلاء' }, { label: 'الاسترجاع وRAG', value: 'RAG' }, { label: 'الأتمتة للأعمال', value: 'الأتمتة' }, { label: 'النماذج المفتوحة', value: 'النماذج المفتوحة' }, ], }, { name: 'level', type: 'select', label: 'Experience level', options: [ { label: 'مبتدئ', value: 'مبتدئ' }, { label: 'متوسط', value: 'متوسط' }, { label: 'متقدم', value: 'متقدم' }, ], }, { name: 'goal', type: 'select', label: 'Goal (from VIP onboarding)', options: [ { label: 'بناء مشروع ذكاء اصطناعي', value: 'بناء مشروع ذكاء اصطناعي' }, { label: 'تطوير مساري المهني', value: 'تطوير مساري المهني' }, { label: 'أتمتة العمل', value: 'أتمتة العمل' }, { label: 'فهم الوكلاء بعمق', value: 'فهم الوكلاء بعمق' }, ], }, { name: 'prefs', type: 'json', label: 'Notification prefs', admin: { description: 'weekly/trackUpdates/replies switches (VIP onboarding)' }, }, { name: 'emailVerified', type: 'checkbox', defaultValue: false, label: 'Email verified' }, { name: 'verificationToken', type: 'text', hidden: true, admin: { readOnly: true, description: 'Email verification token (hashed at rest not required for this flow)' }, }, { name: 'onboarded', type: 'checkbox', defaultValue: false, label: 'Onboarding completed' }, { name: 'role', type: 'select', required: true, defaultValue: 'free', options: [ { label: 'Free', value: 'free' }, { label: 'VIP', value: 'vip' }, { label: 'Admin', value: 'admin' }, ], }, { name: 'stripeCustomerId', type: 'text', label: 'Stripe Customer ID', admin: { readOnly: true, position: 'sidebar' }, }, ], } export default Users