feat: quiz
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* /staff — who can use the admin panel, and with what powers. Superadmins only.
|
||||
*
|
||||
* Two tabs: the people (grant, change role, block, revoke) and the roles
|
||||
* (the three system roles, plus custom ones built from the permission
|
||||
* catalog). Every rule that matters — you can't act on yourself, the last
|
||||
* active superadmin can't be removed, custom roles can't hold staff
|
||||
* management — is enforced by the BFF; this page just explains the refusal.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { StaffTab } from "./staff-tab";
|
||||
import { RolesTab } from "./roles-tab";
|
||||
|
||||
export default function StaffPage() {
|
||||
const [tab, setTab] = useState<"staff" | "roles">("staff");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Staff & roles
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Decide who can use this panel and what each role may do.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex gap-1 border-b border-border">
|
||||
{(["staff", "roles"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
className={cn(
|
||||
"-mb-px border-b-2 px-3 py-2 text-sm capitalize transition-colors",
|
||||
tab === t
|
||||
? "border-primary font-medium text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "staff" ? <StaffTab /> : <RolesTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
"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<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">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{r.name}{" "}
|
||||
{r.is_system && (
|
||||
<span className="ml-1 rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
System
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{r.description && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{r.description}
|
||||
</p>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
api,
|
||||
staff,
|
||||
AdminError,
|
||||
type AdminRole,
|
||||
type StaffMember,
|
||||
} from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Table, THead, TH, TR, TD } from "@/components/ui/table";
|
||||
import { useConfirm } from "@/components/confirm";
|
||||
|
||||
const errMsg = (err: unknown, fallback: string) =>
|
||||
err instanceof AdminError ? err.message : fallback;
|
||||
|
||||
export function StaffTab() {
|
||||
const confirm = useConfirm();
|
||||
const [members, setMembers] = useState<StaffMember[] | null>(null);
|
||||
const [roles, setRoles] = useState<AdminRole[]>([]);
|
||||
const [selfId, setSelfId] = useState<string | null>(null);
|
||||
const [handle, setHandle] = useState("");
|
||||
const [grantRole, setGrantRole] = useState("");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [m, r, me] = await Promise.all([
|
||||
staff.list(),
|
||||
staff.roles(),
|
||||
api.me(),
|
||||
]);
|
||||
setMembers(m);
|
||||
setRoles(r);
|
||||
setSelfId(me?.id ?? null);
|
||||
setGrantRole(
|
||||
(prev) =>
|
||||
prev || r.find((x) => x.key === "moderator")?.id || r[0]?.id || "",
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(errMsg(err, "Could not load staff"));
|
||||
setMembers([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const run = async (key: string, fn: () => Promise<unknown>, ok: string) => {
|
||||
setBusy(key);
|
||||
try {
|
||||
await fn();
|
||||
toast.success(ok);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(errMsg(err, "That didn't work"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const grant = () => {
|
||||
const h = handle.trim().replace(/^@/, "");
|
||||
if (!h || !grantRole) return;
|
||||
// Only someone new to the panel gets the welcome email; granting to an
|
||||
// existing staff member just changes their role.
|
||||
const existing = members?.some((m) => m.handle === h);
|
||||
void run(
|
||||
"grant",
|
||||
() => staff.grant(h, grantRole),
|
||||
existing
|
||||
? `@${h}'s role was changed`
|
||||
: `@${h} is now staff — we've emailed them how to log in`,
|
||||
).then(() => setHandle(""));
|
||||
};
|
||||
|
||||
const block = async (m: StaffMember) => {
|
||||
const blocking = !m.blocked_at;
|
||||
if (
|
||||
blocking &&
|
||||
!(await confirm({
|
||||
title: `Block ${m.name}?`,
|
||||
message:
|
||||
"They keep their role but can't use the admin panel until you unblock them.",
|
||||
confirmLabel: "Block",
|
||||
destructive: true,
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void run(
|
||||
m.user_id,
|
||||
() => staff.setBlocked(m.user_id, blocking),
|
||||
blocking ? "Blocked" : "Unblocked",
|
||||
);
|
||||
};
|
||||
|
||||
const revoke = async (m: StaffMember) => {
|
||||
if (
|
||||
!(await confirm({
|
||||
title: `Remove ${m.name} from staff?`,
|
||||
message:
|
||||
"They lose admin access entirely. Their user account is unaffected.",
|
||||
confirmLabel: "Remove",
|
||||
destructive: true,
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void run(m.user_id, () => staff.revoke(m.user_id), "Removed from staff");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="space-y-3 rounded-xl border border-border p-4">
|
||||
<h2 className="text-sm font-medium text-foreground">
|
||||
Add a staff member
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && grant()}
|
||||
placeholder="@handle"
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<select
|
||||
value={grantRole}
|
||||
onChange={(e) => setGrantRole(e.target.value)}
|
||||
className="h-10 rounded-lg border border-input bg-card px-3 text-sm"
|
||||
>
|
||||
{roles.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button onClick={grant} disabled={busy === "grant" || !handle.trim()}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{members === null ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : (
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>Person</TH>
|
||||
<TH>Role</TH>
|
||||
<TH>Status</TH>
|
||||
<TH className="text-right">Actions</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<tbody>
|
||||
{members.map((m) => {
|
||||
const self = m.user_id === selfId;
|
||||
return (
|
||||
<TR key={m.user_id}>
|
||||
<TD>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar
|
||||
src={m.avatar_url ?? null}
|
||||
name={m.name}
|
||||
className="size-8"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{m.name}{" "}
|
||||
{self && (
|
||||
<span className="text-muted-foreground">(you)</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
@{m.handle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TD>
|
||||
<TD>
|
||||
{/* Your own row is read-only: nobody changes their own
|
||||
access, so a superadmin can't demote themselves into
|
||||
a locked-out panel by accident. */}
|
||||
{self ? (
|
||||
<span className="text-sm">{m.role_name}</span>
|
||||
) : (
|
||||
<select
|
||||
value={m.role_id}
|
||||
disabled={busy === m.user_id}
|
||||
onChange={(e) =>
|
||||
void run(
|
||||
m.user_id,
|
||||
() => staff.changeRole(m.user_id, e.target.value),
|
||||
"Role changed",
|
||||
)
|
||||
}
|
||||
className="h-8 rounded-md border border-input bg-card px-2 text-xs"
|
||||
>
|
||||
{roles.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</TD>
|
||||
<TD>
|
||||
{m.blocked_at ? (
|
||||
<span className="rounded bg-destructive/10 px-2 py-0.5 text-xs text-destructive">
|
||||
Blocked
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</TD>
|
||||
<TD className="text-right">
|
||||
{!self && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy === m.user_id}
|
||||
onClick={() => void block(m)}
|
||||
>
|
||||
{m.blocked_at ? "Unblock" : "Block"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy === m.user_id}
|
||||
onClick={() => void revoke(m)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TD>
|
||||
</TR>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user