import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ApiError, can, createRole, deleteRole, fetchRoles, updateRole, type ManagedRole, type Permission, type SessionUser, } from "./api.js"; import { Modal } from "./ui/Modal.js"; import { MODULES, jobsBehind, tillsFor, type JobPreset, type ModuleId, type TillId } from "@parking/shared"; // Role management (admin). Compose a role from the permission grid (a checkbox // matrix of resource × action) and name it; users are then assigned a role. The // built-in `admin` role is shown read-only/locked (it always has every permission // and can't be edited or deleted). The server enforces the same. See // @parking/shared PERMISSIONS. // // JOBS (venue-modules.md §"Permissions matrix", move 2): each EFFECTIVE module brings // named permission bundles ("Booth operator", "Wash operator", "Merchant") offered as // one-click chips above the grid — a chip adds/removes its bundle; the grid stays the // fine-tune + enforcement layer. The editor LINTS the result (warnings, never blocks): // "mixes desks" (may open more than one till) and "partial job" (holds a module's read // permission but not the rest of its job — a desk that can look but not act). // // A role REMEMBERS the jobs it follows (chips on at save, or bundles fully present). When // a later release grows a job, the role shows as "behind" it — in the list (with a // one-click re-apply) and in the editor — instead of silently falling short the way the // wash operator's price list did (2026-09-06). Every save is signed on the ledger. /** Group "resource:action" permissions by resource for the grid rows. */ function groupByResource(perms: Permission[]): Record { const out: Record = {}; for (const p of perms) { const resource = p.split(":")[0]!; (out[resource] ??= []).push(p); } return out; } export function RolesManager({ user }: { user: SessionUser | null }) { const { t } = useTranslation(); const qc = useQueryClient(); const rolesQ = useQuery({ queryKey: ["roles"], queryFn: fetchRoles }); const canCreate = can(user, "role:create"); const canUpdate = can(user, "role:update"); const canDelete = can(user, "role:delete"); const catalog = rolesQ.data?.catalog ?? []; const roles = rolesQ.data?.roles ?? []; const grouped = useMemo(() => groupByResource(catalog), [catalog]); const [error, setError] = useState(null); const [editing, setEditing] = useState(null); const invalidate = () => { void qc.invalidateQueries({ queryKey: ["roles"] }); void qc.invalidateQueries({ queryKey: ["users"] }); }; const onError = (e: unknown) => setError(e instanceof ApiError ? e.message : (e as Error).message); return (

{t("roles.title")}

{canCreate && ( )}
{error &&
{error}
} setEditing(null)} title={editing && editing !== "new" ? t("roles.editTitle") : t("roles.new")} width="max-w-2xl" > {editing && ( setEditing(null)} onSubmit={async (v) => { try { if (editing === "new") await createRole(v); else await updateRole(editing.id, v); setEditing(null); invalidate(); } catch (e) { onError(e); } }} /> )}
{roles.map((r) => (
{r.name} {r.builtin && ( {t("roles.builtin")} )} {t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })} {behindOf(r).map((b) => ( {t("roles.behind", { job: t(`jobs.${b.job}`) })} ))}
{canUpdate && !r.builtin && behindOf(r).length > 0 && ( )} {canUpdate && !r.builtin && ( )} {canDelete && !r.builtin && ( )}
))}
); } async function deleteRoleSafe(id: string, ok: () => void, onError: (e: unknown) => void) { try { await deleteRole(id); ok(); } catch (e) { onError(e); } } /** The jobs a role follows that have grown past it (this release's bundles). */ function behindOf(r: ManagedRole): { job: string; missing: Permission[] }[] { const has = new Set(r.permissions); return jobsBehind(r.jobs ?? [], (p) => has.has(p)); } /** Re-apply = add what the followed jobs now carry. Nothing is removed; the save is * signed like any other role edit. */ async function reapply(r: ManagedRole, ok: () => void, onError: (e: unknown) => void) { const missing = behindOf(r).flatMap((b) => b.missing); try { await updateRole(r.id, { permissions: [...new Set([...r.permissions, ...missing])] }); ok(); } catch (e) { onError(e); } } /** The jobs the composer offers: every effective module's, in registry order. */ function jobsFor(effective: readonly ModuleId[]): { module: ModuleId; job: JobPreset }[] { return MODULES.filter((m) => effective.includes(m.id)).flatMap((m) => m.jobs.map((job) => ({ module: m.id, job }))); } /** Composer lints — warnings about what the admin just composed. */ function lintRole(perms: Set, jobs: Set, effective: readonly ModuleId[]): { key: string; vars?: Record }[] { const has = (p: Permission) => perms.has(p); const out: { key: string; vars?: Record }[] = []; // Behind a job it follows: the bundle grew (a newer release) past what the role holds. for (const b of jobsBehind([...jobs], has)) out.push({ key: "roles.lintJobBehind", vars: { job: b.job, missing: b.missing.join(", ") } }); // Mixes desks: may OPEN more than one till. const workable: TillId[] = tillsFor(effective, has, "shift"); if (workable.length > 1) out.push({ key: "roles.lintMixedTills", vars: { tills: workable.join(", ") } }); // Partial job: holds a module's till-read (or a job's first permission) but not the // rest of that job's OWN-resource permissions (a booth job also carries core // permissions a supervisor legitimately leaves out — those don't count). for (const { module, job } of jobsFor(effective)) { const m = MODULES.find((x) => x.id === module)!; const anchor = m.tillGuards?.read ?? job.permissions[0]; if (!anchor || !has(anchor)) continue; const own = job.permissions.filter((p) => !has(p) && m.resources.some((r) => p.startsWith(`${r}:`))); if (own.length > 0) out.push({ key: "roles.lintPartialJob", vars: { job: job.id, missing: own.join(", ") } }); } return out; } function RoleEditor({ role, grouped, effective, onCancel, onSubmit, }: { role: ManagedRole | null; grouped: Record; effective: readonly ModuleId[]; onCancel: () => void; onSubmit: (v: { name: string; permissions: Permission[]; jobs: string[] }) => void; }) { const { t } = useTranslation(); const [name, setName] = useState(role?.name ?? ""); const [perms, setPerms] = useState>(new Set(role?.permissions ?? [])); // The jobs this role follows: what was remembered, plus (at save) any bundle that is // fully present — so a role composed before jobs were remembered picks them up. const [jobIds, setJobIds] = useState>(new Set(role?.jobs ?? [])); const toggle = (p: Permission) => setPerms((prev) => { const next = new Set(prev); next.has(p) ? next.delete(p) : next.add(p); return next; }); const jobs = useMemo(() => jobsFor(effective), [effective]); const complete = (job: JobPreset) => job.permissions.every((p) => perms.has(p)); const jobOn = (job: JobPreset) => jobIds.has(job.id) || complete(job); const toggleJob = (job: JobPreset) => { const on = jobOn(job); setJobIds((prev) => { const next = new Set(prev); on ? next.delete(job.id) : next.add(job.id); return next; }); setPerms((prev) => { const next = new Set(prev); if (on) for (const p of job.permissions) next.delete(p); else for (const p of job.permissions) next.add(p); return next; }); }; const lints = useMemo(() => lintRole(perms, jobIds, effective), [perms, jobIds, effective]); const followed = () => jobs.filter(({ job }) => jobIds.has(job.id) || complete(job)).map(({ job }) => job.id); const valid = name.trim().length > 0; return (
{t("roles.name")} setName(e.target.value)} />
{jobs.length > 0 && (
{t("roles.jobs")}
{jobs.map(({ module, job }) => ( ))}
{t("roles.jobsHint")}
)} {lints.length > 0 && (
{lints.map((l) => (
{t(l.key, l.vars)}
))}
)}
{t("roles.permissions")}
{Object.entries(grouped).map(([resource, list]) => (
{resource} {list.map((p) => { const action = p.split(":")[1]!; return ( ); })}
))}
); }