266 lines
8.4 KiB
TypeScript
266 lines
8.4 KiB
TypeScript
"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";
|
|
import { Clamp } from "@/components/ui/clamp";
|
|
|
|
const errMsg = (err: unknown, fallback: string) =>
|
|
err instanceof AdminError ? err.message : fallback;
|
|
|
|
type Draft = {
|
|
id?: string;
|
|
name: string;
|
|
description: string;
|
|
permissions: Set<string>;
|
|
};
|
|
|
|
export function RolesTab() {
|
|
const confirm = useConfirm();
|
|
const [roles, setRoles] = useState<AdminRole[] | null>(null);
|
|
const [catalog, setCatalog] = useState<Permission[]>([]);
|
|
const [draft, setDraft] = useState<Draft | null>(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<string, Permission[]>();
|
|
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 <p className="text-sm text-muted-foreground">Loading…</p>;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex justify-end">
|
|
<Button
|
|
onClick={() =>
|
|
setDraft({ name: "", description: "", permissions: new Set() })
|
|
}
|
|
>
|
|
New role
|
|
</Button>
|
|
</div>
|
|
|
|
<ul className="space-y-3">
|
|
{roles.map((r) => (
|
|
<li key={r.id} className="rounded-xl border border-border p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<Clamp className="text-sm font-medium text-foreground">
|
|
{r.name}
|
|
</Clamp>
|
|
{r.is_system && (
|
|
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground whitespace-nowrap">
|
|
System
|
|
</span>
|
|
)}
|
|
</div>
|
|
{r.description && (
|
|
<Clamp lines={2} className="mt-0.5 text-xs text-muted-foreground">
|
|
{r.description}
|
|
</Clamp>
|
|
)}
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{r.member_count} {r.member_count === 1 ? "member" : "members"}
|
|
</p>
|
|
</div>
|
|
{!r.is_system && (
|
|
<div className="flex shrink-0 gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() =>
|
|
setDraft({
|
|
id: r.id,
|
|
name: r.name,
|
|
description: r.description,
|
|
permissions: new Set(r.permissions),
|
|
})
|
|
}
|
|
>
|
|
Edit
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => void remove(r)}
|
|
>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
{r.permissions.includes("*") ? (
|
|
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
|
|
Everything, including managing staff
|
|
</span>
|
|
) : (
|
|
r.permissions.map((p) => (
|
|
<span
|
|
key={p}
|
|
className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
|
|
>
|
|
{labelOf.get(p) ?? p}
|
|
</span>
|
|
))
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
<Modal
|
|
open={draft !== null}
|
|
onClose={() => setDraft(null)}
|
|
title={draft?.id ? "Edit role" : "New role"}
|
|
>
|
|
{draft && (
|
|
<div className="space-y-4">
|
|
<Input
|
|
value={draft.name}
|
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
|
placeholder="Role name — e.g. Quiz editor"
|
|
maxLength={80}
|
|
/>
|
|
<Input
|
|
value={draft.description}
|
|
onChange={(e) =>
|
|
setDraft({ ...draft, description: e.target.value })
|
|
}
|
|
placeholder="What is this role for? (optional)"
|
|
/>
|
|
<div className="max-h-[50vh] space-y-4 overflow-y-auto pr-1">
|
|
{groups.map(([group, perms]) => (
|
|
<fieldset key={group}>
|
|
<legend className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
{group}
|
|
</legend>
|
|
<div className="space-y-1">
|
|
{perms.map((p) => (
|
|
<label
|
|
key={p.key}
|
|
className="flex items-center gap-2 text-sm"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={draft.permissions.has(p.key)}
|
|
onChange={() => toggle(p.key)}
|
|
className="accent-primary"
|
|
/>
|
|
{p.label}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Managing staff and roles is reserved for superadmins and
|
|
can't be added to a custom role.
|
|
</p>
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="ghost" onClick={() => setDraft(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => void save()}
|
|
disabled={
|
|
saving || !draft.name.trim() || draft.permissions.size === 0
|
|
}
|
|
>
|
|
{saving ? "Saving…" : "Save role"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|