1
0
Fork 0

Compare commits

..

6 commits

Author SHA1 Message Date
2a1c94072f feat(auth): protect role management page
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-01 21:39:34 -04:00
9841fb3628 chore(payload): regenerate permission schema types
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-01 21:39:24 -04:00
8cd5d22a47 chore(payload): add admin page permission migration
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-01 21:39:19 -04:00
37d82fd711 chore(seed): grant admin page permissions to built-in roles
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-01 21:39:14 -04:00
822ac47c23 feat(auth): scope Payload admin pages by permissions
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@siisyphuslabs.ai>
2026-09-01 21:39:09 -04:00
e29b403d06 feat(permissions): add admin page permission definitions
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-01 21:39:06 -04:00
11 changed files with 21379 additions and 111 deletions

View file

@ -1,5 +1,6 @@
import type { CollectionConfig } from "payload"; import type { CollectionConfig } from "payload";
import { isAdmin, isDeveloper } from "@/utils/access-control/isRole"; import { isAdmin, isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
import { permissionSelectOptions } from "@/permissions"; import { permissionSelectOptions } from "@/permissions";
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions"; import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
@ -12,7 +13,7 @@ export const Roles: CollectionConfig = {
"Dynamic roles for the RBAC permission system. Assign permissions to roles, then assign roles to users.", "Dynamic roles for the RBAC permission system. Assign permissions to roles, then assign roles to users.",
}, },
access: { access: {
admin: isAdmin, admin: requirePermission("admin:roles:manage"),
create: isAdmin, create: isAdmin,
update: isAdmin, update: isAdmin,
delete: isDeveloper, delete: isDeveloper,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,50 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
DO $$
DECLARE permission text;
BEGIN
FOREACH permission IN ARRAY ARRAY[
'media:create', 'media:read', 'media:update', 'media:delete', 'admin:media:manage',
'admin:users:manage', 'roles:create', 'roles:read', 'roles:update', 'roles:delete',
'admin:roles:manage', 'admin:ranks:manage', 'admin:profiles:manage', 'admin:awards:manage',
'admin:qualifications:manage', 'admin:assignments:manage', 'assignment-transfers:create',
'assignment-transfers:read', 'assignment-transfers:update', 'assignment-transfers:delete',
'admin:assignment-transfers:manage', 'admin:experience:manage',
'admin:user-notifications:manage', 'admin:evaluations:manage', 'admin:missions:manage',
'admin:mission-attendances:manage', 'admin:campaigns:manage', 'admin:factions:manage',
'admin:technologies:manage', 'admin:assets:manage', 'admin:resources:manage',
'admin:vehicles:manage', 'admin:structures:manage', 'admin:shipments:manage',
'admin:bank-accounts:manage', 'admin:bank-transactions:manage', 'admin:ledger-entries:manage',
'admin:locker-storages:manage', 'admin:loadouts:manage', 'admin:market-listings:manage',
'admin:market-negotiations:manage', 'admin:projects:manage', 'admin:labels:manage',
'admin:sprints:manage', 'admin:releases:manage', 'admin:tickets:manage',
'ticket-votes:create', 'ticket-votes:read', 'ticket-votes:update', 'ticket-votes:delete',
'admin:ticket-votes:manage', 'admin:game-structures:manage', 'admin:game-vehicles:manage',
'admin:game-npcs:manage', 'admin:game-hard-resources:manage', 'admin:game-event-logs:manage',
'admin:maps:manage', 'admin:narrative-events:manage', 'admin:mission-files:manage',
'admin:mod-lists:manage', 'game-servers:create', 'game-servers:read', 'game-servers:update',
'game-servers:delete', 'admin:game-servers:manage', 'arma-sync-events:create',
'arma-sync-events:read', 'arma-sync-events:update', 'arma-sync-events:delete',
'admin:arma-sync-events:manage', 'arma-commands:create', 'arma-commands:read',
'arma-commands:update', 'arma-commands:delete', 'admin:arma-commands:manage'
] LOOP
IF NOT EXISTS (
SELECT 1 FROM pg_enum e
JOIN pg_type t ON t.oid = e.enumtypid
WHERE t.typname = 'enum_roles_permissions' AND e.enumlabel = permission
) THEN
EXECUTE format('ALTER TYPE public.enum_roles_permissions ADD VALUE %L', permission);
END IF;
END LOOP;
END $$;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "roles_permissions" ALTER COLUMN "value" SET DATA TYPE text;
DROP TYPE "public"."enum_roles_permissions";
CREATE TYPE "public"."enum_roles_permissions" AS ENUM('system:admin-access', 'users:create', 'users:read', 'users:update', 'users:delete', 'users:unlock', 'users:assign-roles', 'ranks:create', 'ranks:read', 'ranks:update', 'ranks:delete', 'profiles:create', 'profiles:read', 'profiles:update', 'profiles:delete', 'awards:create', 'awards:read', 'awards:update', 'awards:delete', 'qualifications:create', 'qualifications:read', 'qualifications:update', 'qualifications:delete', 'assignments:create', 'assignments:read', 'assignments:update', 'assignments:delete', 'experience:create', 'experience:read', 'experience:update', 'experience:delete', 'user-notifications:create', 'user-notifications:read', 'user-notifications:update', 'user-notifications:delete', 'evaluations:create', 'evaluations:read', 'evaluations:update', 'evaluations:delete', 'missions:create', 'missions:read', 'missions:update', 'missions:delete', 'missions:read-sensitive', 'mission-attendances:create', 'mission-attendances:read', 'mission-attendances:update', 'mission-attendances:delete', 'campaigns:create', 'campaigns:read', 'campaigns:update', 'campaigns:delete', 'campaigns:manage', 'factions:create', 'factions:read', 'factions:update', 'factions:delete', 'technologies:create', 'technologies:read', 'technologies:update', 'technologies:delete', 'assets:create', 'assets:read', 'assets:update', 'assets:delete', 'resources:create', 'resources:read', 'resources:update', 'resources:delete', 'vehicles:create', 'vehicles:read', 'vehicles:update', 'vehicles:delete', 'structures:create', 'structures:read', 'structures:update', 'structures:delete', 'shipments:create', 'shipments:read', 'shipments:update', 'shipments:delete', 'bank-accounts:create', 'bank-accounts:read', 'bank-accounts:update', 'bank-accounts:delete', 'bank-transactions:create', 'bank-transactions:read', 'bank-transactions:update', 'bank-transactions:delete', 'ledger-entries:create', 'ledger-entries:read', 'ledger-entries:update', 'ledger-entries:delete', 'locker-storages:create', 'locker-storages:read', 'locker-storages:update', 'locker-storages:delete', 'loadouts:create', 'loadouts:read', 'loadouts:update', 'loadouts:delete', 'market-listings:create', 'market-listings:read', 'market-listings:update', 'market-listings:delete', 'market-negotiations:create', 'market-negotiations:read', 'market-negotiations:update', 'market-negotiations:delete', 'projects:create', 'projects:read', 'projects:update', 'projects:delete', 'labels:create', 'labels:read', 'labels:update', 'labels:delete', 'sprints:create', 'sprints:read', 'sprints:update', 'sprints:delete', 'releases:create', 'releases:read', 'releases:update', 'releases:delete', 'tickets:create', 'tickets:read', 'tickets:update', 'tickets:delete', 'game-structures:create', 'game-structures:read', 'game-structures:update', 'game-structures:delete', 'game-vehicles:create', 'game-vehicles:read', 'game-vehicles:update', 'game-vehicles:delete', 'game-npcs:create', 'game-npcs:read', 'game-npcs:update', 'game-npcs:delete', 'game-hard-resources:create', 'game-hard-resources:read', 'game-hard-resources:update', 'game-hard-resources:delete', 'game-event-logs:create', 'game-event-logs:read', 'game-event-logs:update', 'game-event-logs:delete', 'maps:create', 'maps:read', 'maps:update', 'maps:delete', 'narrative-events:create', 'narrative-events:read', 'narrative-events:update', 'narrative-events:delete', 'mission-files:create', 'mission-files:read', 'mission-files:update', 'mission-files:delete', 'mod-lists:create', 'mod-lists:read', 'mod-lists:update', 'mod-lists:delete', 'game-rules:read', 'game-rules:update', 'shims:read', 'shims:update', 'logistics:manage', 'intelligence:manage', 'profiles:intel_excerpt:update', 'banking:manage', 'tickets:staff', 'discord:staff', 'discord:announce');
ALTER TABLE "roles_permissions" ALTER COLUMN "value" SET DATA TYPE "public"."enum_roles_permissions" USING "value"::"public"."enum_roles_permissions";`)
}

View file

@ -13,6 +13,7 @@ import * as migration_20260831_142503 from './20260831_142503';
import * as migration_20260831_180233_assignment_transfers from './20260831_180233_assignment_transfers'; import * as migration_20260831_180233_assignment_transfers from './20260831_180233_assignment_transfers';
import * as migration_20260901_014006_add_assignment_transfer_event_target from './20260901_014006_add_assignment_transfer_event_target'; import * as migration_20260901_014006_add_assignment_transfer_event_target from './20260901_014006_add_assignment_transfer_event_target';
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from './20260901_213108_add_ticket_votes_and_edit_history'; import * as migration_20260901_213108_add_ticket_votes_and_edit_history from './20260901_213108_add_ticket_votes_and_edit_history';
import * as migration_20260901_222200_add_admin_page_permissions from './20260901_222200_add_admin_page_permissions';
export const migrations = [ export const migrations = [
{ {
@ -88,6 +89,11 @@ export const migrations = [
{ {
up: migration_20260901_213108_add_ticket_votes_and_edit_history.up, up: migration_20260901_213108_add_ticket_votes_and_edit_history.up,
down: migration_20260901_213108_add_ticket_votes_and_edit_history.down, down: migration_20260901_213108_add_ticket_votes_and_edit_history.down,
name: '20260901_213108_add_ticket_votes_and_edit_history' name: '20260901_213108_add_ticket_votes_and_edit_history',
},
{
up: migration_20260901_222200_add_admin_page_permissions.up,
down: migration_20260901_222200_add_admin_page_permissions.down,
name: '20260901_222200_add_admin_page_permissions'
}, },
]; ];

View file

@ -120,170 +120,245 @@ export const enum_users_preferences_display_lead_or_actual = pgEnum(
); );
export const enum_roles_permissions = pgEnum("enum_roles_permissions", [ export const enum_roles_permissions = pgEnum("enum_roles_permissions", [
"system:admin-access", "system:admin-access",
"media:create",
"media:read",
"media:update",
"media:delete",
"admin:media:manage",
"users:create", "users:create",
"users:read", "users:read",
"users:update", "users:update",
"users:delete", "users:delete",
"users:unlock", "users:unlock",
"users:assign-roles", "users:assign-roles",
"admin:users:manage",
"roles:create",
"roles:read",
"roles:update",
"roles:delete",
"admin:roles:manage",
"ranks:create", "ranks:create",
"ranks:read", "ranks:read",
"ranks:update", "ranks:update",
"ranks:delete", "ranks:delete",
"admin:ranks:manage",
"profiles:create", "profiles:create",
"profiles:read", "profiles:read",
"profiles:update", "profiles:update",
"profiles:delete", "profiles:delete",
"admin:profiles:manage",
"awards:create", "awards:create",
"awards:read", "awards:read",
"awards:update", "awards:update",
"awards:delete", "awards:delete",
"admin:awards:manage",
"qualifications:create", "qualifications:create",
"qualifications:read", "qualifications:read",
"qualifications:update", "qualifications:update",
"qualifications:delete", "qualifications:delete",
"admin:qualifications:manage",
"assignments:create", "assignments:create",
"assignments:read", "assignments:read",
"assignments:update", "assignments:update",
"assignments:delete", "assignments:delete",
"admin:assignments:manage",
"assignment-transfers:create",
"assignment-transfers:read",
"assignment-transfers:update",
"assignment-transfers:delete",
"admin:assignment-transfers:manage",
"experience:create", "experience:create",
"experience:read", "experience:read",
"experience:update", "experience:update",
"experience:delete", "experience:delete",
"admin:experience:manage",
"user-notifications:create", "user-notifications:create",
"user-notifications:read", "user-notifications:read",
"user-notifications:update", "user-notifications:update",
"user-notifications:delete", "user-notifications:delete",
"admin:user-notifications:manage",
"evaluations:create", "evaluations:create",
"evaluations:read", "evaluations:read",
"evaluations:update", "evaluations:update",
"evaluations:delete", "evaluations:delete",
"admin:evaluations:manage",
"missions:create", "missions:create",
"missions:read", "missions:read",
"missions:update", "missions:update",
"missions:delete", "missions:delete",
"missions:read-sensitive", "missions:read-sensitive",
"admin:missions:manage",
"mission-attendances:create", "mission-attendances:create",
"mission-attendances:read", "mission-attendances:read",
"mission-attendances:update", "mission-attendances:update",
"mission-attendances:delete", "mission-attendances:delete",
"admin:mission-attendances:manage",
"campaigns:create", "campaigns:create",
"campaigns:read", "campaigns:read",
"campaigns:update", "campaigns:update",
"campaigns:delete", "campaigns:delete",
"admin:campaigns:manage",
"campaigns:manage", "campaigns:manage",
"factions:create", "factions:create",
"factions:read", "factions:read",
"factions:update", "factions:update",
"factions:delete", "factions:delete",
"admin:factions:manage",
"technologies:create", "technologies:create",
"technologies:read", "technologies:read",
"technologies:update", "technologies:update",
"technologies:delete", "technologies:delete",
"admin:technologies:manage",
"assets:create", "assets:create",
"assets:read", "assets:read",
"assets:update", "assets:update",
"assets:delete", "assets:delete",
"admin:assets:manage",
"resources:create", "resources:create",
"resources:read", "resources:read",
"resources:update", "resources:update",
"resources:delete", "resources:delete",
"admin:resources:manage",
"vehicles:create", "vehicles:create",
"vehicles:read", "vehicles:read",
"vehicles:update", "vehicles:update",
"vehicles:delete", "vehicles:delete",
"admin:vehicles:manage",
"structures:create", "structures:create",
"structures:read", "structures:read",
"structures:update", "structures:update",
"structures:delete", "structures:delete",
"admin:structures:manage",
"shipments:create", "shipments:create",
"shipments:read", "shipments:read",
"shipments:update", "shipments:update",
"shipments:delete", "shipments:delete",
"admin:shipments:manage",
"bank-accounts:create", "bank-accounts:create",
"bank-accounts:read", "bank-accounts:read",
"bank-accounts:update", "bank-accounts:update",
"bank-accounts:delete", "bank-accounts:delete",
"admin:bank-accounts:manage",
"bank-transactions:create", "bank-transactions:create",
"bank-transactions:read", "bank-transactions:read",
"bank-transactions:update", "bank-transactions:update",
"bank-transactions:delete", "bank-transactions:delete",
"admin:bank-transactions:manage",
"ledger-entries:create", "ledger-entries:create",
"ledger-entries:read", "ledger-entries:read",
"ledger-entries:update", "ledger-entries:update",
"ledger-entries:delete", "ledger-entries:delete",
"admin:ledger-entries:manage",
"locker-storages:create", "locker-storages:create",
"locker-storages:read", "locker-storages:read",
"locker-storages:update", "locker-storages:update",
"locker-storages:delete", "locker-storages:delete",
"admin:locker-storages:manage",
"loadouts:create", "loadouts:create",
"loadouts:read", "loadouts:read",
"loadouts:update", "loadouts:update",
"loadouts:delete", "loadouts:delete",
"admin:loadouts:manage",
"market-listings:create", "market-listings:create",
"market-listings:read", "market-listings:read",
"market-listings:update", "market-listings:update",
"market-listings:delete", "market-listings:delete",
"admin:market-listings:manage",
"market-negotiations:create", "market-negotiations:create",
"market-negotiations:read", "market-negotiations:read",
"market-negotiations:update", "market-negotiations:update",
"market-negotiations:delete", "market-negotiations:delete",
"admin:market-negotiations:manage",
"projects:create", "projects:create",
"projects:read", "projects:read",
"projects:update", "projects:update",
"projects:delete", "projects:delete",
"admin:projects:manage",
"labels:create", "labels:create",
"labels:read", "labels:read",
"labels:update", "labels:update",
"labels:delete", "labels:delete",
"admin:labels:manage",
"sprints:create", "sprints:create",
"sprints:read", "sprints:read",
"sprints:update", "sprints:update",
"sprints:delete", "sprints:delete",
"admin:sprints:manage",
"releases:create", "releases:create",
"releases:read", "releases:read",
"releases:update", "releases:update",
"releases:delete", "releases:delete",
"admin:releases:manage",
"tickets:create", "tickets:create",
"tickets:read", "tickets:read",
"tickets:update", "tickets:update",
"tickets:delete", "tickets:delete",
"admin:tickets:manage",
"ticket-votes:create",
"ticket-votes:read",
"ticket-votes:update",
"ticket-votes:delete",
"admin:ticket-votes:manage",
"game-structures:create", "game-structures:create",
"game-structures:read", "game-structures:read",
"game-structures:update", "game-structures:update",
"game-structures:delete", "game-structures:delete",
"admin:game-structures:manage",
"game-vehicles:create", "game-vehicles:create",
"game-vehicles:read", "game-vehicles:read",
"game-vehicles:update", "game-vehicles:update",
"game-vehicles:delete", "game-vehicles:delete",
"admin:game-vehicles:manage",
"game-npcs:create", "game-npcs:create",
"game-npcs:read", "game-npcs:read",
"game-npcs:update", "game-npcs:update",
"game-npcs:delete", "game-npcs:delete",
"admin:game-npcs:manage",
"game-hard-resources:create", "game-hard-resources:create",
"game-hard-resources:read", "game-hard-resources:read",
"game-hard-resources:update", "game-hard-resources:update",
"game-hard-resources:delete", "game-hard-resources:delete",
"admin:game-hard-resources:manage",
"game-event-logs:create", "game-event-logs:create",
"game-event-logs:read", "game-event-logs:read",
"game-event-logs:update", "game-event-logs:update",
"game-event-logs:delete", "game-event-logs:delete",
"admin:game-event-logs:manage",
"maps:create", "maps:create",
"maps:read", "maps:read",
"maps:update", "maps:update",
"maps:delete", "maps:delete",
"admin:maps:manage",
"narrative-events:create", "narrative-events:create",
"narrative-events:read", "narrative-events:read",
"narrative-events:update", "narrative-events:update",
"narrative-events:delete", "narrative-events:delete",
"admin:narrative-events:manage",
"mission-files:create", "mission-files:create",
"mission-files:read", "mission-files:read",
"mission-files:update", "mission-files:update",
"mission-files:delete", "mission-files:delete",
"admin:mission-files:manage",
"mod-lists:create", "mod-lists:create",
"mod-lists:read", "mod-lists:read",
"mod-lists:update", "mod-lists:update",
"mod-lists:delete", "mod-lists:delete",
"admin:mod-lists:manage",
"game-servers:create",
"game-servers:read",
"game-servers:update",
"game-servers:delete",
"admin:game-servers:manage",
"arma-sync-events:create",
"arma-sync-events:read",
"arma-sync-events:update",
"arma-sync-events:delete",
"admin:arma-sync-events:manage",
"arma-commands:create",
"arma-commands:read",
"arma-commands:update",
"arma-commands:delete",
"admin:arma-commands:manage",
"game-rules:read", "game-rules:read",
"game-rules:update", "game-rules:update",
"shims:read", "shims:read",

View file

@ -1259,170 +1259,245 @@ export interface Role {
permissions?: permissions?:
| ( | (
| 'system:admin-access' | 'system:admin-access'
| 'media:create'
| 'media:read'
| 'media:update'
| 'media:delete'
| 'admin:media:manage'
| 'users:create' | 'users:create'
| 'users:read' | 'users:read'
| 'users:update' | 'users:update'
| 'users:delete' | 'users:delete'
| 'users:unlock' | 'users:unlock'
| 'users:assign-roles' | 'users:assign-roles'
| 'admin:users:manage'
| 'roles:create'
| 'roles:read'
| 'roles:update'
| 'roles:delete'
| 'admin:roles:manage'
| 'ranks:create' | 'ranks:create'
| 'ranks:read' | 'ranks:read'
| 'ranks:update' | 'ranks:update'
| 'ranks:delete' | 'ranks:delete'
| 'admin:ranks:manage'
| 'profiles:create' | 'profiles:create'
| 'profiles:read' | 'profiles:read'
| 'profiles:update' | 'profiles:update'
| 'profiles:delete' | 'profiles:delete'
| 'admin:profiles:manage'
| 'awards:create' | 'awards:create'
| 'awards:read' | 'awards:read'
| 'awards:update' | 'awards:update'
| 'awards:delete' | 'awards:delete'
| 'admin:awards:manage'
| 'qualifications:create' | 'qualifications:create'
| 'qualifications:read' | 'qualifications:read'
| 'qualifications:update' | 'qualifications:update'
| 'qualifications:delete' | 'qualifications:delete'
| 'admin:qualifications:manage'
| 'assignments:create' | 'assignments:create'
| 'assignments:read' | 'assignments:read'
| 'assignments:update' | 'assignments:update'
| 'assignments:delete' | 'assignments:delete'
| 'admin:assignments:manage'
| 'assignment-transfers:create'
| 'assignment-transfers:read'
| 'assignment-transfers:update'
| 'assignment-transfers:delete'
| 'admin:assignment-transfers:manage'
| 'experience:create' | 'experience:create'
| 'experience:read' | 'experience:read'
| 'experience:update' | 'experience:update'
| 'experience:delete' | 'experience:delete'
| 'admin:experience:manage'
| 'user-notifications:create' | 'user-notifications:create'
| 'user-notifications:read' | 'user-notifications:read'
| 'user-notifications:update' | 'user-notifications:update'
| 'user-notifications:delete' | 'user-notifications:delete'
| 'admin:user-notifications:manage'
| 'evaluations:create' | 'evaluations:create'
| 'evaluations:read' | 'evaluations:read'
| 'evaluations:update' | 'evaluations:update'
| 'evaluations:delete' | 'evaluations:delete'
| 'admin:evaluations:manage'
| 'missions:create' | 'missions:create'
| 'missions:read' | 'missions:read'
| 'missions:update' | 'missions:update'
| 'missions:delete' | 'missions:delete'
| 'missions:read-sensitive' | 'missions:read-sensitive'
| 'admin:missions:manage'
| 'mission-attendances:create' | 'mission-attendances:create'
| 'mission-attendances:read' | 'mission-attendances:read'
| 'mission-attendances:update' | 'mission-attendances:update'
| 'mission-attendances:delete' | 'mission-attendances:delete'
| 'admin:mission-attendances:manage'
| 'campaigns:create' | 'campaigns:create'
| 'campaigns:read' | 'campaigns:read'
| 'campaigns:update' | 'campaigns:update'
| 'campaigns:delete' | 'campaigns:delete'
| 'admin:campaigns:manage'
| 'campaigns:manage' | 'campaigns:manage'
| 'factions:create' | 'factions:create'
| 'factions:read' | 'factions:read'
| 'factions:update' | 'factions:update'
| 'factions:delete' | 'factions:delete'
| 'admin:factions:manage'
| 'technologies:create' | 'technologies:create'
| 'technologies:read' | 'technologies:read'
| 'technologies:update' | 'technologies:update'
| 'technologies:delete' | 'technologies:delete'
| 'admin:technologies:manage'
| 'assets:create' | 'assets:create'
| 'assets:read' | 'assets:read'
| 'assets:update' | 'assets:update'
| 'assets:delete' | 'assets:delete'
| 'admin:assets:manage'
| 'resources:create' | 'resources:create'
| 'resources:read' | 'resources:read'
| 'resources:update' | 'resources:update'
| 'resources:delete' | 'resources:delete'
| 'admin:resources:manage'
| 'vehicles:create' | 'vehicles:create'
| 'vehicles:read' | 'vehicles:read'
| 'vehicles:update' | 'vehicles:update'
| 'vehicles:delete' | 'vehicles:delete'
| 'admin:vehicles:manage'
| 'structures:create' | 'structures:create'
| 'structures:read' | 'structures:read'
| 'structures:update' | 'structures:update'
| 'structures:delete' | 'structures:delete'
| 'admin:structures:manage'
| 'shipments:create' | 'shipments:create'
| 'shipments:read' | 'shipments:read'
| 'shipments:update' | 'shipments:update'
| 'shipments:delete' | 'shipments:delete'
| 'admin:shipments:manage'
| 'bank-accounts:create' | 'bank-accounts:create'
| 'bank-accounts:read' | 'bank-accounts:read'
| 'bank-accounts:update' | 'bank-accounts:update'
| 'bank-accounts:delete' | 'bank-accounts:delete'
| 'admin:bank-accounts:manage'
| 'bank-transactions:create' | 'bank-transactions:create'
| 'bank-transactions:read' | 'bank-transactions:read'
| 'bank-transactions:update' | 'bank-transactions:update'
| 'bank-transactions:delete' | 'bank-transactions:delete'
| 'admin:bank-transactions:manage'
| 'ledger-entries:create' | 'ledger-entries:create'
| 'ledger-entries:read' | 'ledger-entries:read'
| 'ledger-entries:update' | 'ledger-entries:update'
| 'ledger-entries:delete' | 'ledger-entries:delete'
| 'admin:ledger-entries:manage'
| 'locker-storages:create' | 'locker-storages:create'
| 'locker-storages:read' | 'locker-storages:read'
| 'locker-storages:update' | 'locker-storages:update'
| 'locker-storages:delete' | 'locker-storages:delete'
| 'admin:locker-storages:manage'
| 'loadouts:create' | 'loadouts:create'
| 'loadouts:read' | 'loadouts:read'
| 'loadouts:update' | 'loadouts:update'
| 'loadouts:delete' | 'loadouts:delete'
| 'admin:loadouts:manage'
| 'market-listings:create' | 'market-listings:create'
| 'market-listings:read' | 'market-listings:read'
| 'market-listings:update' | 'market-listings:update'
| 'market-listings:delete' | 'market-listings:delete'
| 'admin:market-listings:manage'
| 'market-negotiations:create' | 'market-negotiations:create'
| 'market-negotiations:read' | 'market-negotiations:read'
| 'market-negotiations:update' | 'market-negotiations:update'
| 'market-negotiations:delete' | 'market-negotiations:delete'
| 'admin:market-negotiations:manage'
| 'projects:create' | 'projects:create'
| 'projects:read' | 'projects:read'
| 'projects:update' | 'projects:update'
| 'projects:delete' | 'projects:delete'
| 'admin:projects:manage'
| 'labels:create' | 'labels:create'
| 'labels:read' | 'labels:read'
| 'labels:update' | 'labels:update'
| 'labels:delete' | 'labels:delete'
| 'admin:labels:manage'
| 'sprints:create' | 'sprints:create'
| 'sprints:read' | 'sprints:read'
| 'sprints:update' | 'sprints:update'
| 'sprints:delete' | 'sprints:delete'
| 'admin:sprints:manage'
| 'releases:create' | 'releases:create'
| 'releases:read' | 'releases:read'
| 'releases:update' | 'releases:update'
| 'releases:delete' | 'releases:delete'
| 'admin:releases:manage'
| 'tickets:create' | 'tickets:create'
| 'tickets:read' | 'tickets:read'
| 'tickets:update' | 'tickets:update'
| 'tickets:delete' | 'tickets:delete'
| 'admin:tickets:manage'
| 'ticket-votes:create'
| 'ticket-votes:read'
| 'ticket-votes:update'
| 'ticket-votes:delete'
| 'admin:ticket-votes:manage'
| 'game-structures:create' | 'game-structures:create'
| 'game-structures:read' | 'game-structures:read'
| 'game-structures:update' | 'game-structures:update'
| 'game-structures:delete' | 'game-structures:delete'
| 'admin:game-structures:manage'
| 'game-vehicles:create' | 'game-vehicles:create'
| 'game-vehicles:read' | 'game-vehicles:read'
| 'game-vehicles:update' | 'game-vehicles:update'
| 'game-vehicles:delete' | 'game-vehicles:delete'
| 'admin:game-vehicles:manage'
| 'game-npcs:create' | 'game-npcs:create'
| 'game-npcs:read' | 'game-npcs:read'
| 'game-npcs:update' | 'game-npcs:update'
| 'game-npcs:delete' | 'game-npcs:delete'
| 'admin:game-npcs:manage'
| 'game-hard-resources:create' | 'game-hard-resources:create'
| 'game-hard-resources:read' | 'game-hard-resources:read'
| 'game-hard-resources:update' | 'game-hard-resources:update'
| 'game-hard-resources:delete' | 'game-hard-resources:delete'
| 'admin:game-hard-resources:manage'
| 'game-event-logs:create' | 'game-event-logs:create'
| 'game-event-logs:read' | 'game-event-logs:read'
| 'game-event-logs:update' | 'game-event-logs:update'
| 'game-event-logs:delete' | 'game-event-logs:delete'
| 'admin:game-event-logs:manage'
| 'maps:create' | 'maps:create'
| 'maps:read' | 'maps:read'
| 'maps:update' | 'maps:update'
| 'maps:delete' | 'maps:delete'
| 'admin:maps:manage'
| 'narrative-events:create' | 'narrative-events:create'
| 'narrative-events:read' | 'narrative-events:read'
| 'narrative-events:update' | 'narrative-events:update'
| 'narrative-events:delete' | 'narrative-events:delete'
| 'admin:narrative-events:manage'
| 'mission-files:create' | 'mission-files:create'
| 'mission-files:read' | 'mission-files:read'
| 'mission-files:update' | 'mission-files:update'
| 'mission-files:delete' | 'mission-files:delete'
| 'admin:mission-files:manage'
| 'mod-lists:create' | 'mod-lists:create'
| 'mod-lists:read' | 'mod-lists:read'
| 'mod-lists:update' | 'mod-lists:update'
| 'mod-lists:delete' | 'mod-lists:delete'
| 'admin:mod-lists:manage'
| 'game-servers:create'
| 'game-servers:read'
| 'game-servers:update'
| 'game-servers:delete'
| 'admin:game-servers:manage'
| 'arma-sync-events:create'
| 'arma-sync-events:read'
| 'arma-sync-events:update'
| 'arma-sync-events:delete'
| 'admin:arma-sync-events:manage'
| 'arma-commands:create'
| 'arma-commands:read'
| 'arma-commands:update'
| 'arma-commands:delete'
| 'admin:arma-commands:manage'
| 'game-rules:read' | 'game-rules:read'
| 'game-rules:update' | 'game-rules:update'
| 'shims:read' | 'shims:read'

View file

@ -58,6 +58,7 @@ import { migrations } from "./migrations";
import { nodemailerAdapter } from "@payloadcms/email-nodemailer"; import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { Shims } from "@/collections/Shims"; import { Shims } from "@/collections/Shims";
import { requireAdminPageAccess } from "@/utils/access-control/hasPermission";
import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms"; import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms";
import { mcpPlugin } from "@payloadcms/plugin-mcp"; import { mcpPlugin } from "@payloadcms/plugin-mcp";
import { createOpenAI } from "@ai-sdk/openai"; import { createOpenAI } from "@ai-sdk/openai";
@ -139,6 +140,89 @@ const dirname = path.dirname(filename);
const META_TITLE = "Polaris Task Force"; const META_TITLE = "Polaris Task Force";
const META_DESCRIPTION = "A gamified platform for Polaris Task Force, an Arma 3 unit."; const META_DESCRIPTION = "A gamified platform for Polaris Task Force, an Arma 3 unit.";
const collections = [
Media,
// Game
GameHardResources,
GameStructures,
GameVehicles,
GameNpcs,
GameEventLogs,
// Users
Users,
Roles,
Ranks,
Profiles,
Awards,
Qualifications,
Assignments,
AssignmentTransfers,
Experience,
UserNotifications,
Evaluations,
// Intel
Missions,
MissionAttendances,
Campaigns,
Factions,
Technologies,
// Logistics
Assets,
Resources,
Vehicles,
Structures,
Shipments,
// Banking
BankAccounts,
BankTransactions,
LedgerEntries,
// Locker
LockerStorages,
Loadouts,
// Market
MarketListings,
MarketNegotiations,
// Helpdesk
Tickets,
TicketVotes,
// Projects
Projects,
Sprints,
Releases,
Labels,
// World
Maps,
NarrativeEvents,
// Server
MissionFiles,
ModLists,
GameServers,
ArmaSyncEvents,
ArmaCommands,
];
// Scoped admin pages: a user needs BOTH `<slug>:read` AND `admin:<slug>:manage`
// to see/interact with a collection in the Payload admin panel. Non-admin
// (REST/API) reads keep the collection's original access behavior.
const scopedCollections = collections.map((collection) => ({
...collection,
access: {
...collection.access,
read: requireAdminPageAccess(collection.slug, collection.access?.read),
},
}));
export default buildConfig({ export default buildConfig({
// serverURL: "http://localhost:3000/", // serverURL: "http://localhost:3000/",
admin: { admin: {
@ -186,77 +270,7 @@ export default buildConfig({
}, },
}, },
globals: [GameRules, Shims], globals: [GameRules, Shims],
collections: [ collections: scopedCollections,
Media,
// Game
GameHardResources,
GameStructures,
GameVehicles,
GameNpcs,
GameEventLogs,
// Users
Users,
Roles,
Ranks,
Profiles,
Awards,
Qualifications,
Assignments,
AssignmentTransfers,
Experience,
UserNotifications,
Evaluations,
// Intel
Missions,
MissionAttendances,
Campaigns,
Factions,
Technologies,
// Logistics
Assets,
Resources,
Vehicles,
Structures,
Shipments,
// Banking
BankAccounts,
BankTransactions,
LedgerEntries,
// Locker
LockerStorages,
Loadouts,
// Market
MarketListings,
MarketNegotiations,
// Helpdesk
Tickets,
TicketVotes,
// Projects
Projects,
Sprints,
Releases,
Labels,
// World
Maps,
NarrativeEvents,
// Server
MissionFiles,
ModLists,
GameServers,
ArmaSyncEvents,
ArmaCommands,
],
editor: lexicalEditor({ editor: lexicalEditor({
features: ({ rootFeatures }) => { features: ({ rootFeatures }) => {
return [HeadingFeature(), PayloadAiPluginLexicalEditorFeature()]; return [HeadingFeature(), PayloadAiPluginLexicalEditorFeature()];

View file

@ -37,6 +37,18 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
permissions: [{ value: "system:admin-access", label: "Access Admin Panel" }], permissions: [{ value: "system:admin-access", label: "Access Admin Panel" }],
}, },
// ---- Media ---------------------------------------------------------------
{
group: "Media",
permissions: [
{ value: "media:create", label: "Create" },
{ value: "media:read", label: "Read" },
{ value: "media:update", label: "Update" },
{ value: "media:delete", label: "Delete" },
{ value: "admin:media:manage", label: "Manage Admin Page" },
],
},
// ---- Users --------------------------------------------------------------- // ---- Users ---------------------------------------------------------------
{ {
group: "Users", group: "Users",
@ -47,6 +59,17 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "users:delete", label: "Delete Users" }, { value: "users:delete", label: "Delete Users" },
{ value: "users:unlock", label: "Unlock User Accounts" }, { value: "users:unlock", label: "Unlock User Accounts" },
{ value: "users:assign-roles", label: "Assign Roles to Users" }, { value: "users:assign-roles", label: "Assign Roles to Users" },
{ value: "admin:users:manage", label: "Manage Admin Page" },
],
},
{
group: "Roles",
permissions: [
{ value: "roles:create", label: "Create" },
{ value: "roles:read", label: "Read" },
{ value: "roles:update", label: "Update" },
{ value: "roles:delete", label: "Delete" },
{ value: "admin:roles:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -56,6 +79,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "ranks:read", label: "Read" }, { value: "ranks:read", label: "Read" },
{ value: "ranks:update", label: "Update" }, { value: "ranks:update", label: "Update" },
{ value: "ranks:delete", label: "Delete" }, { value: "ranks:delete", label: "Delete" },
{ value: "admin:ranks:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -65,6 +89,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "profiles:read", label: "Read" }, { value: "profiles:read", label: "Read" },
{ value: "profiles:update", label: "Update" }, { value: "profiles:update", label: "Update" },
{ value: "profiles:delete", label: "Delete" }, { value: "profiles:delete", label: "Delete" },
{ value: "admin:profiles:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -74,6 +99,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "awards:read", label: "Read" }, { value: "awards:read", label: "Read" },
{ value: "awards:update", label: "Update" }, { value: "awards:update", label: "Update" },
{ value: "awards:delete", label: "Delete" }, { value: "awards:delete", label: "Delete" },
{ value: "admin:awards:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -83,6 +109,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "qualifications:read", label: "Read" }, { value: "qualifications:read", label: "Read" },
{ value: "qualifications:update", label: "Update" }, { value: "qualifications:update", label: "Update" },
{ value: "qualifications:delete", label: "Delete" }, { value: "qualifications:delete", label: "Delete" },
{ value: "admin:qualifications:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -92,6 +119,17 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "assignments:read", label: "Read" }, { value: "assignments:read", label: "Read" },
{ value: "assignments:update", label: "Update" }, { value: "assignments:update", label: "Update" },
{ value: "assignments:delete", label: "Delete" }, { value: "assignments:delete", label: "Delete" },
{ value: "admin:assignments:manage", label: "Manage Admin Page" },
],
},
{
group: "Assignment Transfers",
permissions: [
{ value: "assignment-transfers:create", label: "Create" },
{ value: "assignment-transfers:read", label: "Read" },
{ value: "assignment-transfers:update", label: "Update" },
{ value: "assignment-transfers:delete", label: "Delete" },
{ value: "admin:assignment-transfers:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -101,6 +139,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "experience:read", label: "Read" }, { value: "experience:read", label: "Read" },
{ value: "experience:update", label: "Update" }, { value: "experience:update", label: "Update" },
{ value: "experience:delete", label: "Delete" }, { value: "experience:delete", label: "Delete" },
{ value: "admin:experience:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -110,6 +149,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "user-notifications:read", label: "Read" }, { value: "user-notifications:read", label: "Read" },
{ value: "user-notifications:update", label: "Update" }, { value: "user-notifications:update", label: "Update" },
{ value: "user-notifications:delete", label: "Delete" }, { value: "user-notifications:delete", label: "Delete" },
{ value: "admin:user-notifications:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -119,6 +159,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "evaluations:read", label: "Read" }, { value: "evaluations:read", label: "Read" },
{ value: "evaluations:update", label: "Update" }, { value: "evaluations:update", label: "Update" },
{ value: "evaluations:delete", label: "Delete" }, { value: "evaluations:delete", label: "Delete" },
{ value: "admin:evaluations:manage", label: "Manage Admin Page" },
], ],
}, },
@ -131,6 +172,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "missions:update", label: "Update" }, { value: "missions:update", label: "Update" },
{ value: "missions:delete", label: "Delete" }, { value: "missions:delete", label: "Delete" },
{ value: "missions:read-sensitive", label: "Read Sensitive Fields (server passwords)" }, { value: "missions:read-sensitive", label: "Read Sensitive Fields (server passwords)" },
{ value: "admin:missions:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -140,6 +182,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "mission-attendances:read", label: "Read" }, { value: "mission-attendances:read", label: "Read" },
{ value: "mission-attendances:update", label: "Update" }, { value: "mission-attendances:update", label: "Update" },
{ value: "mission-attendances:delete", label: "Delete" }, { value: "mission-attendances:delete", label: "Delete" },
{ value: "admin:mission-attendances:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -149,6 +192,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "campaigns:read", label: "Read" }, { value: "campaigns:read", label: "Read" },
{ value: "campaigns:update", label: "Update" }, { value: "campaigns:update", label: "Update" },
{ value: "campaigns:delete", label: "Delete" }, { value: "campaigns:delete", label: "Delete" },
{ value: "admin:campaigns:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -162,6 +206,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "factions:read", label: "Read" }, { value: "factions:read", label: "Read" },
{ value: "factions:update", label: "Update" }, { value: "factions:update", label: "Update" },
{ value: "factions:delete", label: "Delete" }, { value: "factions:delete", label: "Delete" },
{ value: "admin:factions:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -171,6 +216,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "technologies:read", label: "Read" }, { value: "technologies:read", label: "Read" },
{ value: "technologies:update", label: "Update" }, { value: "technologies:update", label: "Update" },
{ value: "technologies:delete", label: "Delete" }, { value: "technologies:delete", label: "Delete" },
{ value: "admin:technologies:manage", label: "Manage Admin Page" },
], ],
}, },
@ -182,6 +228,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "assets:read", label: "Read" }, { value: "assets:read", label: "Read" },
{ value: "assets:update", label: "Update" }, { value: "assets:update", label: "Update" },
{ value: "assets:delete", label: "Delete" }, { value: "assets:delete", label: "Delete" },
{ value: "admin:assets:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -191,6 +238,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "resources:read", label: "Read" }, { value: "resources:read", label: "Read" },
{ value: "resources:update", label: "Update" }, { value: "resources:update", label: "Update" },
{ value: "resources:delete", label: "Delete" }, { value: "resources:delete", label: "Delete" },
{ value: "admin:resources:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -200,6 +248,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "vehicles:read", label: "Read" }, { value: "vehicles:read", label: "Read" },
{ value: "vehicles:update", label: "Update" }, { value: "vehicles:update", label: "Update" },
{ value: "vehicles:delete", label: "Delete" }, { value: "vehicles:delete", label: "Delete" },
{ value: "admin:vehicles:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -209,6 +258,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "structures:read", label: "Read" }, { value: "structures:read", label: "Read" },
{ value: "structures:update", label: "Update" }, { value: "structures:update", label: "Update" },
{ value: "structures:delete", label: "Delete" }, { value: "structures:delete", label: "Delete" },
{ value: "admin:structures:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -218,6 +268,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "shipments:read", label: "Read" }, { value: "shipments:read", label: "Read" },
{ value: "shipments:update", label: "Update" }, { value: "shipments:update", label: "Update" },
{ value: "shipments:delete", label: "Delete" }, { value: "shipments:delete", label: "Delete" },
{ value: "admin:shipments:manage", label: "Manage Admin Page" },
], ],
}, },
@ -229,6 +280,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "bank-accounts:read", label: "Read" }, { value: "bank-accounts:read", label: "Read" },
{ value: "bank-accounts:update", label: "Update" }, { value: "bank-accounts:update", label: "Update" },
{ value: "bank-accounts:delete", label: "Delete" }, { value: "bank-accounts:delete", label: "Delete" },
{ value: "admin:bank-accounts:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -238,6 +290,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "bank-transactions:read", label: "Read" }, { value: "bank-transactions:read", label: "Read" },
{ value: "bank-transactions:update", label: "Update" }, { value: "bank-transactions:update", label: "Update" },
{ value: "bank-transactions:delete", label: "Delete" }, { value: "bank-transactions:delete", label: "Delete" },
{ value: "admin:bank-transactions:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -247,6 +300,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "ledger-entries:read", label: "Read" }, { value: "ledger-entries:read", label: "Read" },
{ value: "ledger-entries:update", label: "Update" }, { value: "ledger-entries:update", label: "Update" },
{ value: "ledger-entries:delete", label: "Delete" }, { value: "ledger-entries:delete", label: "Delete" },
{ value: "admin:ledger-entries:manage", label: "Manage Admin Page" },
], ],
}, },
@ -258,6 +312,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "locker-storages:read", label: "Read" }, { value: "locker-storages:read", label: "Read" },
{ value: "locker-storages:update", label: "Update" }, { value: "locker-storages:update", label: "Update" },
{ value: "locker-storages:delete", label: "Delete" }, { value: "locker-storages:delete", label: "Delete" },
{ value: "admin:locker-storages:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -267,6 +322,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "loadouts:read", label: "Read" }, { value: "loadouts:read", label: "Read" },
{ value: "loadouts:update", label: "Update" }, { value: "loadouts:update", label: "Update" },
{ value: "loadouts:delete", label: "Delete" }, { value: "loadouts:delete", label: "Delete" },
{ value: "admin:loadouts:manage", label: "Manage Admin Page" },
], ],
}, },
@ -278,6 +334,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "market-listings:read", label: "Read" }, { value: "market-listings:read", label: "Read" },
{ value: "market-listings:update", label: "Update" }, { value: "market-listings:update", label: "Update" },
{ value: "market-listings:delete", label: "Delete" }, { value: "market-listings:delete", label: "Delete" },
{ value: "admin:market-listings:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -287,6 +344,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "market-negotiations:read", label: "Read" }, { value: "market-negotiations:read", label: "Read" },
{ value: "market-negotiations:update", label: "Update" }, { value: "market-negotiations:update", label: "Update" },
{ value: "market-negotiations:delete", label: "Delete" }, { value: "market-negotiations:delete", label: "Delete" },
{ value: "admin:market-negotiations:manage", label: "Manage Admin Page" },
], ],
}, },
@ -298,6 +356,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "projects:read", label: "Read" }, { value: "projects:read", label: "Read" },
{ value: "projects:update", label: "Update" }, { value: "projects:update", label: "Update" },
{ value: "projects:delete", label: "Delete" }, { value: "projects:delete", label: "Delete" },
{ value: "admin:projects:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -307,6 +366,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "labels:read", label: "Read" }, { value: "labels:read", label: "Read" },
{ value: "labels:update", label: "Update" }, { value: "labels:update", label: "Update" },
{ value: "labels:delete", label: "Delete" }, { value: "labels:delete", label: "Delete" },
{ value: "admin:labels:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -316,6 +376,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "sprints:read", label: "Read" }, { value: "sprints:read", label: "Read" },
{ value: "sprints:update", label: "Update" }, { value: "sprints:update", label: "Update" },
{ value: "sprints:delete", label: "Delete" }, { value: "sprints:delete", label: "Delete" },
{ value: "admin:sprints:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -325,6 +386,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "releases:read", label: "Read" }, { value: "releases:read", label: "Read" },
{ value: "releases:update", label: "Update" }, { value: "releases:update", label: "Update" },
{ value: "releases:delete", label: "Delete" }, { value: "releases:delete", label: "Delete" },
{ value: "admin:releases:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -334,6 +396,17 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "tickets:read", label: "Read" }, { value: "tickets:read", label: "Read" },
{ value: "tickets:update", label: "Update" }, { value: "tickets:update", label: "Update" },
{ value: "tickets:delete", label: "Delete" }, { value: "tickets:delete", label: "Delete" },
{ value: "admin:tickets:manage", label: "Manage Admin Page" },
],
},
{
group: "Ticket Votes",
permissions: [
{ value: "ticket-votes:create", label: "Create" },
{ value: "ticket-votes:read", label: "Read" },
{ value: "ticket-votes:update", label: "Update" },
{ value: "ticket-votes:delete", label: "Delete" },
{ value: "admin:ticket-votes:manage", label: "Manage Admin Page" },
], ],
}, },
@ -345,6 +418,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "game-structures:read", label: "Read" }, { value: "game-structures:read", label: "Read" },
{ value: "game-structures:update", label: "Update" }, { value: "game-structures:update", label: "Update" },
{ value: "game-structures:delete", label: "Delete" }, { value: "game-structures:delete", label: "Delete" },
{ value: "admin:game-structures:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -354,6 +428,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "game-vehicles:read", label: "Read" }, { value: "game-vehicles:read", label: "Read" },
{ value: "game-vehicles:update", label: "Update" }, { value: "game-vehicles:update", label: "Update" },
{ value: "game-vehicles:delete", label: "Delete" }, { value: "game-vehicles:delete", label: "Delete" },
{ value: "admin:game-vehicles:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -363,6 +438,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "game-npcs:read", label: "Read" }, { value: "game-npcs:read", label: "Read" },
{ value: "game-npcs:update", label: "Update" }, { value: "game-npcs:update", label: "Update" },
{ value: "game-npcs:delete", label: "Delete" }, { value: "game-npcs:delete", label: "Delete" },
{ value: "admin:game-npcs:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -372,6 +448,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "game-hard-resources:read", label: "Read" }, { value: "game-hard-resources:read", label: "Read" },
{ value: "game-hard-resources:update", label: "Update" }, { value: "game-hard-resources:update", label: "Update" },
{ value: "game-hard-resources:delete", label: "Delete" }, { value: "game-hard-resources:delete", label: "Delete" },
{ value: "admin:game-hard-resources:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -381,6 +458,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "game-event-logs:read", label: "Read" }, { value: "game-event-logs:read", label: "Read" },
{ value: "game-event-logs:update", label: "Update" }, { value: "game-event-logs:update", label: "Update" },
{ value: "game-event-logs:delete", label: "Delete" }, { value: "game-event-logs:delete", label: "Delete" },
{ value: "admin:game-event-logs:manage", label: "Manage Admin Page" },
], ],
}, },
@ -392,6 +470,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "maps:read", label: "Read" }, { value: "maps:read", label: "Read" },
{ value: "maps:update", label: "Update" }, { value: "maps:update", label: "Update" },
{ value: "maps:delete", label: "Delete" }, { value: "maps:delete", label: "Delete" },
{ value: "admin:maps:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -401,6 +480,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "narrative-events:read", label: "Read" }, { value: "narrative-events:read", label: "Read" },
{ value: "narrative-events:update", label: "Update" }, { value: "narrative-events:update", label: "Update" },
{ value: "narrative-events:delete", label: "Delete" }, { value: "narrative-events:delete", label: "Delete" },
{ value: "admin:narrative-events:manage", label: "Manage Admin Page" },
], ],
}, },
@ -412,6 +492,7 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "mission-files:read", label: "Read" }, { value: "mission-files:read", label: "Read" },
{ value: "mission-files:update", label: "Update" }, { value: "mission-files:update", label: "Update" },
{ value: "mission-files:delete", label: "Delete" }, { value: "mission-files:delete", label: "Delete" },
{ value: "admin:mission-files:manage", label: "Manage Admin Page" },
], ],
}, },
{ {
@ -421,6 +502,37 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "mod-lists:read", label: "Read" }, { value: "mod-lists:read", label: "Read" },
{ value: "mod-lists:update", label: "Update" }, { value: "mod-lists:update", label: "Update" },
{ value: "mod-lists:delete", label: "Delete" }, { value: "mod-lists:delete", label: "Delete" },
{ value: "admin:mod-lists:manage", label: "Manage Admin Page" },
],
},
{
group: "Game Servers",
permissions: [
{ value: "game-servers:create", label: "Create" },
{ value: "game-servers:read", label: "Read" },
{ value: "game-servers:update", label: "Update" },
{ value: "game-servers:delete", label: "Delete" },
{ value: "admin:game-servers:manage", label: "Manage Admin Page" },
],
},
{
group: "Arma Sync Events",
permissions: [
{ value: "arma-sync-events:create", label: "Create" },
{ value: "arma-sync-events:read", label: "Read" },
{ value: "arma-sync-events:update", label: "Update" },
{ value: "arma-sync-events:delete", label: "Delete" },
{ value: "admin:arma-sync-events:manage", label: "Manage Admin Page" },
],
},
{
group: "Arma Commands",
permissions: [
{ value: "arma-commands:create", label: "Create" },
{ value: "arma-commands:read", label: "Read" },
{ value: "arma-commands:update", label: "Update" },
{ value: "arma-commands:delete", label: "Delete" },
{ value: "admin:arma-commands:manage", label: "Manage Admin Page" },
], ],
}, },
@ -478,182 +590,256 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
g.permissions.map((p) => p.value), g.permissions.map((p) => p.value),
) as unknown as readonly [ ) as unknown as readonly [
"system:admin-access", "system:admin-access",
"media:create",
"media:read",
"media:update",
"media:delete",
"admin:media:manage",
"users:create", "users:create",
"users:read", "users:read",
"users:update", "users:update",
"users:delete", "users:delete",
"users:unlock", "users:unlock",
"users:assign-roles", "users:assign-roles",
"admin:users:manage",
"roles:create",
"roles:read",
"roles:update",
"roles:delete",
"admin:roles:manage",
"ranks:create", "ranks:create",
"ranks:read", "ranks:read",
"ranks:update", "ranks:update",
"ranks:delete", "ranks:delete",
"admin:ranks:manage",
"profiles:create", "profiles:create",
"profiles:read", "profiles:read",
"profiles:update", "profiles:update",
"profiles:delete", "profiles:delete",
"admin:profiles:manage",
"awards:create", "awards:create",
"awards:read", "awards:read",
"awards:update", "awards:update",
"awards:delete", "awards:delete",
"admin:awards:manage",
"qualifications:create", "qualifications:create",
"qualifications:read", "qualifications:read",
"qualifications:update", "qualifications:update",
"qualifications:delete", "qualifications:delete",
"admin:qualifications:manage",
"assignments:create", "assignments:create",
"assignments:read", "assignments:read",
"assignments:update", "assignments:update",
"assignments:delete", "assignments:delete",
"admin:assignments:manage",
"assignment-transfers:create",
"assignment-transfers:read",
"assignment-transfers:update",
"assignment-transfers:delete",
"admin:assignment-transfers:manage",
"experience:create", "experience:create",
"experience:read", "experience:read",
"experience:update", "experience:update",
"experience:delete", "experience:delete",
"admin:experience:manage",
"user-notifications:create", "user-notifications:create",
"user-notifications:read", "user-notifications:read",
"user-notifications:update", "user-notifications:update",
"user-notifications:delete", "user-notifications:delete",
"admin:user-notifications:manage",
"evaluations:create", "evaluations:create",
"evaluations:read", "evaluations:read",
"evaluations:update", "evaluations:update",
"evaluations:delete", "evaluations:delete",
"admin:evaluations:manage",
"missions:create", "missions:create",
"missions:read", "missions:read",
"missions:update", "missions:update",
"missions:delete", "missions:delete",
"missions:read-sensitive", "missions:read-sensitive",
"admin:missions:manage",
"mission-attendances:create", "mission-attendances:create",
"mission-attendances:read", "mission-attendances:read",
"mission-attendances:update", "mission-attendances:update",
"mission-attendances:delete", "mission-attendances:delete",
"admin:mission-attendances:manage",
"campaigns:create", "campaigns:create",
"campaigns:read", "campaigns:read",
"campaigns:update", "campaigns:update",
"campaigns:delete", "campaigns:delete",
"admin:campaigns:manage",
"campaigns:manage", "campaigns:manage",
"factions:create", "factions:create",
"factions:read", "factions:read",
"factions:update", "factions:update",
"factions:delete", "factions:delete",
"admin:factions:manage",
"technologies:create", "technologies:create",
"technologies:read", "technologies:read",
"technologies:update", "technologies:update",
"technologies:delete", "technologies:delete",
"admin:technologies:manage",
"assets:create", "assets:create",
"assets:read", "assets:read",
"assets:update", "assets:update",
"assets:delete", "assets:delete",
"admin:assets:manage",
"resources:create", "resources:create",
"resources:read", "resources:read",
"resources:update", "resources:update",
"resources:delete", "resources:delete",
"admin:resources:manage",
"vehicles:create", "vehicles:create",
"vehicles:read", "vehicles:read",
"vehicles:update", "vehicles:update",
"vehicles:delete", "vehicles:delete",
"admin:vehicles:manage",
"structures:create", "structures:create",
"structures:read", "structures:read",
"structures:update", "structures:update",
"structures:delete", "structures:delete",
"admin:structures:manage",
"shipments:create", "shipments:create",
"shipments:read", "shipments:read",
"shipments:update", "shipments:update",
"shipments:delete", "shipments:delete",
"admin:shipments:manage",
"bank-accounts:create", "bank-accounts:create",
"bank-accounts:read", "bank-accounts:read",
"bank-accounts:update", "bank-accounts:update",
"bank-accounts:delete", "bank-accounts:delete",
"admin:bank-accounts:manage",
"bank-transactions:create", "bank-transactions:create",
"bank-transactions:read", "bank-transactions:read",
"bank-transactions:update", "bank-transactions:update",
"bank-transactions:delete", "bank-transactions:delete",
"admin:bank-transactions:manage",
"ledger-entries:create", "ledger-entries:create",
"ledger-entries:read", "ledger-entries:read",
"ledger-entries:update", "ledger-entries:update",
"ledger-entries:delete", "ledger-entries:delete",
"admin:ledger-entries:manage",
"locker-storages:create", "locker-storages:create",
"locker-storages:read", "locker-storages:read",
"locker-storages:update", "locker-storages:update",
"locker-storages:delete", "locker-storages:delete",
"admin:locker-storages:manage",
"loadouts:create", "loadouts:create",
"loadouts:read", "loadouts:read",
"loadouts:update", "loadouts:update",
"loadouts:delete", "loadouts:delete",
"admin:loadouts:manage",
"market-listings:create", "market-listings:create",
"market-listings:read", "market-listings:read",
"market-listings:update", "market-listings:update",
"market-listings:delete", "market-listings:delete",
"admin:market-listings:manage",
"market-negotiations:create", "market-negotiations:create",
"market-negotiations:read", "market-negotiations:read",
"market-negotiations:update", "market-negotiations:update",
"market-negotiations:delete", "market-negotiations:delete",
"admin:market-negotiations:manage",
"projects:create", "projects:create",
"projects:read", "projects:read",
"projects:update", "projects:update",
"projects:delete", "projects:delete",
"sprints:create", "admin:projects:manage",
"sprints:read",
"sprints:update",
"sprints:delete",
"labels:create", "labels:create",
"labels:read", "labels:read",
"labels:update", "labels:update",
"labels:delete", "labels:delete",
"admin:labels:manage",
"sprints:create",
"sprints:read",
"sprints:update",
"sprints:delete",
"admin:sprints:manage",
"releases:create", "releases:create",
"releases:read", "releases:read",
"releases:update", "releases:update",
"releases:delete", "releases:delete",
"admin:releases:manage",
"tickets:create", "tickets:create",
"tickets:read", "tickets:read",
"tickets:update", "tickets:update",
"tickets:delete", "tickets:delete",
"admin:tickets:manage",
"ticket-votes:create",
"ticket-votes:read",
"ticket-votes:update",
"ticket-votes:delete",
"admin:ticket-votes:manage",
"game-structures:create", "game-structures:create",
"game-structures:read", "game-structures:read",
"game-structures:update", "game-structures:update",
"game-structures:delete", "game-structures:delete",
"admin:game-structures:manage",
"game-vehicles:create", "game-vehicles:create",
"game-vehicles:read", "game-vehicles:read",
"game-vehicles:update", "game-vehicles:update",
"game-vehicles:delete", "game-vehicles:delete",
"admin:game-vehicles:manage",
"game-npcs:create", "game-npcs:create",
"game-npcs:read", "game-npcs:read",
"game-npcs:update", "game-npcs:update",
"game-npcs:delete", "game-npcs:delete",
"admin:game-npcs:manage",
"game-hard-resources:create", "game-hard-resources:create",
"game-hard-resources:read", "game-hard-resources:read",
"game-hard-resources:update", "game-hard-resources:update",
"game-hard-resources:delete", "game-hard-resources:delete",
"admin:game-hard-resources:manage",
"game-event-logs:create", "game-event-logs:create",
"game-event-logs:read", "game-event-logs:read",
"game-event-logs:update", "game-event-logs:update",
"game-event-logs:delete", "game-event-logs:delete",
"admin:game-event-logs:manage",
"maps:create", "maps:create",
"maps:read", "maps:read",
"maps:update", "maps:update",
"maps:delete", "maps:delete",
"admin:maps:manage",
"narrative-events:create", "narrative-events:create",
"narrative-events:read", "narrative-events:read",
"narrative-events:update", "narrative-events:update",
"narrative-events:delete", "narrative-events:delete",
"admin:narrative-events:manage",
"mission-files:create", "mission-files:create",
"mission-files:read", "mission-files:read",
"mission-files:update", "mission-files:update",
"mission-files:delete", "mission-files:delete",
"admin:mission-files:manage",
"mod-lists:create", "mod-lists:create",
"mod-lists:read", "mod-lists:read",
"mod-lists:update", "mod-lists:update",
"mod-lists:delete", "mod-lists:delete",
"admin:mod-lists:manage",
"game-servers:create",
"game-servers:read",
"game-servers:update",
"game-servers:delete",
"admin:game-servers:manage",
"arma-sync-events:create",
"arma-sync-events:read",
"arma-sync-events:update",
"arma-sync-events:delete",
"admin:arma-sync-events:manage",
"arma-commands:create",
"arma-commands:read",
"arma-commands:update",
"arma-commands:delete",
"admin:arma-commands:manage",
"game-rules:read", "game-rules:read",
"game-rules:update", "game-rules:update",
"shims:read", "shims:read",
"shims:update", "shims:update",
"campaigns:manage",
"logistics:manage", "logistics:manage",
"intelligence:manage",
"profiles:intel_excerpt:update",
"banking:manage", "banking:manage",
"tickets:staff", "tickets:staff",
"discord:staff", "discord:staff",
"discord:announce", "discord:announce",
"intelligence:manage",
"profiles:intel_excerpt:update",
]; ];
/** /**

View file

@ -2,6 +2,17 @@ import { getPayload } from "payload";
import config from "@payload-config"; import config from "@payload-config";
import type { Permission } from "@/permissions"; import type { Permission } from "@/permissions";
const resolvedConfig = await config;
const COLLECTION_SLUGS = resolvedConfig.collections.map((c) => c.slug);
const ALL_COLLECTION_READS: Permission[] = COLLECTION_SLUGS.map(
(slug) => `${slug}:read` as Permission,
);
const ALL_ADMIN_PAGE_MANAGE: Permission[] = COLLECTION_SLUGS.map(
(slug) => `admin:${slug}:manage` as Permission,
);
const USER_PERMISSIONS: Permission[] = [ const USER_PERMISSIONS: Permission[] = [
"users:read", "users:read",
"ranks:read", "ranks:read",
@ -47,34 +58,38 @@ const USER_PERMISSIONS: Permission[] = [
]; ];
const ADMIN_PERMISSIONS: Permission[] = [ const ADMIN_PERMISSIONS: Permission[] = [
...USER_PERMISSIONS, ...new Set<Permission>([
"system:admin-access", ...USER_PERMISSIONS,
"users:read", "system:admin-access",
"users:update", "users:read",
"users:delete", "users:update",
"technologies:create", "users:delete",
"technologies:read", "technologies:create",
"technologies:update", "technologies:read",
"technologies:delete", "technologies:update",
"tickets:read", "technologies:delete",
"tickets:update", "tickets:read",
"tickets:staff", "tickets:update",
"bank-accounts:create", "tickets:staff",
"bank-accounts:update", "bank-accounts:create",
"user-notifications:read", "bank-accounts:update",
"user-notifications:update", "user-notifications:read",
"mission-attendances:create", "user-notifications:update",
"mission-attendances:read", "mission-attendances:create",
"mission-attendances:update", "mission-attendances:read",
"mission-attendances:delete", "mission-attendances:update",
"missions:read", "mission-attendances:delete",
"market-negotiations:read", "missions:read",
"market-negotiations:update", "market-negotiations:read",
"logistics:manage", "market-negotiations:update",
"banking:manage", "logistics:manage",
"discord:staff", "banking:manage",
"discord:announce", "discord:staff",
"structures:create", "discord:announce",
"structures:create",
...ALL_COLLECTION_READS,
...ALL_ADMIN_PAGE_MANAGE,
]),
]; ];
interface BuiltinRole { interface BuiltinRole {
@ -145,9 +160,36 @@ export const seedRoles = async () => {
}); });
if (existing.docs.length > 0) { if (existing.docs.length > 0) {
const doc = existing.docs[0] as { id: number }; const doc = existing.docs[0] as {
id: number;
isSystem?: boolean;
permissions?: Permission[] | null;
};
roleIdMap.set(role.slug, doc.id); roleIdMap.set(role.slug, doc.id);
payload.logger.info(` Role "${role.slug}" already exists (id=${doc.id}), skipping.`);
if (role.isSystem) {
const existingPerms = doc.permissions ?? [];
const missing = role.permissions.filter((p) => !existingPerms.includes(p));
if (missing.length > 0) {
await payload.update({
collection: "roles",
id: doc.id,
data: { permissions: [...existingPerms, ...missing] },
overrideAccess: true,
});
payload.logger.info(
` Role "${role.slug}" (id=${doc.id}) updated with ${missing.length} missing built-in permission(s).`,
);
} else {
payload.logger.info(
` Role "${role.slug}" already exists (id=${doc.id}), permissions up to date.`,
);
}
} else {
payload.logger.info(
` Role "${role.slug}" already exists (id=${doc.id}), skipping (non-system).`,
);
}
continue; continue;
} }

View file

@ -1,4 +1,4 @@
import type { Payload, PayloadRequest } from "payload"; import type { Access, AccessArgs, AccessResult, Payload, PayloadRequest } from "payload";
import { loadUserPermissions } from "@/utils/access-control/loadUserPermissions"; import { loadUserPermissions } from "@/utils/access-control/loadUserPermissions";
import type { Permission } from "@/permissions"; import type { Permission } from "@/permissions";
@ -80,6 +80,65 @@ export async function hasAllPermissions(
return permissions.every((p) => userPerms.has(p)); 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
* `<slug>:read` AND `admin:<slug>: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<AccessResult> => {
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 * Factory that creates a Payload collection access function requiring a specific
* permission. Drop-in replacement for `isDeveloper` / `isAdmin` in collection * permission. Drop-in replacement for `isDeveloper` / `isAdmin` in collection

View file

@ -0,0 +1,164 @@
import { getPayload, Payload } from "payload";
import type { Access } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Role, User } from "@/payload-types";
import { requireAdminPageAccess } from "@/utils/access-control/hasPermission";
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
let payload: Payload;
const RUN = `adm-${Date.now().toString(36)}`;
const TIMEOUT = 30_000;
describe("Scoped admin page access control", () => {
const roleIds: number[] = [];
const userIds: number[] = [];
let readOnlyUser: User;
let manageOnlyUser: User;
let bothUser: User;
let devUser: User;
let neitherUser: User;
const makeRole = async (label: string, extra: Partial<Role> = {}): Promise<Role> => {
const role = (await payload.create({
collection: "roles",
data: { name: `${RUN}-${label}`, slug: `${RUN}-${label}`, ...extra },
overrideAccess: true,
depth: 0,
})) as unknown as Role;
roleIds.push(role.id);
return role;
};
const makeUser = async (label: string, roleId: number): Promise<User> => {
const user = (await payload.create({
collection: "users",
data: {
username: `${RUN}-${label}`,
discordUsername: `${RUN}-${label}`,
displayName: label.toUpperCase(),
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
// Permission resolution reads roleDocs (the dynamic RBAC relationship), not
// the legacy `roles` enum — so assign a real role doc to grant permissions.
roleDocs: [roleId],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userIds.push(user.id);
return user;
};
// Invoke the admin-page wrapper exactly as Payload would for an admin-panel
// request (pathname under /admin). The vitest environment has no Next.js HTTP
// server, so calling the access function directly is the standard way to unit-test
// Payload access control.
const adminDecision = async (user: User | null): Promise<unknown> => {
const fn = requireAdminPageAccess("missions");
return await (fn as (args: { req: unknown }) => Promise<unknown>)({
req: { user, payload, pathname: "/admin/collections/missions" },
});
};
// Same wrapper, but on a REST API pathname — the original read access must be
// preserved unchanged outside the admin panel.
const apiDecision = async (user: User | null, readAccess?: Access | boolean): Promise<unknown> => {
const fn = requireAdminPageAccess("missions", readAccess);
return await (fn as (args: { req: unknown }) => Promise<unknown>)({
req: { user, payload, pathname: "/api/missions" },
});
};
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
invalidatePermissionCache();
const readOnlyRole = await makeRole("readonly", { permissions: ["missions:read"] });
const manageOnlyRole = await makeRole("manageonly", {
permissions: ["admin:missions:manage"],
});
const bothRole = await makeRole("both", {
permissions: ["missions:read", "admin:missions:manage"],
});
const devRole = await makeRole("dev", { isSuperuser: true });
const neitherRole = await makeRole("neither", { permissions: [] });
readOnlyUser = await makeUser("readonly", readOnlyRole.id);
manageOnlyUser = await makeUser("manageonly", manageOnlyRole.id);
bothUser = await makeUser("both", bothRole.id);
devUser = await makeUser("dev", devRole.id);
neitherUser = await makeUser("neither", neitherRole.id);
}, TIMEOUT);
afterAll(async () => {
if (!payload) return;
for (const id of userIds) {
const profiles = await payload
.find({
collection: "profiles",
where: { user: { equals: id } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const p of profiles?.docs ?? []) {
await Promise.allSettled([
payload.delete({ collection: "profiles", id: p.id, overrideAccess: true }),
]);
}
await Promise.allSettled([payload.delete({ collection: "users", id, overrideAccess: true })]);
}
for (const id of roleIds) {
await Promise.allSettled([payload.delete({ collection: "roles", id, overrideAccess: true })]);
}
});
it("denies admin access with only the collection read permission", async () => {
expect(await adminDecision(readOnlyUser)).toBe(false);
}, TIMEOUT);
it("denies admin access with only the admin page-manage permission", async () => {
expect(await adminDecision(manageOnlyUser)).toBe(false);
}, TIMEOUT);
it("grants admin access with both permissions", async () => {
expect(await adminDecision(bothUser)).toBe(true);
}, TIMEOUT);
it("grants admin access to superuser roles", async () => {
expect(await adminDecision(devUser)).toBe(true);
}, TIMEOUT);
it("denies admin access with neither permission", async () => {
expect(await adminDecision(neitherUser)).toBe(false);
}, TIMEOUT);
it("denies anonymous admin access", async () => {
expect(await adminDecision(null)).toBe(false);
}, TIMEOUT);
it("preserves the default logged-in read on the API path", async () => {
expect(await apiDecision(bothUser)).toBe(true);
}, TIMEOUT);
it("preserves the default anonymous denial on the API path", async () => {
expect(await apiDecision(null)).toBe(false);
}, TIMEOUT);
it("delegates a Where-returning original access on the API path", async () => {
const where = { user: { equals: bothUser.id } };
expect(await apiDecision(bothUser, () => where)).toEqual(where);
}, TIMEOUT);
it("delegates a boolean original access on the API path", async () => {
expect(await apiDecision(bothUser, () => false)).toBe(false);
expect(await apiDecision(bothUser, () => true)).toBe(true);
}, TIMEOUT);
});