"use client"; /** * Roles: the three system roles (read-only) and custom ones. * * A custom role is any subset of the permission catalog. Staff management * itself isn't in the catalog, so it can't be granted here — that stays with * superadmins. */ import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { staff, AdminError, type AdminRole, type Permission } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Modal } from "@/components/ui/modal"; import { useConfirm } from "@/components/confirm"; const errMsg = (err: unknown, fallback: string) => err instanceof AdminError ? err.message : fallback; type Draft = { id?: string; name: string; description: string; permissions: Set; }; export function RolesTab() { const confirm = useConfirm(); const [roles, setRoles] = useState(null); const [catalog, setCatalog] = useState([]); const [draft, setDraft] = useState(null); const [saving, setSaving] = useState(false); const load = useCallback(async () => { try { const [r, c] = await Promise.all([staff.roles(), staff.catalog()]); setRoles(r); setCatalog(c); } catch (err) { toast.error(errMsg(err, "Could not load roles")); setRoles([]); } }, []); useEffect(() => { void load(); }, [load]); // Catalog grouped for the editor and for describing system roles. const groups = useMemo(() => { const m = new Map(); for (const p of catalog) m.set(p.group, [...(m.get(p.group) ?? []), p]); return [...m.entries()]; }, [catalog]); const labelOf = useMemo( () => new Map(catalog.map((p) => [p.key, p.label])), [catalog], ); const save = async () => { if (!draft) return; setSaving(true); const input = { name: draft.name.trim(), description: draft.description.trim(), permissions: [...draft.permissions], }; try { if (draft.id) await staff.updateRole(draft.id, input); else await staff.createRole(input); toast.success(draft.id ? "Role updated" : "Role created"); setDraft(null); await load(); } catch (err) { toast.error(errMsg(err, "Could not save the role")); } finally { setSaving(false); } }; const remove = async (r: AdminRole) => { if ( !(await confirm({ title: `Delete the ${r.name} role?`, message: "Only roles nobody holds can be deleted.", confirmLabel: "Delete", destructive: true, })) ) { return; } try { await staff.deleteRole(r.id); toast.success("Role deleted"); await load(); } catch (err) { toast.error(errMsg(err, "Could not delete the role")); } }; const toggle = (key: string) => setDraft((d) => { if (!d) return d; const next = new Set(d.permissions); if (next.has(key)) next.delete(key); else next.add(key); return { ...d, permissions: next }; }); if (roles === null) return

Loading…

; return (
    {roles.map((r) => (
  • {r.name}{" "} {r.is_system && ( System )}

    {r.description && (

    {r.description}

    )}

    {r.member_count} {r.member_count === 1 ? "member" : "members"}

    {!r.is_system && (
    )}
    {r.permissions.includes("*") ? ( Everything, including managing staff ) : ( r.permissions.map((p) => ( {labelOf.get(p) ?? p} )) )}
  • ))}
setDraft(null)} title={draft?.id ? "Edit role" : "New role"} > {draft && (
setDraft({ ...draft, name: e.target.value })} placeholder="Role name — e.g. Quiz editor" maxLength={80} /> setDraft({ ...draft, description: e.target.value }) } placeholder="What is this role for? (optional)" />
{groups.map(([group, perms]) => (
{group}
{perms.map((p) => ( ))}
))}

Managing staff and roles is reserved for superadmins and can't be added to a custom role.

)}
); }