import type { CollectionConfig } from "payload"; import { requirePermission, hasPermission, isSuperuser, } from "@/utils/access-control/hasPermission"; import { ensurePersonalAccount } from "@/lib/banking/index"; import { MUTEABLE_NOTIFICATION_TYPES } from "@/lib/notifications/notificationTypes"; export const Users: CollectionConfig = { hooks: { afterChange: [ async ({ req, doc, operation }) => { if (operation !== "create") return doc; const payload = req.payload; const userId = doc.id as number; try { await ensurePersonalAccount(payload, userId, req); } catch (e) { payload.logger.error( `[Users] Failed to create personal bank account for user ${userId}: ${e}`, ); } let recruit: { id: number } | undefined; try { const ranks = await payload.find({ collection: "ranks", where: { name: { like: "Recruit", }, }, limit: 1, depth: 0, overrideAccess: true, req, }); recruit = (ranks as { docs: Array<{ id: number }> }).docs[0]; if (!recruit) { const created = await payload.create({ collection: "ranks", data: { name: "Recruit", abbreviation: "Rct", description: "Entry-level rank for new members.", }, overrideAccess: true, req, }); recruit = { id: created.id }; payload.logger.info("[Users] Created default 'Recruit' rank on demand."); } } catch (e) { payload.logger.error(`[Users] Failed to resolve Recruit rank: ${e}`); } try { await payload.create({ collection: "profiles", draft: false, data: { user: userId, rank: recruit?.id ?? (null as unknown as number), dossier: { enlistmentDate: new Date().toISOString(), }, progression: { qualifications: [], experience: 0, awards: [], } as any, }, overrideAccess: true, req, }); } catch (e) { payload.logger.error(`[Users] Failed to create profile for user ${userId}: ${e}`); } return doc; }, ], }, slug: "users", access: { admin: requirePermission("system:admin-access"), unlock: requirePermission("users:unlock"), create: requirePermission("users:create"), update: async ({ req, id, data }) => { if (data?.roles?.includes("developer")) { return isSuperuser(req.payload, req.user); } const existing = id ? ((await req.payload.findByID({ collection: "users", id, depth: 2, overrideAccess: true, })) as { roleDocs?: Array<{ isSuperuser?: boolean }> | null }) : null; if (existing?.roleDocs?.some((r) => r.isSuperuser)) { return isSuperuser(req.payload, req.user); } return hasPermission(req.payload, req.user, "users:update"); }, delete: async ({ req, id, data }) => { if (data?.roles?.includes("developer")) { return isSuperuser(req.payload, req.user); } const existing = id ? ((await req.payload.findByID({ collection: "users", id, depth: 2, overrideAccess: true, })) as { roleDocs?: Array<{ isSuperuser?: boolean }> | null }) : null; if (existing?.roleDocs?.some((r) => r.isSuperuser)) { return isSuperuser(req.payload, req.user); } return hasPermission(req.payload, req.user, "users:delete"); }, }, admin: { useAsTitle: "payloadDisplayName", group: "Users", }, auth: { tokenExpiration: 43200, loginWithUsername: { requireUsername: true, allowEmailLogin: false, requireEmail: false, }, }, fields: [ // Email added by default by Payload, but essentially ignored in this application { name: "username", type: "text", required: true, unique: true, }, { name: "discordUsername", type: "text", required: true, unique: true, }, { name: "discordId", type: "text", label: "Discord User ID", unique: true, index: true, }, { name: "displayName", type: "text", label: "In-Game Name", required: true, }, { name: "payloadDisplayName", type: "text", label: "Display Name", admin: { hidden: true, readOnly: true, }, hooks: { beforeChange: [ ({ data }) => { // console.log(data); return `${data!.displayName ? data!.displayName + " " : ""}(${data!.username})`; }, ], }, }, { name: "steamId", type: "text", label: "Steam ID 64", required: true, }, { name: "roles", type: "select", access: { create: requirePermission("users:assign-roles"), update: requirePermission("users:assign-roles"), }, hasMany: true, options: [ { label: "Guest", value: "guest", }, { label: "User", value: "user", }, { label: "Trusted", value: "trusted", }, { label: "Admin", value: "admin", }, { label: "Developer", value: "developer", }, ], /*filterOptions: ({ options, data, req }) => { return !isDeveloper({ req }) ? options.filter( (option) => (typeof option === "string" ? options : !["developer", "admin"].includes(option.value)), ) : options; },*/ admin: { description: "Legacy role enum — being replaced by the dynamic RBAC system (see 'Role Assignments' below).", }, }, { name: "roleDocs", type: "relationship", relationTo: "roles", hasMany: true, access: { create: requirePermission("users:assign-roles"), update: requirePermission("users:assign-roles"), }, admin: { description: "Dynamic RBAC role assignments. These determine the user's permissions via the roles collection.", }, }, { name: "preferences", label: "Preferences", type: "group", fields: [ { name: "notifications", label: "Notifications", type: "group", fields: [ { name: "mutedAll", label: "Mute all in-app notifications", type: "checkbox", defaultValue: false, }, { name: "mutedTypes", label: "Muted notification types", type: "select", hasMany: true, options: MUTEABLE_NOTIFICATION_TYPES.map((t) => ({ label: t.label, value: t.value })), }, ], }, { name: "display", label: "Display", type: "group", fields: [ { name: "showCallsign", label: "Show callsign (username) instead of display name in the sidebar", type: "checkbox", defaultValue: false, }, { name: "leadOrActual", label: 'Use "Lead" or "Actual" when reviewing the unit roster', type: "select", options: [ { label: "Lead", value: "lead" }, { label: "Actual", value: "actual" }, ], defaultValue: "lead", }, { name: "showVersionOverlay", label: "Show version overlay at top of page", type: "checkbox", defaultValue: true, }, { name: "xpDisplayMini", label: "Use mini mode for XP display", type: "checkbox", defaultValue: false, }, { name: "showBackgroundTexture", label: "Show diagonal background texture", type: "checkbox", defaultValue: true, }, ], }, { name: "discord", label: "Discord", type: "group", fields: [ { name: "enabled", label: "Discord notifications", type: "checkbox", defaultValue: false, }, { name: "mutedTypes", label: "Muted notification types", type: "select", hasMany: true, options: MUTEABLE_NOTIFICATION_TYPES.map((t) => ({ label: t.label, value: t.value })), }, ], }, ], }, ], };