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
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ApiError,
|
||||
can,
|
||||
createUser,
|
||||
deleteUser,
|
||||
fetchRoles,
|
||||
fetchUsers,
|
||||
resetUserPassword,
|
||||
updateUser,
|
||||
type ManagedRole,
|
||||
type ManagedUser,
|
||||
type SessionUser,
|
||||
} from "./api.js";
|
||||
|
||||
// User management (admin). List users, create one (username + password + role),
|
||||
// change a user's role, reset a password, delete. The server enforces the same
|
||||
// permissions and the no-lockout rule (the last admin can't be removed). See
|
||||
// wiki/entities/local-jwt-auth.md.
|
||||
|
||||
export function UsersManager({ user }: { user: SessionUser | null }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const usersQ = useQuery({ queryKey: ["users"], queryFn: fetchUsers });
|
||||
const rolesQ = useQuery({ queryKey: ["roles"], queryFn: fetchRoles });
|
||||
|
||||
const canCreate = can(user, "user:create");
|
||||
const canUpdate = can(user, "user:update");
|
||||
const canDelete = can(user, "user:delete");
|
||||
|
||||
const roles: ManagedRole[] = rolesQ.data?.roles ?? [];
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const invalidate = () => 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-3xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("users.title")}</h1>
|
||||
{canCreate && roles.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAdding(true); 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("users.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>}
|
||||
|
||||
{adding && (
|
||||
<UserForm
|
||||
roles={roles}
|
||||
onCancel={() => setAdding(false)}
|
||||
onSubmit={async (v) => {
|
||||
try {
|
||||
await createUser({ username: v.username, password: v.password, roleId: v.roleId });
|
||||
setAdding(false);
|
||||
invalidate();
|
||||
} catch (e) { onError(e); }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-term border border-term-border">
|
||||
<table className="w-full text-[12px]">
|
||||
<thead className="bg-term-panel-2 text-[11px] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
<th className="px-3 py-1.5 text-left">{t("users.username")}</th>
|
||||
<th className="px-3 py-1.5 text-left">{t("users.role")}</th>
|
||||
<th className="px-3 py-1.5 text-right">{t("common.none")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(usersQ.data?.users ?? []).map((u) => (
|
||||
<UserRow
|
||||
key={u.id}
|
||||
u={u}
|
||||
roles={roles}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
onChanged={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
{usersQ.data?.users.length === 0 && (
|
||||
<tr><td colSpan={3} className="px-3 py-3 text-term-muted">{t("users.none")}</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({
|
||||
u, roles, canUpdate, canDelete, onChanged, onError,
|
||||
}: {
|
||||
u: ManagedUser;
|
||||
roles: ManagedRole[];
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
onChanged: () => void;
|
||||
onError: (e: unknown) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [pw, setPw] = useState("");
|
||||
|
||||
const roleMut = useMutation({
|
||||
mutationFn: (roleId: string) => updateUser(u.id, { roleId }),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
});
|
||||
const pwMut = useMutation({
|
||||
mutationFn: () => resetUserPassword(u.id, pw),
|
||||
onSuccess: () => { setResetting(false); setPw(""); },
|
||||
onError,
|
||||
});
|
||||
const delMut = useMutation({
|
||||
mutationFn: () => deleteUser(u.id),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
});
|
||||
|
||||
return (
|
||||
<tr className="border-t border-term-border">
|
||||
<td className="px-3 py-1.5">{u.username}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{canUpdate ? (
|
||||
<select
|
||||
value={u.roleId}
|
||||
onChange={(e) => roleMut.mutate(e.target.value)}
|
||||
className="rounded-term border border-term-border bg-term-panel px-2 py-0.5 text-[12px]"
|
||||
>
|
||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
u.roleName
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{canUpdate && !resetting && (
|
||||
<button type="button" onClick={() => setResetting(true)}
|
||||
className="text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">
|
||||
{t("users.resetPassword")}
|
||||
</button>
|
||||
)}
|
||||
{canUpdate && resetting && (
|
||||
<span className="flex items-center gap-1">
|
||||
<input
|
||||
type="password" value={pw} autoFocus
|
||||
onChange={(e) => setPw(e.target.value)}
|
||||
placeholder={t("users.newPassword")}
|
||||
className="w-32 rounded-term border border-term-border bg-term-panel px-2 py-0.5 text-[12px]"
|
||||
/>
|
||||
<button type="button" disabled={pw.length < 8 || pwMut.isPending} onClick={() => pwMut.mutate()}
|
||||
className="text-[11px] uppercase tracking-wider text-term-green disabled:opacity-40">
|
||||
{t("common.save")}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setResetting(false); setPw(""); }}
|
||||
className="text-[11px] uppercase tracking-wider text-term-muted">✕</button>
|
||||
</span>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (confirm(t("users.confirmDelete", { name: u.username }))) delMut.mutate(); }}
|
||||
className="text-[11px] uppercase tracking-wider text-term-red hover:text-term-text"
|
||||
>
|
||||
{t("users.delete")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function UserForm({
|
||||
roles, onCancel, onSubmit,
|
||||
}: {
|
||||
roles: ManagedRole[];
|
||||
onCancel: () => void;
|
||||
onSubmit: (v: { username: string; password: string; roleId: string }) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [roleId, setRoleId] = useState(roles[0]?.id ?? "");
|
||||
const valid = username.trim().length > 0 && password.length >= 8 && roleId;
|
||||
|
||||
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">{t("users.new")}</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<label className="text-[11px] text-term-muted">
|
||||
{t("users.username")}
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)}
|
||||
className="mt-1 w-full rounded-term border border-term-border bg-term-panel-2 px-2 py-1 text-[12px] text-term-text" />
|
||||
</label>
|
||||
<label className="text-[11px] text-term-muted">
|
||||
{t("users.password")}
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 w-full rounded-term border border-term-border bg-term-panel-2 px-2 py-1 text-[12px] text-term-text" />
|
||||
</label>
|
||||
<label className="text-[11px] text-term-muted">
|
||||
{t("users.role")}
|
||||
<select value={roleId} onChange={(e) => setRoleId(e.target.value)}
|
||||
className="mt-1 w-full rounded-term border border-term-border bg-term-panel-2 px-2 py-1 text-[12px] text-term-text">
|
||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-term-muted">{t("users.passwordHint")}</div>
|
||||
<div className="mt-2 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({ username: username.trim(), password, roleId })}
|
||||
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>
|
||||
);
|
||||
}
|
||||
+60
-2
@@ -44,16 +44,26 @@ export class ApiError extends Error {
|
||||
|
||||
// --- Auth -----------------------------------------------------------------
|
||||
|
||||
export type Role = "admin" | "operator" | "cashier" | "readonly";
|
||||
export type Lang = "sq" | "en";
|
||||
/** A `resource:action` permission string (the server is the source of truth for
|
||||
* the full grid; the role composer fetches it via /api/roles). */
|
||||
export type Permission = string;
|
||||
export interface SessionUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role: Role;
|
||||
roleId: string;
|
||||
roleName: string;
|
||||
/** The permissions this user's role grants — the UI gates nav/routes on these. */
|
||||
permissions: Permission[];
|
||||
/** Preferred UI language (loaded from the server on login). */
|
||||
language: Lang;
|
||||
}
|
||||
|
||||
/** Does this session grant the permission? Central authz check for the SPA. */
|
||||
export function can(user: SessionUser | null, perm: Permission): boolean {
|
||||
return !!user && user.permissions.includes(perm);
|
||||
}
|
||||
|
||||
export function login(username: string, password: string): Promise<SessionUser> {
|
||||
return apiFetch<SessionUser>("/api/auth/login", {
|
||||
method: "POST",
|
||||
@@ -80,6 +90,54 @@ export async function fetchMe(): Promise<SessionUser | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- User & role management (RBAC) ----------------------------------------
|
||||
|
||||
export interface ManagedUser {
|
||||
id: string;
|
||||
username: string;
|
||||
roleId: string;
|
||||
roleName: string;
|
||||
language: Lang;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface ManagedRole {
|
||||
id: string;
|
||||
name: string;
|
||||
builtin: boolean;
|
||||
permissions: Permission[];
|
||||
userCount: number;
|
||||
}
|
||||
|
||||
export function fetchUsers(): Promise<{ users: ManagedUser[] }> {
|
||||
return apiFetch("/api/users");
|
||||
}
|
||||
export function createUser(body: { username: string; password: string; roleId: string }): Promise<ManagedUser> {
|
||||
return apiFetch("/api/users", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function updateUser(id: string, body: { username?: string; roleId?: string }): Promise<ManagedUser> {
|
||||
return apiFetch(`/api/users/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||||
}
|
||||
export function resetUserPassword(id: string, password: string): Promise<{ ok: boolean }> {
|
||||
return apiFetch(`/api/users/${id}/password`, { method: "PUT", body: JSON.stringify({ password }) });
|
||||
}
|
||||
export function deleteUser(id: string): Promise<{ ok: boolean }> {
|
||||
return apiFetch(`/api/users/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/** Roles + the full permission catalog (for the composer checkbox grid). */
|
||||
export function fetchRoles(): Promise<{ catalog: Permission[]; roles: ManagedRole[] }> {
|
||||
return apiFetch("/api/roles");
|
||||
}
|
||||
export function createRole(body: { name: string; permissions: Permission[] }): Promise<ManagedRole> {
|
||||
return apiFetch("/api/roles", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function updateRole(id: string, body: { name?: string; permissions?: Permission[] }): Promise<ManagedRole> {
|
||||
return apiFetch(`/api/roles/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||||
}
|
||||
export function deleteRole(id: string): Promise<{ ok: boolean }> {
|
||||
return apiFetch(`/api/roles/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// --- Device setup ---------------------------------------------------------
|
||||
|
||||
export interface ConfigField {
|
||||
|
||||
@@ -26,6 +26,8 @@ export const en: Catalog = {
|
||||
tariff: "Tariff",
|
||||
subscriptions: "Subscriptions",
|
||||
site: "Site",
|
||||
users: "Users",
|
||||
roles: "Roles",
|
||||
},
|
||||
status: {
|
||||
live: "LIVE",
|
||||
@@ -227,6 +229,36 @@ export const en: Catalog = {
|
||||
fieldPhone: "Phone",
|
||||
fieldEmail: "Email",
|
||||
},
|
||||
users: {
|
||||
title: "Users",
|
||||
add: "+ Add user",
|
||||
new: "New user",
|
||||
none: "No users.",
|
||||
username: "Username",
|
||||
password: "Password",
|
||||
passwordHint: "At least 8 characters.",
|
||||
newPassword: "new password",
|
||||
role: "Role",
|
||||
resetPassword: "Reset password",
|
||||
delete: "Delete",
|
||||
confirmDelete: "Delete user \"{{name}}\"?",
|
||||
},
|
||||
roles: {
|
||||
title: "Roles",
|
||||
add: "+ Add role",
|
||||
new: "New role",
|
||||
editTitle: "Edit role",
|
||||
name: "Name",
|
||||
permissions: "Permissions",
|
||||
builtin: "built-in",
|
||||
edit: "Edit",
|
||||
delete: "Delete",
|
||||
confirmDelete: "Delete role \"{{name}}\"?",
|
||||
permCount_one: "{{count}} permission",
|
||||
permCount_other: "{{count}} permissions",
|
||||
userCount_one: "{{count}} user",
|
||||
userCount_other: "{{count}} users",
|
||||
},
|
||||
shift: {
|
||||
label: "Shift:",
|
||||
open: "open",
|
||||
|
||||
@@ -26,6 +26,8 @@ export const sq = {
|
||||
tariff: "Tarifa",
|
||||
subscriptions: "Abonimet",
|
||||
site: "Vendi",
|
||||
users: "Përdoruesit",
|
||||
roles: "Rolet",
|
||||
},
|
||||
status: {
|
||||
live: "LIVE",
|
||||
@@ -229,6 +231,36 @@ export const sq = {
|
||||
fieldPhone: "Telefoni",
|
||||
fieldEmail: "Email",
|
||||
},
|
||||
users: {
|
||||
title: "Përdoruesit",
|
||||
add: "+ Shto përdorues",
|
||||
new: "Përdorues i ri",
|
||||
none: "Asnjë përdorues.",
|
||||
username: "Përdoruesi",
|
||||
password: "Fjalëkalimi",
|
||||
passwordHint: "Të paktën 8 karaktere.",
|
||||
newPassword: "fjalëkalim i ri",
|
||||
role: "Roli",
|
||||
resetPassword: "Rivendos fjalëkalimin",
|
||||
delete: "Fshi",
|
||||
confirmDelete: "Të fshihet përdoruesi \"{{name}}\"?",
|
||||
},
|
||||
roles: {
|
||||
title: "Rolet",
|
||||
add: "+ Shto rol",
|
||||
new: "Rol i ri",
|
||||
editTitle: "Ndrysho rolin",
|
||||
name: "Emri",
|
||||
permissions: "Lejet",
|
||||
builtin: "i integruar",
|
||||
edit: "Ndrysho",
|
||||
delete: "Fshi",
|
||||
confirmDelete: "Të fshihet roli \"{{name}}\"?",
|
||||
permCount_one: "{{count}} leje",
|
||||
permCount_other: "{{count}} leje",
|
||||
userCount_one: "{{count}} përdorues",
|
||||
userCount_other: "{{count}} përdorues",
|
||||
},
|
||||
shift: {
|
||||
label: "Turni:",
|
||||
open: "hapur",
|
||||
|
||||
+51
-17
@@ -9,8 +9,8 @@ import {
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Lang, SessionUser } from "./api.js";
|
||||
import { closeShift, logout, openShift, setLanguagePref } from "./api.js";
|
||||
import type { Lang, Permission, SessionUser } from "./api.js";
|
||||
import { can, closeShift, logout, openShift, setLanguagePref } from "./api.js";
|
||||
import { qk, queryClient } from "./lib/query.js";
|
||||
import { setLanguage } from "./lib/i18n/index.js";
|
||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||
@@ -23,6 +23,8 @@ import { TariffComposer } from "./TariffComposer.js";
|
||||
import { SubscriptionManager } from "./SubscriptionManager.js";
|
||||
import { ShiftControl } from "./ShiftControl.js";
|
||||
import { SiteSettings } from "./SiteSettings.js";
|
||||
import { UsersManager } from "./UsersManager.js";
|
||||
import { RolesManager } from "./RolesManager.js";
|
||||
|
||||
// Code-based TanStack Router (no file-based codegen — the app is small enough that
|
||||
// an explicit tree is clearer). The router context carries the signed-in user and
|
||||
@@ -154,7 +156,9 @@ function RootLayout() {
|
||||
const { t } = useTranslation();
|
||||
// One app-wide WebSocket for the live feed (booth + any live widget).
|
||||
useLiveFeed();
|
||||
const isAdmin = user?.role === "admin";
|
||||
// Nav is gated by PERMISSION, not role — a tab shows iff the user's role grants
|
||||
// the permission its screen needs (the route guards enforce the same server-side).
|
||||
const show = (perm: Permission) => can(user, perm);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-term-bg text-term-text">
|
||||
@@ -163,17 +167,19 @@ function RootLayout() {
|
||||
<nav className="flex items-center gap-1">
|
||||
<NavLink to="/booth" label={t("nav.booth")} />
|
||||
<NavLink to="/shift" label={t("nav.shift")} />
|
||||
{isAdmin && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||
{isAdmin && <NavLink to="/tariff" label={t("nav.tariff")} />}
|
||||
{isAdmin && <NavLink to="/subscriptions" label={t("nav.subscriptions")} />}
|
||||
{isAdmin && <NavLink to="/site" label={t("nav.site")} />}
|
||||
{show("site:update") && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||
{show("tariff:read") && <NavLink to="/tariff" label={t("nav.tariff")} />}
|
||||
{show("subscription:read") && <NavLink to="/subscriptions" label={t("nav.subscriptions")} />}
|
||||
{show("site:read") && <NavLink to="/site" label={t("nav.site")} />}
|
||||
{show("user:read") && <NavLink to="/users" label={t("nav.users")} />}
|
||||
{show("role:read") && <NavLink to="/roles" label={t("nav.roles")} />}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{user && <ShiftButton />}
|
||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||
<StatusDot />
|
||||
<span className="text-[11px] text-term-muted">
|
||||
{user?.username} · {user?.role}
|
||||
{user?.username} · {user?.roleName}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -215,38 +221,64 @@ const shiftRoute = createRoute({
|
||||
path: "/shift",
|
||||
component: function ShiftRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
return <ShiftControl isAdmin={user?.role === "admin"} />;
|
||||
// "Admin" actions on the shift screen (drawer cash) need shift:cash.
|
||||
return <ShiftControl isAdmin={can(user, "shift:cash")} />;
|
||||
},
|
||||
});
|
||||
|
||||
/** Guard: admin-only routes redirect non-admins back to the booth. */
|
||||
function adminOnly(ctx: RouterContext) {
|
||||
if (ctx.user?.role !== "admin") throw redirect({ to: "/booth" });
|
||||
/** Guard factory: a route requiring `perm` redirects a user who lacks it back to
|
||||
* the booth. Same permission the server enforces — defence in depth, not the only
|
||||
* gate. */
|
||||
function requirePerm(perm: Permission) {
|
||||
return (ctx: RouterContext) => {
|
||||
if (!can(ctx.user, perm)) throw redirect({ to: "/booth" });
|
||||
};
|
||||
}
|
||||
|
||||
const setupRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/setup",
|
||||
beforeLoad: ({ context }) => adminOnly(context),
|
||||
beforeLoad: ({ context }) => requirePerm("site:update")(context),
|
||||
component: () => <SetupWizard />,
|
||||
});
|
||||
const tariffRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/tariff",
|
||||
beforeLoad: ({ context }) => adminOnly(context),
|
||||
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
||||
component: () => <TariffComposer />,
|
||||
});
|
||||
const subscriptionsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/subscriptions",
|
||||
beforeLoad: ({ context }) => adminOnly(context),
|
||||
beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
|
||||
component: () => <SubscriptionManager />,
|
||||
});
|
||||
const siteRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/site",
|
||||
beforeLoad: ({ context }) => adminOnly(context),
|
||||
component: () => <SiteSettings canEdit={true} />,
|
||||
beforeLoad: ({ context }) => requirePerm("site:read")(context),
|
||||
component: function SiteRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
return <SiteSettings canEdit={can(user, "site:update")} />;
|
||||
},
|
||||
});
|
||||
const usersRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/users",
|
||||
beforeLoad: ({ context }) => requirePerm("user:read")(context),
|
||||
component: function UsersRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
return <UsersManager user={user} />;
|
||||
},
|
||||
});
|
||||
const rolesRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/roles",
|
||||
beforeLoad: ({ context }) => requirePerm("role:read")(context),
|
||||
component: function RolesRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
return <RolesManager user={user} />;
|
||||
},
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
@@ -257,6 +289,8 @@ const routeTree = rootRoute.addChildren([
|
||||
tariffRoute,
|
||||
subscriptionsRoute,
|
||||
siteRoute,
|
||||
usersRoute,
|
||||
rolesRoute,
|
||||
]);
|
||||
|
||||
export const router = createRouter({
|
||||
|
||||
Reference in New Issue
Block a user