Replace the maps:create/update/delete catch-all for map features: roads, zones, and nodes each take a per-collection author permission, structure placement takes maps:place (still co-gated with the logistics qualification), and GeoJSON import requires all three author permissions. The map view page keys edit UI per feature type; admin role seed grants the new values. Tests updated for the permission split.
260 lines
7 KiB
TypeScript
260 lines
7 KiB
TypeScript
import { getPayload } from "payload";
|
|
import config from "@payload-config";
|
|
import { ALL_PERMISSION_VALUES, 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,
|
|
);
|
|
|
|
/**
|
|
* Registry grants only: the Roles field's select options come from the
|
|
* permission registry, so collection reads generated for plugin-injected
|
|
* collections (story-beats, voice-submissions, ...) are not grantable and
|
|
* would fail validation.
|
|
*/
|
|
const GRANTABLE = new Set<string>(ALL_PERMISSION_VALUES);
|
|
const grantable = (permissions: Permission[]): Permission[] =>
|
|
permissions.filter((p) => GRANTABLE.has(p));
|
|
|
|
/**
|
|
* Read-only baseline (grantable collection reads + global reads). No write
|
|
* permissions on purpose: admin-page visibility must stay scoped to division
|
|
* roles' manage grants. `<slug>:read` alone never grants admin panel entry
|
|
* (that needs a superuser role or any `admin:<slug>:manage`).
|
|
*/
|
|
const USER_PERMISSIONS: Permission[] = [
|
|
...grantable(ALL_COLLECTION_READS),
|
|
"game-rules:read",
|
|
"shims:read",
|
|
];
|
|
|
|
const ADMIN_PERMISSIONS: Permission[] = [
|
|
...new Set<Permission>([
|
|
...USER_PERMISSIONS,
|
|
"system:admin-access",
|
|
"users:read",
|
|
"users:update",
|
|
"users:delete",
|
|
"technologies:create",
|
|
"technologies:read",
|
|
"technologies:update",
|
|
"technologies:delete",
|
|
"tickets:read",
|
|
"tickets:update",
|
|
"tickets:staff",
|
|
"bank-accounts:create",
|
|
"bank-accounts:update",
|
|
"user-notifications:read",
|
|
"user-notifications:update",
|
|
"mission-attendances:create",
|
|
"mission-attendances:read",
|
|
"mission-attendances:update",
|
|
"mission-attendances:delete",
|
|
"missions:read",
|
|
"market-negotiations:read",
|
|
"market-negotiations:update",
|
|
"logistics:manage",
|
|
"banking:manage",
|
|
"discord:staff",
|
|
"discord:announce",
|
|
"forms:create",
|
|
"forms:read",
|
|
"forms:update",
|
|
"forms:delete",
|
|
"form-submissions:read",
|
|
"structures:create",
|
|
"structures:update",
|
|
"maps:place",
|
|
"map-roads:author",
|
|
"map-zones:author",
|
|
"resource-nodes:author",
|
|
...ALL_COLLECTION_READS,
|
|
...ALL_ADMIN_PAGE_MANAGE,
|
|
]),
|
|
];
|
|
|
|
interface BuiltinRole {
|
|
slug: string;
|
|
name: string;
|
|
description: string;
|
|
permissions: Permission[];
|
|
isSystem: boolean;
|
|
isSuperuser: boolean;
|
|
}
|
|
|
|
const BUILTIN_ROLES: BuiltinRole[] = [
|
|
{
|
|
slug: "guest",
|
|
name: "Guest",
|
|
description: "Default role for unauthenticated or basic users. No permissions.",
|
|
permissions: [],
|
|
isSystem: true,
|
|
isSuperuser: false,
|
|
},
|
|
{
|
|
slug: "user",
|
|
name: "User",
|
|
description: "Standard authenticated user. Can manage shipments, structures, and read profiles.",
|
|
permissions: [...USER_PERMISSIONS],
|
|
isSystem: true,
|
|
isSuperuser: false,
|
|
},
|
|
{
|
|
slug: "admin",
|
|
name: "Admin",
|
|
description: "Administrator. Can manage users, tickets, banking, logistics, and most collections.",
|
|
permissions: [...ADMIN_PERMISSIONS],
|
|
isSystem: true,
|
|
isSuperuser: false,
|
|
},
|
|
{
|
|
slug: "developer",
|
|
name: "Developer",
|
|
description: "Superuser. Bypasses all permission checks. Full access to everything.",
|
|
permissions: [],
|
|
isSystem: true,
|
|
isSuperuser: true,
|
|
},
|
|
];
|
|
|
|
const ENUM_TO_SLUG: Record<string, string> = {
|
|
guest: "guest",
|
|
user: "user",
|
|
trusted: "user",
|
|
admin: "admin",
|
|
developer: "developer",
|
|
};
|
|
|
|
export const seedRoles = async () => {
|
|
const payload = await getPayload({ config });
|
|
|
|
payload.logger.info("Seeding built-in RBAC roles...");
|
|
|
|
const roleIdMap = new Map<string, number>();
|
|
|
|
for (const role of BUILTIN_ROLES) {
|
|
const existing = await payload.find({
|
|
collection: "roles",
|
|
where: { slug: { equals: role.slug } },
|
|
limit: 1,
|
|
overrideAccess: true,
|
|
});
|
|
|
|
if (existing.docs.length > 0) {
|
|
const doc = existing.docs[0] as {
|
|
id: number;
|
|
isSystem?: boolean;
|
|
permissions?: Permission[] | null;
|
|
};
|
|
roleIdMap.set(role.slug, doc.id);
|
|
|
|
if (role.isSystem) {
|
|
const existingPerms = (doc.permissions ?? []).filter((p) => GRANTABLE.has(p));
|
|
const missing = grantable(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;
|
|
}
|
|
|
|
const created = await payload.create({
|
|
collection: "roles",
|
|
data: {
|
|
name: role.name,
|
|
slug: role.slug,
|
|
description: role.description,
|
|
permissions: grantable(role.permissions),
|
|
isSystem: role.isSystem,
|
|
isSuperuser: role.isSuperuser,
|
|
},
|
|
overrideAccess: true,
|
|
});
|
|
|
|
roleIdMap.set(role.slug, created.id);
|
|
payload.logger.info(` Created role "${role.slug}" (id=${created.id}).`);
|
|
}
|
|
|
|
payload.logger.info("Migrating existing users from roles enum to roleDocs...");
|
|
|
|
const users = await payload.find({
|
|
collection: "users",
|
|
limit: 0,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
select: { roles: true, roleDocs: true },
|
|
});
|
|
|
|
let migrated = 0;
|
|
let skipped = 0;
|
|
|
|
for (const user of users.docs) {
|
|
const u = user as { id: number; roles?: string[] | null; roleDocs?: unknown[] | null };
|
|
|
|
if (u.roleDocs && Array.isArray(u.roleDocs) && u.roleDocs.length > 0) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
const enumRoles = u.roles ?? [];
|
|
if (enumRoles.length === 0) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
const roleDocIds: number[] = [];
|
|
const seenSlugs = new Set<string>();
|
|
|
|
for (const enumRole of enumRoles) {
|
|
const slug = ENUM_TO_SLUG[enumRole];
|
|
if (!slug || seenSlugs.has(slug)) continue;
|
|
seenSlugs.add(slug);
|
|
const roleId = roleIdMap.get(slug);
|
|
if (roleId) roleDocIds.push(roleId);
|
|
}
|
|
|
|
if (roleDocIds.length === 0) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
await payload.update({
|
|
collection: "users",
|
|
id: user.id,
|
|
data: { roleDocs: roleDocIds },
|
|
overrideAccess: true,
|
|
});
|
|
migrated++;
|
|
}
|
|
|
|
payload.logger.info(`Migration complete: ${migrated} users migrated, ${skipped} users skipped.`);
|
|
};
|
|
|
|
await seedRoles();
|
|
|
|
// CLI script: the Payload pool keeps the event loop alive after completion;
|
|
// exit explicitly (same convention as the game-tick bins).
|
|
process.exit(0);
|