feat(permissions): per-desk till guards, jobs in the role composer, permission-scoped live feed; role reassignment applies without re-login
Permissions matrix rethink (wiki/decisions/venue-modules.md §"Permissions matrix", open-questions #16) — the grid stays the enforcement layer: - Move 1: each desk's money is guarded by that desk's own permissions. Manifest tillGuards {read, shift, cash}: booth = shift:read / shift:create / drawer:create (unchanged), carwash = carwash:read / carwash:cash (new). Shift + drawer routes resolve the guard FROM THE TILL (requireTill); a wash role holds no shift:* and cannot touch the booth by construction. Replaces the session:read borrowing (tillPermission). /api/shift/tills lists the role's readable tills with canWork; history/movements without a till filter return the union of readable tills. - Move 2: jobs — manifest permission bundles (booth-operator, booth-supervisor, merchant, wash-operator) as one-click chips in Setup → Roles, with "mixes desks" and "partial job" lints (warnings, never blocks). - Move 3: the live WebSocket admits any watch permission (event/session/device read or a module's feedPermission) and filters every push per role; report:read is the reports screen only. Auth: the token's roleId is only a hint — refreshRole() after every jwtVerify resolves the user's CURRENT role (cached, bumped on role/user writes), so reassigning a user's role applies on the next request and a deleted user's session ends with 401. Tests: till guards + look-only role, feed rules, every job's permissions exist, role reassignment without re-login. 353/353. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -13,12 +13,20 @@ import {
|
||||
type SessionUser,
|
||||
} from "./api.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
import { MODULES, 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).
|
||||
|
||||
/** Group "resource:action" permissions by resource for the grid rows. */
|
||||
function groupByResource(perms: Permission[]): Record<string, Permission[]> {
|
||||
@@ -75,6 +83,7 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
|
||||
<RoleEditor
|
||||
role={editing === "new" ? null : editing}
|
||||
grouped={grouped}
|
||||
effective={(user?.modules ?? []) as ModuleId[]}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSubmit={async (v) => {
|
||||
try {
|
||||
@@ -124,11 +133,37 @@ async function deleteRoleSafe(id: string, ok: () => void, onError: (e: unknown)
|
||||
try { await deleteRole(id); 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>, effective: readonly ModuleId[]): { key: string; vars?: Record<string, string> }[] {
|
||||
const has = (p: Permission) => perms.has(p);
|
||||
const out: { key: string; vars?: Record<string, string> }[] = [];
|
||||
// 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, onCancel, onSubmit,
|
||||
role, grouped, effective, onCancel, onSubmit,
|
||||
}: {
|
||||
role: ManagedRole | null;
|
||||
grouped: Record<string, Permission[]>;
|
||||
effective: readonly ModuleId[];
|
||||
onCancel: () => void;
|
||||
onSubmit: (v: { name: string; permissions: Permission[] }) => void;
|
||||
}) {
|
||||
@@ -141,6 +176,16 @@ function RoleEditor({
|
||||
next.has(p) ? next.delete(p) : next.add(p);
|
||||
return next;
|
||||
});
|
||||
const jobs = useMemo(() => jobsFor(effective), [effective]);
|
||||
const jobOn = (job: JobPreset) => job.permissions.every((p) => perms.has(p));
|
||||
const toggleJob = (job: JobPreset) =>
|
||||
setPerms((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (job.permissions.every((p) => prev.has(p))) 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, effective), [perms, effective]);
|
||||
|
||||
const valid = name.trim().length > 0;
|
||||
|
||||
@@ -151,6 +196,34 @@ function RoleEditor({
|
||||
<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]) => (
|
||||
|
||||
Reference in New Issue
Block a user