Files
parking_solution/apps/web/src/RolesManager.tsx
T
julian d0841c8601 feat(auth): dynamic RBAC — composable roles + resource×CRUD permissions
Replace the hardcoded role enum (admin/operator/cashier/readonly, checked
literally as requireRole("admin",...) across ~15 routes) with dynamic RBAC:
roles are DATA, route guards check a PERMISSION.

@parking/shared defines a code-defined grid: RESOURCES (user/role/tariff/
subscription/site/device/shift/payment/session/event/report) × Action
(create/read/update/delete + domain verbs void/cash) -> PERMISSIONS
(resource:action, e.g. tariff:update, payment:create, event:void).

DB: new roles + role_permissions tables; users.role enum -> role_id FK;
migration 0007_rbac (create tables, seed the builtin admin role + all 26
perms, seed operator/cashier/readonly composable roles matching old
behaviour, rebuild users to swap the column copying all rows).

auth.ts: JWT payload role -> roleId; permissionsFor(roleId) with an
in-memory cache + bumpPermsCache(); requirePermission(...perms) preHandler;
requireAuth for /me & /language; initAuth(db) wires the resolver once. Every
route guard mapped to a permission; device ingress (devices/qr-reader) stays
auth-free by design. New routes/users.ts (user:* CRUD, bcrypt 12, last-admin
guard) + routes/roles.ts (role:* CRUD, builtin-protected, perms validated
against the grid, cache bump on write). auth/me + /login return
{roleId, roleName, permissions, language}. seed-admin -> roleId:'admin'.

Frontend: SessionUser carries permissions + can() helper; router nav/route
guards gate by permission (requirePerm replaces adminOnly); SiteSettings
edit gated by site:update; new UsersManager + RolesManager (permission
checkbox grid; admin role locked); i18n nav.users/roles + blocks (sq+en).

Decisions: one role per user; protected built-in admin (no-lockout: the last
admin can't be deleted/downgraded); JWT carries roleId, perms resolved
per-request so role edits apply immediately (no re-login).

Verified: full build green; 20-assertion inject test passes (cashier 403s on
tariff publish + user list, admin passes, granting a perm applies on the next
request, last-admin + builtin-role protections return 409); migration 0007
applied to a copy of the live DB (incl WAL/shm) — existing admin maps to
role_id='admin', all rows preserved. Append-only event chain untouched
(event:void gates appending a void, not a delete).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 01:19:28 +02:00

180 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
// 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.
/** 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="mx-auto max-w-4xl">
<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" onClick={() => { setEditing("new"); setError(null); }}
className="rounded-term border border-term-green bg-term-green/10 px-3 py-1 text-[12px] uppercase tracking-wider text-term-green">
{t("roles.add")}
</button>
)}
</div>
{error && <div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{error}</div>}
{editing && (
<RoleEditor
role={editing === "new" ? null : editing}
grouped={grouped}
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); }
}}
/>
)}
<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-[13px] 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-[10px] uppercase tracking-wider text-term-amber">
{t("roles.builtin")}
</span>
)}
<span className="text-[11px] text-term-muted">
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
</span>
</div>
<div className="flex gap-2">
{canUpdate && !r.builtin && (
<button type="button" onClick={() => { setEditing(r); setError(null); }}
className="text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">{t("roles.edit")}</button>
)}
{canDelete && !r.builtin && (
<button type="button"
onClick={() => { if (confirm(t("roles.confirmDelete", { name: r.name }))) deleteRoleSafe(r.id, invalidate, onError); }}
className="text-[11px] uppercase tracking-wider text-term-red hover:text-term-text">{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); }
}
function RoleEditor({
role, grouped, onCancel, onSubmit,
}: {
role: ManagedRole | null;
grouped: Record<string, Permission[]>;
onCancel: () => void;
onSubmit: (v: { name: string; permissions: Permission[] }) => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState(role?.name ?? "");
const [perms, setPerms] = useState<Set<Permission>>(new Set(role?.permissions ?? []));
const toggle = (p: Permission) =>
setPerms((prev) => {
const next = new Set(prev);
next.has(p) ? next.delete(p) : next.add(p);
return next;
});
const valid = name.trim().length > 0;
return (
<div className="mb-3 rounded-term border border-term-border bg-term-panel p-3">
<div className="mb-2 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
{role ? t("roles.editTitle") : t("roles.new")}
</div>
<label className="mb-3 block text-[11px] text-term-muted">
{t("roles.name")}
<input value={name} onChange={(e) => setName(e.target.value)}
className="mt-1 w-64 rounded-term border border-term-border bg-term-panel-2 px-2 py-1 text-[12px] text-term-text" />
</label>
<div className="text-[11px] uppercase tracking-wider text-term-muted">{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-[12px] 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-[12px] text-term-text">
<input type="checkbox" checked={perms.has(p)} onChange={() => toggle(p)} />
{action}
</label>
);
})}
</div>
))}
</div>
<div className="mt-3 flex justify-end gap-2">
<button type="button" onClick={onCancel}
className="rounded-term border border-term-border px-3 py-1 text-[12px] uppercase tracking-wider text-term-muted">{t("common.cancel")}</button>
<button type="button" disabled={!valid} onClick={() => onSubmit({ name: name.trim(), permissions: [...perms] })}
className="rounded-term border border-term-green bg-term-green/10 px-3 py-1 text-[12px] uppercase tracking-wider text-term-green disabled:opacity-40">{t("common.save")}</button>
</div>
</div>
);
}