50c18405b6
Closes the permissions-matrix loose ends (venue-modules.md §Permissions matrix): - `role_jobs` (migration 0029): a role stores the manifest jobs it was composed from (chips on at save + any bundle fully present). `jobById` / `jobsBehind` in @parking/shared surface a followed job whose bundle grew past the role in a later release; the roles list shows a "behind <job>" badge with a one-click "Update to job" (the union, nothing removed) and the editor lints it. Never a runtime union: the grid stays the explicit enforcement layer and an update never widens a role without a click. - Every role create/update/delete appends a `config_change` (`role.<id>`, prev/value = name + sorted permissions + jobs, operator); a no-op resave signs nothing. roleRoutes now takes the ledger. - booth-supervisor already carries subscription:*; the stale open note is closed. Tests: routes/roles.test.ts. Wiki: venue-modules status, local-jwt-auth, log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
293 lines
13 KiB
TypeScript
293 lines
13 KiB
TypeScript
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<string, Permission[]> {
|
||
const out: Record<string, Permission[]> = {};
|
||
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<string | null>(null);
|
||
const [editing, setEditing] = useState<ManagedRole | "new" | null>(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 (
|
||
<div className="">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("roles.title")}</h1>
|
||
{canCreate && (
|
||
<button type="button" className="btn btn-go btn-sm" onClick={() => { setEditing("new"); setError(null); }}>
|
||
{t("roles.add")}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[0.75rem] text-term-red">{error}</div>}
|
||
|
||
<Modal
|
||
open={editing != null}
|
||
onClose={() => setEditing(null)}
|
||
title={editing && editing !== "new" ? t("roles.editTitle") : t("roles.new")}
|
||
width="max-w-2xl"
|
||
>
|
||
{editing && (
|
||
<RoleEditor
|
||
role={editing === "new" ? null : editing}
|
||
grouped={grouped}
|
||
effective={(user?.modules ?? []) as ModuleId[]}
|
||
onCancel={() => 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); }
|
||
}}
|
||
/>
|
||
)}
|
||
</Modal>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
{roles.map((r) => (
|
||
<div key={r.id} className="rounded-term border border-term-border bg-term-panel p-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[0.8125rem] font-semibold text-term-text">{r.name}</span>
|
||
{r.builtin && (
|
||
<span className="rounded-term border border-term-amber/50 px-1.5 py-0.5 text-[0.625rem] uppercase tracking-wider text-term-amber">
|
||
{t("roles.builtin")}
|
||
</span>
|
||
)}
|
||
<span className="text-[0.6875rem] text-term-muted">
|
||
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
|
||
</span>
|
||
{behindOf(r).map((b) => (
|
||
<span key={b.job} className="rounded-term border border-term-amber/60 px-1.5 py-0.5 text-[0.625rem] text-term-amber" title={b.missing.join(", ")}>
|
||
{t("roles.behind", { job: t(`jobs.${b.job}`) })}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
{canUpdate && !r.builtin && behindOf(r).length > 0 && (
|
||
<button type="button" className="btn btn-primary btn-sm" title={behindOf(r).flatMap((b) => b.missing).join(", ")}
|
||
onClick={() => reapply(r, invalidate, onError)}>{t("roles.reapply")}</button>
|
||
)}
|
||
{canUpdate && !r.builtin && (
|
||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setEditing(r); setError(null); }}>{t("roles.edit")}</button>
|
||
)}
|
||
{canDelete && !r.builtin && (
|
||
<button type="button" className="btn btn-danger btn-sm"
|
||
onClick={() => { if (confirm(t("roles.confirmDelete", { name: r.name }))) deleteRoleSafe(r.id, invalidate, onError); }}>{t("roles.delete")}</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<Permission>, jobs: Set<string>, effective: readonly ModuleId[]): { key: string; vars?: Record<string, string> }[] {
|
||
const has = (p: Permission) => perms.has(p);
|
||
const out: { key: string; vars?: Record<string, string> }[] = [];
|
||
// 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<string, Permission[]>;
|
||
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<Set<Permission>>(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<Set<string>>(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 (
|
||
<div>
|
||
<div className="field mb-3 w-64">
|
||
<span className="label">{t("roles.name")}</span>
|
||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
|
||
</div>
|
||
|
||
{jobs.length > 0 && (
|
||
<div className="mb-3">
|
||
<div className="label">{t("roles.jobs")}</div>
|
||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||
{jobs.map(({ module, job }) => (
|
||
<button
|
||
key={job.id}
|
||
type="button"
|
||
className={`btn btn-sm ${jobOn(job) ? "btn-primary" : ""}`}
|
||
title={job.permissions.join(", ")}
|
||
onClick={() => toggleJob(job)}
|
||
>
|
||
{t(`jobs.${job.id}`)} <span className="opacity-60">· {t(`modules.name.${module}`)}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<span className="hint">{t("roles.jobsHint")}</span>
|
||
</div>
|
||
)}
|
||
|
||
{lints.length > 0 && (
|
||
<div className="mb-3 rounded-term border border-term-amber/60 px-3 py-2 text-[0.75rem] text-term-amber">
|
||
{lints.map((l) => (
|
||
<div key={l.key + JSON.stringify(l.vars)}>{t(l.key, l.vars)}</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="label">{t("roles.permissions")}</div>
|
||
<div className="mt-1 grid grid-cols-1 gap-1">
|
||
{Object.entries(grouped).map(([resource, list]) => (
|
||
<div key={resource} className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-term-border py-1.5">
|
||
<span className="w-28 shrink-0 text-[0.75rem] font-semibold text-term-text">{resource}</span>
|
||
{list.map((p) => {
|
||
const action = p.split(":")[1]!;
|
||
return (
|
||
<label key={p} className="flex items-center gap-1 text-[0.75rem] text-term-text">
|
||
<input type="checkbox" className="accent-term-amber" checked={perms.has(p)} onChange={() => toggle(p)} />
|
||
{action}
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="mt-3 flex justify-end gap-2">
|
||
<button type="button" className="btn btn-sm" onClick={onCancel}>{t("common.cancel")}</button>
|
||
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={() => onSubmit({ name: name.trim(), permissions: [...perms], jobs: followed() })}>{t("common.save")}</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|