289 lines
8.6 KiB
TypeScript
289 lines
8.6 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { toast } from "react-toastify";
|
|
import { admin } from "../api/client";
|
|
import type { AdminUser, AuditLog, Agent, GlobalSettings } from "../api/types";
|
|
import { useAuth } from "../context/AuthContext";
|
|
import { Button, Card, PageHeader, Spinner, Toggle } from "../components/ui";
|
|
import { relativeTime } from "../utils";
|
|
|
|
type Tab = "users" | "agents" | "settings" | "audit";
|
|
|
|
export function AdminPage() {
|
|
const [tab, setTab] = useState<Tab>("users");
|
|
const tabs: { id: Tab; label: string }[] = [
|
|
{ id: "users", label: "Users" },
|
|
{ id: "agents", label: "Agents" },
|
|
{ id: "settings", label: "Global settings" },
|
|
{ id: "audit", label: "Audit logs" },
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader title="Admin" subtitle="Platform administration." />
|
|
<div className="mb-6 flex gap-1 overflow-x-auto border-b border-border">
|
|
{tabs.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => setTab(t.id)}
|
|
className={`shrink-0 px-4 py-2 text-sm font-medium transition ${
|
|
tab === t.id ? "border-b-2 border-accent text-text" : "text-muted hover:text-text"
|
|
}`}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{tab === "users" && <UsersTab />}
|
|
{tab === "agents" && <AgentsTab />}
|
|
{tab === "settings" && <SettingsTab />}
|
|
{tab === "audit" && <AuditTab />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UsersTab() {
|
|
const { user: me } = useAuth();
|
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setUsers(await admin.users());
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
const setRole = async (u: AdminUser, role: "ADMIN" | "USER") => {
|
|
try {
|
|
await admin.updateUser(u.id, { role });
|
|
toast.success("Role updated");
|
|
load();
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Failed");
|
|
}
|
|
};
|
|
const toggleDisabled = async (u: AdminUser) => {
|
|
try {
|
|
await admin.updateUser(u.id, { disabled: !u.disabled });
|
|
load();
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Failed");
|
|
}
|
|
};
|
|
const remove = async (u: AdminUser) => {
|
|
if (!confirm(`Delete user "${u.username}" and all their data?`)) return;
|
|
try {
|
|
await admin.deleteUser(u.id);
|
|
toast.success("User deleted");
|
|
load();
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Failed");
|
|
}
|
|
};
|
|
|
|
if (loading) return <Loader />;
|
|
return (
|
|
<div className="space-y-2">
|
|
{users.map((u) => (
|
|
<Card key={u.id} className="flex flex-wrap items-center gap-3 p-4">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-text">{u.display_name}</span>
|
|
<span className="text-xs text-faint">@{u.username}</span>
|
|
{u.role === "ADMIN" && (
|
|
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-semibold text-accent">
|
|
ADMIN
|
|
</span>
|
|
)}
|
|
{u.disabled && (
|
|
<span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
|
|
DISABLED
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted">
|
|
{u.agent_count} agents · {u.request_count} requests · joined {relativeTime(u.created_at)}
|
|
</p>
|
|
</div>
|
|
{u.id !== me?.id && (
|
|
<div className="flex w-full flex-wrap gap-2 sm:w-auto sm:flex-nowrap">
|
|
<Button variant="ghost" onClick={() => setRole(u, u.role === "ADMIN" ? "USER" : "ADMIN")}>
|
|
{u.role === "ADMIN" ? "Demote" : "Promote"}
|
|
</Button>
|
|
<Button variant="ghost" onClick={() => toggleDisabled(u)}>
|
|
{u.disabled ? "Enable" : "Disable"}
|
|
</Button>
|
|
<Button variant="ghost" className="text-rejected" onClick={() => remove(u)}>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AgentsTab() {
|
|
const [agents, setAgents] = useState<(Agent & { owner: { id: number; username: string } })[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setAgents(await admin.agents());
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
const toggle = async (a: Agent) => {
|
|
try {
|
|
await admin.setAgentDisabled(a.id, !a.disabled);
|
|
load();
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Failed");
|
|
}
|
|
};
|
|
const remove = async (a: Agent) => {
|
|
if (!confirm(`Delete agent "${a.name}"?`)) return;
|
|
try {
|
|
await admin.deleteAgent(a.id);
|
|
toast.success("Agent deleted");
|
|
load();
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Failed");
|
|
}
|
|
};
|
|
|
|
if (loading) return <Loader />;
|
|
return (
|
|
<div className="space-y-2">
|
|
{agents.map((a) => (
|
|
<Card key={a.id} className="flex flex-wrap items-center gap-3 p-4">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-text">{a.name}</span>
|
|
<span className="text-xs text-faint">by @{a.owner.username}</span>
|
|
{a.disabled && (
|
|
<span className="rounded-full bg-rejected/15 px-2 py-0.5 text-[10px] font-semibold text-rejected">
|
|
DISABLED
|
|
</span>
|
|
)}
|
|
</div>
|
|
{a.description && <p className="text-xs text-muted">{a.description}</p>}
|
|
</div>
|
|
<div className="flex w-full flex-wrap gap-2 sm:w-auto sm:flex-nowrap">
|
|
<Button variant="ghost" onClick={() => toggle(a)}>
|
|
{a.disabled ? "Enable" : "Disable"}
|
|
</Button>
|
|
<Button variant="ghost" className="text-rejected" onClick={() => remove(a)}>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SettingsTab() {
|
|
const [settings, setSettings] = useState<GlobalSettings | null>(null);
|
|
useEffect(() => {
|
|
admin.settings().then(setSettings).catch(() => {});
|
|
}, []);
|
|
|
|
const update = async (patch: Partial<GlobalSettings>) => {
|
|
try {
|
|
const res = await admin.updateSettings(patch);
|
|
setSettings(res);
|
|
toast.success("Settings updated");
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Failed");
|
|
}
|
|
};
|
|
|
|
if (!settings) return <Loader />;
|
|
return (
|
|
<div className="space-y-3">
|
|
<Card className="flex flex-wrap items-center justify-between gap-4 p-4 sm:p-5">
|
|
<div>
|
|
<p className="font-medium text-text">Enable registration</p>
|
|
<p className="text-sm text-muted">Allow new humans to create accounts.</p>
|
|
</div>
|
|
<Toggle checked={settings.registration_enabled} onChange={(v) => update({ registration_enabled: v })} />
|
|
</Card>
|
|
<Card className="flex flex-wrap items-center justify-between gap-4 p-4 sm:p-5">
|
|
<div>
|
|
<p className="font-medium text-text">Enable requests</p>
|
|
<p className="text-sm text-muted">Allow agents to submit new change requests platform-wide.</p>
|
|
</div>
|
|
<Toggle checked={settings.requests_enabled} onChange={(v) => update({ requests_enabled: v })} />
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AuditTab() {
|
|
const [logs, setLogs] = useState<AuditLog[]>([]);
|
|
const [page, setPage] = useState(1);
|
|
const [total, setTotal] = useState(0);
|
|
const [loading, setLoading] = useState(true);
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
admin
|
|
.auditLogs(page)
|
|
.then((d) => {
|
|
setLogs(d.logs);
|
|
setTotal(d.total);
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}, [page]);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(total / 30));
|
|
if (loading) return <Loader />;
|
|
return (
|
|
<div>
|
|
<div className="space-y-1.5">
|
|
{logs.map((l) => (
|
|
<Card key={l.id} className="flex flex-wrap items-center gap-2 p-3 text-sm sm:flex-nowrap sm:gap-3">
|
|
<code className="rounded bg-surface-raised px-2 py-0.5 text-xs text-accent">{l.action}</code>
|
|
<span className="min-w-0 flex-1 truncate text-muted">
|
|
{l.actor ? `@${l.actor.username}` : "system"}
|
|
{l.target_type && ` → ${l.target_type}:${l.target_id}`}
|
|
{l.detail && ` · ${l.detail}`}
|
|
</span>
|
|
<span className="w-full text-xs text-faint sm:w-auto sm:shrink-0">{relativeTime(l.created_at)}</span>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
{totalPages > 1 && (
|
|
<div className="mt-4 flex items-center justify-center gap-3">
|
|
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
|
Previous
|
|
</Button>
|
|
<span className="text-sm text-muted">
|
|
Page {page} of {totalPages}
|
|
</span>
|
|
<Button variant="secondary" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Loader() {
|
|
return (
|
|
<div className="flex justify-center py-16 text-primary">
|
|
<Spinner className="h-6 w-6" />
|
|
</div>
|
|
);
|
|
}
|