Files
patchpass/UI/src/pages/Admin.tsx
T

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>
);
}