Files
edr-platform/apps/finance-web/src/auth/permissions.ts
2026-08-25 00:11:39 +03:00

117 lines
3.8 KiB
TypeScript

import type { AuthPermission, AuthUser } from "./types";
/**
* Mirror of the API's FINANCE_PERMS registry
* (apps/finance-api/src/seed/finance-permissions.registry.ts).
*
* Kept as a hand-written mirror rather than an import: the API is a separate
* deployable and this app must not take a build dependency on it. The keys are a
* wire contract, so drift here surfaces as a UI control that is visible but
* 403s — which is why the server check is the real gate and this is only used to
* hide or disable controls.
*/
export const FINANCE_PERMS = {
account: {
view: "can:view:gl_account",
manage: "can:manage:gl_account",
},
journal: {
create: "can:create:journal_entry",
view: "can:view:journal_entry",
post: "can:post:journal_entry",
reverse: "can:reverse:journal_entry",
},
period: {
view: "can:view:fiscal_period",
manage: "can:manage:fiscal_period",
close: "can:close:fiscal_period",
},
receivable: {
view: "can:view:receivable",
manageCustomer: "can:manage:finance_customer",
recordReceipt: "can:record:receipt",
manageRevenueMapping: "can:manage:revenue_mapping",
},
payable: {
view: "can:view:payable",
manageSupplier: "can:manage:supplier",
manageBill: "can:manage:supplier_bill",
approveBill: "can:approve:supplier_bill",
recordPayment: "can:record:supplier_payment",
postPayroll: "can:post:payroll_to_gl",
manageStatutory: "can:manage:statutory_payable",
},
budget: {
view: "can:view:budget",
manage: "can:manage:budget",
approve: "can:approve:budget",
manageCostCenter: "can:manage:cost_center",
},
asset: {
view: "can:view:fixed_asset",
manage: "can:manage:fixed_asset",
depreciate: "can:run:depreciation",
dispose: "can:dispose:fixed_asset",
},
report: {
view: "can:view:finance_report",
export: "can:export:finance_report",
},
} as const;
const SUPER_ADMIN_ROLE = "super_admin";
const positionsOf = (user: AuthUser | null | undefined) => {
const employee = user?.employee;
if (!employee) return [];
if (Array.isArray(employee)) return employee.flatMap((e) => e.positions ?? []);
return [
...(employee.position ? [employee.position] : []),
...(employee.positions ?? []),
...(employee.delegatedPositions ?? []),
];
};
const keysOf = (permissions: AuthPermission[] | undefined) =>
(permissions ?? []).map((p) => p.key).filter((k): k is string => Boolean(k));
/** Every permission key the token carries — roles, positions, position types. */
export const collectPermissionKeys = (
user: AuthUser | null | undefined,
): string[] => {
if (!user) return [];
const keys = new Set<string>(keysOf(user.permissions));
for (const position of positionsOf(user)) {
keysOf(position.permissions).forEach((key) => keys.add(key));
keysOf(position.positionType?.permissions).forEach((key) => keys.add(key));
}
return [...keys];
};
export const isSuperAdmin = (user: AuthUser | null | undefined): boolean =>
Boolean(user?.roles?.some((role) => role.key === SUPER_ADMIN_ROLE));
/** An array is "any of" — kept in step with edr-hr-web, where a screen
* reachable by either of two permissions made the widening necessary. */
export const hasPermission = (
user: AuthUser | null | undefined,
permission: string | string[],
): boolean => {
if (!user) return false;
if (isSuperAdmin(user)) return true;
const keys = collectPermissionKeys(user);
return Array.isArray(permission)
? permission.some((p) => keys.includes(p))
: keys.includes(permission);
};
/** The caller's `iam.employees.id` — who a posting is recorded against. */
export const currentEmployeeId = (
user: AuthUser | null | undefined,
): string | null => {
const employee = user?.employee;
if (!employee) return null;
if (Array.isArray(employee)) return employee[0]?.id ?? null;
return employee.id ?? null;
};