261 lines
8.4 KiB
TypeScript
261 lines
8.4 KiB
TypeScript
"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";
|
|
import { Clamp } from "@/components/ui/clamp";
|
|
|
|
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)}
|
|
// A <select> sizes itself to its longest option; a long custom
|
|
// role name must not widen the form past the screen.
|
|
className="h-10 min-w-0 max-w-full flex-1 truncate rounded-lg border border-input bg-card px-3 text-sm sm:max-w-xs sm:flex-none"
|
|
>
|
|
{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 min-w-0 items-center gap-3">
|
|
<Avatar
|
|
src={m.avatar_url ?? null}
|
|
name={m.name}
|
|
className="size-8"
|
|
/>
|
|
<div className="min-w-0">
|
|
<Clamp
|
|
className="text-sm font-medium text-foreground"
|
|
title={m.name}
|
|
>
|
|
{m.name}{" "}
|
|
{self && (
|
|
<span className="text-muted-foreground">(you)</span>
|
|
)}
|
|
</Clamp>
|
|
<Clamp className="text-xs text-muted-foreground">
|
|
{`@${m.handle}`}
|
|
</Clamp>
|
|
</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 ? (
|
|
<Clamp className="text-sm">{m.role_name}</Clamp>
|
|
) : (
|
|
<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 w-full max-w-[14rem] truncate 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 whitespace-nowrap">
|
|
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>
|
|
);
|
|
}
|