Files
parking_solution/apps/web/src/RolesManager.tsx
T
julian 8444bf34c3 feat(web): pop-out modal forms for setup/subscriptions/roles
Add a reusable ui/Modal (Radix Dialog + terminal chrome) and move the
add/edit forms in the Devices setup, Subscriptions and Roles screens into it,
leaving each list in the page behind the modal. The Devices wizard's per-
category device form is also fully translated (setup.* i18n keys).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 10:08:33 +02:00

179 lines
6.8 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";
import { Modal } from "./ui/Modal.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" 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-[12px] 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}
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-[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" 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); }
}
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>
<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>
<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-[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" 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] })}>{t("common.save")}</button>
</div>
</div>
);
}