// Server-authoritative permission matrix. Roles form an ordered rank // (ADMIN > MANAGER > STAFF > VIEWER — this is the "level" concept); every // write action carries a minimum rank. VIEWER holds rank 0 and is the // read-only role. Reads are not listed here — they stay on AuthenticatedGuard // alone, so any logged-in user (including VIEWER) can read. // // This is the single source of truth: the API enforces it via AbilityGuard and // ships the resolved per-user map to the web through /auth/me, so the UI never // keeps its own copy of the rules. export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER"; export const ROLE_RANK: Record = { VIEWER: 0, STAFF: 1, MANAGER: 2, ADMIN: 3, }; export type Ability = | "customer:create" | "customer:update" | "customer:delete" | "policy:create" | "policy:update" | "policy:delete" | "policy:ingest" | "policy:ocr-review" | "renewal:send" | "property:create" | "property:update" | "property:delete" | "ledger:create" | "ledger:void" | "bank:create" | "bank:void" | "bank:manage-accounts" | "statement:ingest" | "statement:review" | "lookup:manage" | "user:manage" | "db:manage" | "notification:send" | "setting:manage"; /** Minimum role required for each ability. */ export const ABILITY_MIN: Record = { "customer:create": "STAFF", "customer:update": "STAFF", "customer:delete": "ADMIN", "policy:create": "STAFF", "policy:update": "STAFF", "policy:delete": "MANAGER", // Insurance OCR intake is the same trust tier as statement OCR: STAFF can // upload + confirm, nothing reaches the books unconfirmed. "policy:ingest": "STAFF", "policy:ocr-review": "STAFF", "renewal:send": "MANAGER", "property:create": "STAFF", "property:update": "STAFF", "property:delete": "MANAGER", "ledger:create": "STAFF", "ledger:void": "MANAGER", "bank:create": "STAFF", "bank:void": "MANAGER", // Opening or renaming a chequera is rarer and higher-stakes than posting a // movement into one — a wrong account silently mixes two sets of books. "bank:manage-accounts": "MANAGER", // Uploading a stack of scans and reviewing what the OCR read are both // "capturing a receipt" — the same trust tier as ledger:create, since // confirming a statement *is* capturing it. The review step is what makes // this safe at STAFF level: nothing reaches the ledger unconfirmed. "statement:ingest": "STAFF", "statement:review": "STAFF", "lookup:manage": "MANAGER", "user:manage": "ADMIN", "db:manage": "ADMIN", // Mass email notifications — fires mail to customers on the office's // behalf, with no per-row review. Same trust tier as `renewal:send`: // a STAFF user typing one customer receipt is fine; a STAFF user firing // 260 mail merges on the customer base is not. "notification:send": "MANAGER", // Editing operator configuration. Above `notification:send` on purpose: // firing a sweep is the day job, but changing WHERE the audit summaries // land is how someone would quietly stop them being read. "setting:manage": "ADMIN", }; export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[]; export function can(role: Role, ability: Ability): boolean { return ROLE_RANK[role] >= ROLE_RANK[ABILITY_MIN[ability]]; } /** Resolved {ability: boolean} map for a role — sent to the web via /auth/me. */ export function abilitiesFor(role: Role): Record { return Object.fromEntries( ALL_ABILITIES.map((a) => [a, can(role, a)]), ) as Record; }