Patchpass V1
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
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 border-b border-border">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`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 gap-2">
|
||||
<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 gap-2">
|
||||
<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 items-center justify-between 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 items-center justify-between 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 items-center gap-3 p-3 text-sm">
|
||||
<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="shrink-0 text-xs text-faint">{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user