import type { Access, AccessArgs, AccessResult, Payload, PayloadRequest } from "payload"; import { loadUserPermissions } from "@/utils/access-control/loadUserPermissions"; import type { Permission } from "@/permissions"; /** * Minimal user shape for permission checks. * Accepts the full Payload User or a stripped-down { id } from server actions. */ interface UserLike { id: number | string; roleDocs?: unknown; } /** * Check whether a user has a specific permission. * * Resolution order: * 1. No user → false. * 2. User has a role with `isSuperuser: true` → true (bypasses all checks). * 3. User has a role whose `permissions` array includes the given permission → true. * 4. Otherwise → false. * * Results are cached per-user for 30s (see `loadUserPermissions`). * * @example * const canCreate = await hasPermission(payload, user, "users:create"); */ export async function hasPermission( payload: Payload, user: UserLike | null | undefined, permission: Permission, ): Promise { if (!user) return false; const { permissions, isSuperuser } = await loadUserPermissions(payload, user); if (isSuperuser) return true; return permissions.has(permission); } /** * Check whether a user has a superuser role (bypasses all permission checks). * * Use this for the legacy `isDeveloper` replacement where the check was * "can do anything" rather than a specific permission. */ export async function isSuperuser( payload: Payload, user: UserLike | null | undefined, ): Promise { if (!user) return false; const { isSuperuser: su } = await loadUserPermissions(payload, user); return su; } /** * Check whether a user has ANY of the given permissions. */ export async function hasAnyPermission( payload: Payload, user: UserLike | null | undefined, ...permissions: Permission[] ): Promise { if (!user) return false; const { permissions: userPerms, isSuperuser: su } = await loadUserPermissions(payload, user); if (su) return true; return permissions.some((p) => userPerms.has(p)); } /** * Check whether a user has ALL of the given permissions. */ export async function hasAllPermissions( payload: Payload, user: UserLike | null | undefined, ...permissions: Permission[] ): Promise { if (!user) return false; const { permissions: userPerms, isSuperuser: su } = await loadUserPermissions(payload, user); if (su) return true; return permissions.every((p) => userPerms.has(p)); } /** * Detect whether a request targets the Payload admin panel. * * The admin panel is served under `config.routes.admin` (default `/admin`). * `req.pathname` is the request URL pathname (set by Payload's request * creation), so this reliably distinguishes admin-panel requests from REST * API requests — unlike `x-invoke-path`, which is unreliable in layouts. * Local API calls (server actions, seeds, MCP, bots) get a non-admin * pathname and therefore keep the original access behavior. */ function isAdminPanelRequest(req: PayloadRequest): boolean { const adminRoute = req.payload.config.routes?.admin ?? "/admin"; const pathname = req.pathname; if (!pathname) return false; return pathname === adminRoute || pathname.startsWith(`${adminRoute}/`); } /** * Factory that wraps a collection's `read` access so that the Payload admin * panel only shows/interacts with the collection when the user holds BOTH * `:read` AND `admin::manage`. Non-admin (REST/API) reads keep * the original access behavior unchanged. * * Apply centrally to every collection in `payload.config.ts`: * * @example * collections: collections.map((collection) => ({ * ...collection, * access: { * ...collection.access, * read: requireAdminPageAccess(collection.slug, collection.access?.read), * }, * })) */ export function requireAdminPageAccess(collectionSlug: string, readAccess?: Access | boolean) { return async (args: AccessArgs): Promise => { const { req } = args; if (isAdminPanelRequest(req)) { // Admin panel: require both the collection read permission and the // admin page-manage permission. Must return a plain boolean — a `Where` // here would be treated as "no permission" by the admin UI. return hasAllPermissions( req.payload, req.user, `${collectionSlug}:read` as Permission, `admin:${collectionSlug}:manage` as Permission, ); } // Non-admin (REST/API): preserve the original read access behavior. if (typeof readAccess === "function") return readAccess(args); if (readAccess === true) return true; if (readAccess === false) return false; // Omitted read access → Payload default: any logged-in user can read. return Boolean(req.user); }; } /** * Factory that creates a Payload collection access function requiring a specific * permission. Drop-in replacement for `isDeveloper` / `isAdmin` in collection * `access` blocks. * * @example * access: { * create: requirePermission("users:create"), * update: requirePermission("users:update"), * } */ export function requirePermission(permission: Permission) { return async ({ req }: { req: PayloadRequest }): Promise => { return hasPermission(req.payload, req.user, permission); }; } /** * Factory that creates a Payload collection access function requiring ANY of * the given permissions. * * @example * access: { * update: requireAnyPermission("tickets:update", "tickets:staff"), * } */ export function requireAnyPermission(...permissions: Permission[]) { return async ({ req }: { req: PayloadRequest }): Promise => { return hasAnyPermission(req.payload, req.user, ...permissions); }; } /** * Factory that creates a Payload collection access function requiring campaign ownership. * * A user can access a campaign if: * 1. They own the campaign (owner field matches their user ID), OR * 2. They have the "campaigns:manage" permission * * @example * access: { * update: requireCampaignOwnership("campaigns:update"), * delete: requireCampaignOwnership("campaigns:delete"), * } */ export function requireCampaignOwnership(permission: Permission) { return async ({ req, id }: { req: PayloadRequest; id?: any }) => { if (!req.user) return false; // Check permission first (grants access to all campaigns for managers) const { permissions: userPerms, isSuperuser: su } = await loadUserPermissions(req.payload, req.user); if (su || userPerms.has(permission)) return true; // If no user permissions for manage, check ownership let campaign; try { campaign = await req.payload.findByID({ collection: "campaigns", id: id, depth: 1, overrideAccess: true, }); } catch (error) { // If campaign lookup fails (e.g. invalid ID), deny access return false; } if (!campaign) return false; // User owns the campaign if owner field matches their ID // campaign.owner can be number (ID) or User object const campaignOwnerId = campaign.owner ? (typeof campaign.owner === "object" ? campaign.owner.id : campaign.owner) : null; const userId = Number(req.user.id); if (campaignOwnerId && campaignOwnerId === userId) return true; // User does not own this campaign and lacks manage permission return false; }; }