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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { agents as agentsApi } from "../api/client";
|
||||
import type { Agent } from "../api/types";
|
||||
import { Button, Card, EmptyState, PageHeader, Spinner } from "../components/ui";
|
||||
import { AgentAvatar } from "../components/AgentAvatar";
|
||||
import { AgentFormModal, ConnectModal } from "../components/AgentModals";
|
||||
|
||||
export function AgentsPage() {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Agent | null>(null);
|
||||
const [connectAgent, setConnectAgent] = useState<Agent | null>(null);
|
||||
const [revealedKey, setRevealedKey] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setAgents(await agentsApi.list());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const onSaved = (agent: Agent, created: boolean) => {
|
||||
load();
|
||||
if (created && agent.api_key) {
|
||||
setRevealedKey(agent.api_key);
|
||||
setConnectAgent(agent);
|
||||
}
|
||||
};
|
||||
|
||||
const regenerate = async (agent: Agent) => {
|
||||
if (!confirm(`Regenerate the API key for "${agent.name}"? The old key stops working immediately.`)) return;
|
||||
try {
|
||||
const updated = await agentsApi.regenerateKey(agent.id);
|
||||
toast.success("API key regenerated");
|
||||
setRevealedKey(updated.api_key ?? null);
|
||||
setConnectAgent(updated);
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDisabled = async (agent: Agent) => {
|
||||
try {
|
||||
await agentsApi.setDisabled(agent.id, !agent.disabled);
|
||||
toast.success(agent.disabled ? "Agent enabled" : "Agent disabled");
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed");
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (agent: Agent) => {
|
||||
if (!confirm(`Delete "${agent.name}"? All its requests will be permanently deleted.`)) return;
|
||||
try {
|
||||
await agentsApi.remove(agent.id);
|
||||
toast.success("Agent deleted");
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Agents"
|
||||
subtitle="Each agent has its own API key. You may own up to 5 agents."
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
disabled={agents.length >= 5}
|
||||
>
|
||||
+ New agent
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16 text-primary">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="🤖"
|
||||
title="No agents yet"
|
||||
subtitle="Create an agent, then connect it via OpenClaw, MCP, or the REST API."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{agents.map((a) => (
|
||||
<Card key={a.id} className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AgentAvatar name={a.name} iconUrl={a.icon_url} size={44} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate font-semibold text-text">{a.name}</h3>
|
||||
{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="mt-0.5 line-clamp-2 text-xs text-muted">{a.description}</p>}
|
||||
<p className="mt-1 text-xs text-faint">
|
||||
{a.pending_count ?? 0}/{a.max_pending_requests} pending ·{" "}
|
||||
<code>{a.api_key_masked}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={() => setConnectAgent(a)}>
|
||||
Connect
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEditing(a);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => regenerate(a)}>
|
||||
Regenerate key
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => toggleDisabled(a)}>
|
||||
{a.disabled ? "Enable" : "Disable"}
|
||||
</Button>
|
||||
<Button variant="ghost" className="text-rejected" onClick={() => remove(a)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AgentFormModal
|
||||
open={formOpen}
|
||||
onClose={() => setFormOpen(false)}
|
||||
agent={editing}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
{connectAgent && (
|
||||
<ConnectModal
|
||||
open={!!connectAgent}
|
||||
onClose={() => {
|
||||
setConnectAgent(null);
|
||||
setRevealedKey(null);
|
||||
}}
|
||||
agent={connectAgent}
|
||||
revealedKey={revealedKey}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { requests as requestsApi, agents as agentsApi } from "../api/client";
|
||||
import type { Agent, ChangeRequest, RequestState } from "../api/types";
|
||||
import { useNotifications } from "../context/NotificationsContext";
|
||||
import { Card, EmptyState, PageHeader, Spinner } from "../components/ui";
|
||||
import { StateBadge } from "../components/StateBadge";
|
||||
import { AgentAvatar } from "../components/AgentAvatar";
|
||||
import { expiresIn, relativeTime } from "../utils";
|
||||
|
||||
const summaryTiles: { state: RequestState; label: string }[] = [
|
||||
{ state: "PENDING", label: "Pending" },
|
||||
{ state: "CHANGES_REQUESTED", label: "Changes requested" },
|
||||
{ state: "APPROVED", label: "Approved" },
|
||||
{ state: "CONSUMED", label: "Consumed" },
|
||||
];
|
||||
|
||||
export function DashboardPage() {
|
||||
const { requestEventTick } = useNotifications();
|
||||
const [pending, setPending] = useState<ChangeRequest[]>([]);
|
||||
const [counts, setCounts] = useState<Record<string, number>>({});
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [list, summary, agentList] = await Promise.all([
|
||||
requestsApi.list({ state: "PENDING", page_size: 20 }),
|
||||
requestsApi.summary(),
|
||||
agentsApi.list(),
|
||||
]);
|
||||
setPending(list.requests);
|
||||
setCounts(summary.counts);
|
||||
setAgents(agentList);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load, requestEventTick]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-20 text-primary">
|
||||
<Spinner className="h-7 w-7" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
subtitle="Requests awaiting your review, and your connected agents."
|
||||
/>
|
||||
|
||||
<div className="mb-8 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{summaryTiles.map((t) => (
|
||||
<Card key={t.state} className="p-4">
|
||||
<p className="text-3xl font-bold text-text">{counts[t.state] ?? 0}</p>
|
||||
<p className="mt-1 text-xs text-muted">{t.label}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Awaiting review</h2>
|
||||
<Link to="/requests" className="text-sm text-primary hover:underline">
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
{pending.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="🎉"
|
||||
title="You're all caught up"
|
||||
subtitle="No requests are waiting for your review right now."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{pending.map((r) => (
|
||||
<PendingRow key={r.request_id} request={r} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Agents</h2>
|
||||
<Link to="/agents" className="text-sm text-primary hover:underline">
|
||||
Manage →
|
||||
</Link>
|
||||
</div>
|
||||
{agents.length === 0 ? (
|
||||
<EmptyState icon="🤖" title="No agents yet" subtitle="Create an agent to start receiving requests." />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{agents.map((a) => (
|
||||
<Card key={a.id} className="flex items-center gap-3 p-3">
|
||||
<AgentAvatar name={a.name} iconUrl={a.icon_url} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{a.name}</p>
|
||||
<p className="text-xs text-muted">
|
||||
{a.pending_count ?? 0}/{a.max_pending_requests} pending
|
||||
{a.disabled && <span className="ml-1 text-rejected">· disabled</span>}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingRow({ request }: { request: ChangeRequest }) {
|
||||
const exp = expiresIn(request.expires_at);
|
||||
return (
|
||||
<Link to={`/requests/${request.request_id}`}>
|
||||
<Card className="p-4 transition hover:border-border-strong hover:bg-surface-raised/40">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<AgentAvatar name={request.agent?.name ?? "?"} iconUrl={request.agent?.icon_url} size={32} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate font-medium text-text">{request.title}</p>
|
||||
{request.resubmitted && (
|
||||
<span className="animate-pulse-ring rounded-full bg-changes/20 px-2 py-0.5 text-[10px] font-semibold text-changes">
|
||||
UPDATED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted">
|
||||
{request.agent?.name} · {request.changes.length} change
|
||||
{request.changes.length === 1 ? "" : "s"} · {relativeTime(request.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<StateBadge state={request.state} />
|
||||
<span className={`text-[11px] ${exp.urgent ? "text-pending" : "text-faint"}`}>{exp.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
function LegalShell({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-12">
|
||||
<Link to="/" className="mb-6 inline-block text-sm text-muted hover:text-text">
|
||||
← Back
|
||||
</Link>
|
||||
<h1 className="mb-6 text-3xl font-bold tracking-tight">{title}</h1>
|
||||
<div className="space-y-5 text-sm leading-relaxed text-muted [&_h2]:mt-6 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-text">
|
||||
{children}
|
||||
</div>
|
||||
<footer className="mt-12 border-t border-border pt-6 text-xs text-faint">
|
||||
<Link to="/privacy" className="hover:text-muted">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<span className="mx-2">·</span>
|
||||
<Link to="/terms" className="hover:text-muted">
|
||||
Terms of Service
|
||||
</Link>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrivacyPage() {
|
||||
return (
|
||||
<LegalShell title="Privacy Policy">
|
||||
<p>
|
||||
PatchPass is a self-hostable human approval layer for AI agents. This policy explains what data
|
||||
the platform stores and the controls you have over it.
|
||||
</p>
|
||||
<h2>Data we store</h2>
|
||||
<p>
|
||||
We store the account data you provide (username, display name, a bcrypt-hashed password, and an
|
||||
optional TOTP secret if you enable two-factor authentication), the agents you create (name,
|
||||
description, website, icon URL, and an API key), the change requests your agents submit, the
|
||||
decisions you make, and your in-app notifications.
|
||||
</p>
|
||||
<h2>Agent icons</h2>
|
||||
<p>
|
||||
When you set an agent icon URL, the backend fetches it once to verify it points to a valid image
|
||||
(JPG, PNG, or GIF, under 1 MB). The image itself is <strong>not</strong> stored or cached —
|
||||
only the URL you provided is kept.
|
||||
</p>
|
||||
<h2>How your data is used</h2>
|
||||
<p>
|
||||
Data is used solely to operate the approval workflow: routing agent requests to you for review,
|
||||
recording decisions, and delivering notifications. We do not sell data or share it with third
|
||||
parties. Administrators of your instance can view platform data for moderation; access to another
|
||||
user's request payloads is explicitly audited.
|
||||
</p>
|
||||
<h2>Retention</h2>
|
||||
<p>
|
||||
Change requests are retained indefinitely by default. You may enable auto-deletion in Settings to
|
||||
automatically remove requests older than a retention window you choose (minimum 7 days).
|
||||
</p>
|
||||
<h2>Your rights (GDPR)</h2>
|
||||
<p>
|
||||
You can export all of your data as machine-readable JSON at any time from Settings. You can also
|
||||
delete your account, which permanently removes your account and cascades to all your agents,
|
||||
change requests, and notifications. These actions are self-service and take effect immediately.
|
||||
</p>
|
||||
<h2>Security</h2>
|
||||
<p>
|
||||
Passwords are hashed with bcrypt. Approval decisions are signed with HMAC-SHA256 so agents can
|
||||
cryptographically verify a decision was issued by the platform and is bound to the exact reviewed
|
||||
content.
|
||||
</p>
|
||||
</LegalShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function TermsPage() {
|
||||
return (
|
||||
<LegalShell title="Terms of Service">
|
||||
<p>
|
||||
By using this PatchPass instance you agree to these terms. PatchPass is provided as-is, without
|
||||
warranty of any kind.
|
||||
</p>
|
||||
<h2>Acceptable use</h2>
|
||||
<p>
|
||||
You are responsible for the agents you connect and the actions taken on the basis of approvals you
|
||||
grant. Do not use the platform to facilitate unlawful activity or to abuse other users.
|
||||
</p>
|
||||
<h2>Approvals</h2>
|
||||
<p>
|
||||
An approval is an authorization for an agent to proceed with the exact reviewed content. You are
|
||||
responsible for reviewing changes before approving them. A rejection is a hard blocker; requesting
|
||||
changes returns the request to the agent for revision. Approvals are single-use and are consumed by
|
||||
the agent before it applies changes.
|
||||
</p>
|
||||
<h2>Rate limits</h2>
|
||||
<p>
|
||||
To keep the platform usable, agents are limited to 15 requests per hour and a configurable number
|
||||
of simultaneous pending requests, and each human may own up to 5 agents.
|
||||
</p>
|
||||
<h2>Availability</h2>
|
||||
<p>
|
||||
This is self-hosted software. Availability, backups, and data durability are the responsibility of
|
||||
the operator of this instance.
|
||||
</p>
|
||||
<h2>Changes</h2>
|
||||
<p>These terms may be updated by the operator of your instance.</p>
|
||||
</LegalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { auth, ApiError } from "../api/client";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Button, Card, Input } from "../components/ui";
|
||||
|
||||
export function LoginPage() {
|
||||
const { user, setUser, settings } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [totp, setTotp] = useState("");
|
||||
const [needsTotp, setNeedsTotp] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) navigate("/", { replace: true });
|
||||
}, [user, navigate]);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const u = await auth.login(username, password, needsTotp ? totp : undefined);
|
||||
setUser(u);
|
||||
navigate("/", { replace: true });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && (err.data as any)?.totp_required) {
|
||||
setNeedsTotp(true);
|
||||
toast.info("Enter your 2FA code");
|
||||
} else {
|
||||
toast.error(err instanceof Error ? err.message : "Login failed");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthShell title="Welcome back" subtitle="Review changes. Approve intent. Let agents proceed.">
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<Input
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{needsTotp && (
|
||||
<Input
|
||||
label="2FA code"
|
||||
value={totp}
|
||||
onChange={(e) => setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="123456"
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
<Button type="submit" loading={loading} className="w-full">
|
||||
Log in
|
||||
</Button>
|
||||
</form>
|
||||
{settings?.registration_enabled !== false && (
|
||||
<p className="mt-6 text-center text-sm text-muted">
|
||||
No account?{" "}
|
||||
<Link to="/register" className="text-primary hover:underline">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthShell({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mb-3 text-4xl">✅</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Patch<span className="text-primary">Pass</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted">{subtitle}</p>
|
||||
</div>
|
||||
<Card className="p-6">
|
||||
<h2 className="mb-5 text-lg font-semibold">{title}</h2>
|
||||
{children}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNotifications } from "../context/NotificationsContext";
|
||||
import { Button, Card, EmptyState, PageHeader } from "../components/ui";
|
||||
import { relativeTime } from "../utils";
|
||||
|
||||
export function NotificationsPage() {
|
||||
const { items, unreadCount, markRead, markAllRead, clear } = useNotifications();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Notifications"
|
||||
subtitle={unreadCount > 0 ? `${unreadCount} unread` : "You're all caught up"}
|
||||
actions={
|
||||
items.length > 0 ? (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => markAllRead()}>
|
||||
Mark all read
|
||||
</Button>
|
||||
<Button variant="ghost" className="text-rejected" onClick={() => clear()}>
|
||||
Clear all
|
||||
</Button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<EmptyState icon="🔔" title="No notifications" subtitle="Activity from your agents will appear here." />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((n) => (
|
||||
<Card
|
||||
key={n.id}
|
||||
className={`flex cursor-pointer items-start gap-3 p-4 transition hover:bg-surface-raised/40 ${
|
||||
n.read ? "opacity-60" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!n.read) markRead(n.id);
|
||||
if (n.request_id) navigate(`/requests/${n.request_id}`);
|
||||
}}
|
||||
>
|
||||
{!n.read && <span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-text">{n.title}</p>
|
||||
<p className="text-sm text-muted">{n.message}</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-faint">{relativeTime(n.created_at)}</span>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { auth } from "../api/client";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Button, Input } from "../components/ui";
|
||||
import { AuthShell } from "./Login";
|
||||
|
||||
export function RegisterPage() {
|
||||
const { user, setUser, settings } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) navigate("/", { replace: true });
|
||||
}, [user, navigate]);
|
||||
|
||||
const registrationClosed = settings?.registration_enabled === false;
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (password !== confirm) return toast.error("Passwords do not match");
|
||||
if (password.length < 8) return toast.error("Password must be at least 8 characters");
|
||||
setLoading(true);
|
||||
try {
|
||||
const u = await auth.register(username, displayName || username, password);
|
||||
setUser(u);
|
||||
toast.success("Account created");
|
||||
navigate("/", { replace: true });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Registration failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthShell title="Create your account" subtitle="A human approval layer for AI agents.">
|
||||
{registrationClosed ? (
|
||||
<div className="text-center">
|
||||
<p className="text-muted">Registration is currently disabled by the administrator.</p>
|
||||
<Link to="/login" className="mt-4 inline-block text-primary hover:underline">
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<Input
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
hint="Letters, numbers, dots, dashes, underscores. Case-insensitive."
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Display name (optional)"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Confirm password"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" loading={loading} className="w-full">
|
||||
Create account
|
||||
</Button>
|
||||
</form>
|
||||
<p className="mt-6 text-center text-sm text-muted">
|
||||
Already have an account?{" "}
|
||||
<Link to="/login" className="text-primary hover:underline">
|
||||
Log in
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { requests as requestsApi, ApiError } from "../api/client";
|
||||
import type { ChangeRequest } from "../api/types";
|
||||
import { useNotifications } from "../context/NotificationsContext";
|
||||
import { Button, Card, Spinner } from "../components/ui";
|
||||
import { StateBadge } from "../components/StateBadge";
|
||||
import { ChangeList } from "../components/ChangeRenderer";
|
||||
import { AgentAvatar } from "../components/AgentAvatar";
|
||||
import { DecisionModal } from "../components/DecisionModal";
|
||||
import { expiresIn, formatDateTime, relativeTime } from "../utils";
|
||||
|
||||
type Decision = "APPROVE" | "REJECT" | "REQUEST_CHANGES";
|
||||
|
||||
export function RequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { requestEventTick } = useNotifications();
|
||||
const [request, setRequest] = useState<ChangeRequest | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [decision, setDecision] = useState<Decision | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
setRequest(await requestsApi.get(id));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Failed to load");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load, requestEventTick]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-20 text-primary">
|
||||
<Spinner className="h-7 w-7" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error || !request) {
|
||||
return (
|
||||
<Card className="p-10 text-center">
|
||||
<p className="text-muted">{error ?? "Not found"}</p>
|
||||
<Link to="/requests" className="mt-4 inline-block text-primary hover:underline">
|
||||
← Back to requests
|
||||
</Link>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const exp = expiresIn(request.expires_at);
|
||||
const canDecide = request.state === "PENDING";
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<Link to="/requests" className="mb-4 inline-block text-sm text-muted hover:text-text">
|
||||
← Back to requests
|
||||
</Link>
|
||||
|
||||
{request.resubmitted && request.state === "PENDING" && (
|
||||
<div className="mb-4 flex items-center gap-3 rounded-xl border border-changes/40 bg-changes/10 px-4 py-3">
|
||||
<span className="animate-pulse-ring rounded-full bg-changes/25 px-2 py-0.5 text-xs font-bold text-changes">
|
||||
UPDATED
|
||||
</span>
|
||||
<p className="text-sm text-text">
|
||||
This request was revised by the agent after you requested changes (update #{request.update_count}).
|
||||
Please re-review the changes below.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{request.title}</h1>
|
||||
<StateBadge state={request.state} />
|
||||
</div>
|
||||
{request.description && <p className="mt-2 text-muted">{request.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{request.comment && (
|
||||
<div className="mb-5 rounded-xl border border-border bg-surface-raised/40 p-4">
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
{request.state === "CHANGES_REQUESTED" ? "Changes requested" : "Reviewer note"}
|
||||
</p>
|
||||
<p className="text-sm text-text">{request.comment}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-faint">
|
||||
Proposed changes ({request.changes.length})
|
||||
</h2>
|
||||
<ChangeList changes={request.changes} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{canDecide && (
|
||||
<Card className="space-y-2 p-4">
|
||||
<p className="mb-1 text-sm font-semibold">Your decision</p>
|
||||
<Button variant="success" className="w-full" onClick={() => setDecision("APPROVE")}>
|
||||
Approve
|
||||
</Button>
|
||||
<Button variant="primary" className="w-full" onClick={() => setDecision("REQUEST_CHANGES")}>
|
||||
Request changes
|
||||
</Button>
|
||||
<Button variant="danger" className="w-full" onClick={() => setDecision("REJECT")}>
|
||||
Reject
|
||||
</Button>
|
||||
<p className={`pt-1 text-center text-xs ${exp.urgent ? "text-pending" : "text-faint"}`}>
|
||||
{exp.text}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="p-4">
|
||||
<p className="mb-3 text-sm font-semibold">Agent</p>
|
||||
{request.agent ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<AgentAvatar name={request.agent.name} iconUrl={request.agent.icon_url} />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{request.agent.name}</p>
|
||||
{request.agent.website && (
|
||||
<a
|
||||
href={request.agent.website}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="truncate text-xs text-primary hover:underline"
|
||||
>
|
||||
{request.agent.website}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted">Unknown</p>
|
||||
)}
|
||||
{request.agent?.description && (
|
||||
<p className="mt-2 text-xs text-muted">{request.agent.description}</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="space-y-2 p-4 text-xs">
|
||||
<Row label="Request ID" value={<code className="text-[11px]">{request.request_id}</code>} />
|
||||
<Row
|
||||
label="Content hash"
|
||||
value={<code className="text-[11px] break-all text-muted">{request.content_hash}</code>}
|
||||
/>
|
||||
<Row label="Created" value={relativeTime(request.created_at)} />
|
||||
<Row label="Expires" value={formatDateTime(request.expires_at)} />
|
||||
{request.decided_at && <Row label="Decided" value={formatDateTime(request.decided_at)} />}
|
||||
{request.consumed_at && <Row label="Consumed" value={formatDateTime(request.consumed_at)} />}
|
||||
{request.update_count > 0 && <Row label="Updates" value={String(request.update_count)} />}
|
||||
</Card>
|
||||
|
||||
{request.metadata && Object.keys(request.metadata).length > 0 && (
|
||||
<Card className="p-4">
|
||||
<p className="mb-2 text-sm font-semibold">Metadata</p>
|
||||
<div className="space-y-1 text-xs">
|
||||
{Object.entries(request.metadata).map(([k, v]) => (
|
||||
<Row key={k} label={k} value={<span className="text-muted">{String(v)}</span>} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{request.receipt && (
|
||||
<Card className="border-approved/30 bg-approved/5 p-4">
|
||||
<p className="mb-2 flex items-center gap-2 text-sm font-semibold text-approved">
|
||||
<span>🔏</span> Signed receipt
|
||||
</p>
|
||||
<div className="space-y-1 text-xs">
|
||||
<Row label="Decision" value={request.receipt.payload.decision} />
|
||||
<Row label="Algorithm" value={request.receipt.algorithm} />
|
||||
<Row
|
||||
label="Signature"
|
||||
value={<code className="text-[11px] break-all text-muted">{request.receipt.signature}</code>}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DecisionModal request={request} decision={decision} onClose={() => setDecision(null)} onDone={setRequest} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="shrink-0 text-faint">{label}</span>
|
||||
<span className="text-right text-text">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { requests as requestsApi, agents as agentsApi } from "../api/client";
|
||||
import type { Agent, ChangeRequest, RequestState } from "../api/types";
|
||||
import { useNotifications } from "../context/NotificationsContext";
|
||||
import { Card, EmptyState, PageHeader, Spinner, Button } from "../components/ui";
|
||||
import { StateBadge } from "../components/StateBadge";
|
||||
import { AgentAvatar } from "../components/AgentAvatar";
|
||||
import { relativeTime } from "../utils";
|
||||
|
||||
const STATES: RequestState[] = [
|
||||
"PENDING",
|
||||
"CHANGES_REQUESTED",
|
||||
"APPROVED",
|
||||
"REJECTED",
|
||||
"CONSUMED",
|
||||
"EXPIRED",
|
||||
"CANCELLED",
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export function RequestsPage() {
|
||||
const { requestEventTick } = useNotifications();
|
||||
const [items, setItems] = useState<ChangeRequest[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [stateFilter, setStateFilter] = useState<RequestState | "">("");
|
||||
const [agentFilter, setAgentFilter] = useState<number | "">("");
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
agentsApi.list().then(setAgents).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await requestsApi.list({
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
state: stateFilter || undefined,
|
||||
agent_id: agentFilter || undefined,
|
||||
});
|
||||
setItems(data.requests);
|
||||
setTotal(data.total);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, stateFilter, agentFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load, requestEventTick]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Requests" subtitle="Full history of change requests submitted by your agents." />
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setStateFilter("");
|
||||
setPage(1);
|
||||
}}
|
||||
className={`rounded-lg px-3 py-1.5 text-sm ${stateFilter === "" ? "bg-surface-raised text-text" : "text-muted hover:text-text"}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{STATES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => {
|
||||
setStateFilter(s);
|
||||
setPage(1);
|
||||
}}
|
||||
className={`rounded-lg px-3 py-1.5 text-sm ${stateFilter === s ? "bg-surface-raised text-text" : "text-muted hover:text-text"}`}
|
||||
>
|
||||
{s.replace("_", " ").toLowerCase()}
|
||||
</button>
|
||||
))}
|
||||
<select
|
||||
value={agentFilter}
|
||||
onChange={(e) => {
|
||||
setAgentFilter(e.target.value ? Number(e.target.value) : "");
|
||||
setPage(1);
|
||||
}}
|
||||
className="ml-auto rounded-lg border border-border-strong bg-bg px-3 py-1.5 text-sm text-text outline-none"
|
||||
>
|
||||
<option value="">All agents</option>
|
||||
{agents.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16 text-primary">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState icon="📭" title="No requests" subtitle="Nothing matches this filter." />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((r) => (
|
||||
<Link key={r.request_id} to={`/requests/${r.request_id}`}>
|
||||
<Card className="flex items-center gap-3 p-3.5 transition hover:border-border-strong hover:bg-surface-raised/40">
|
||||
<AgentAvatar name={r.agent?.name ?? "?"} iconUrl={r.agent?.icon_url} size={32} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate font-medium text-text">{r.title}</p>
|
||||
{r.resubmitted && r.state === "PENDING" && (
|
||||
<span className="rounded-full bg-changes/20 px-1.5 py-0.5 text-[10px] font-semibold text-changes">
|
||||
UPDATED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted">
|
||||
{r.agent?.name} · {r.changes.length} change{r.changes.length === 1 ? "" : "s"} ·{" "}
|
||||
{relativeTime(r.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<StateBadge state={r.state} />
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { account, auth as authApi } from "../api/client";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Button, Card, Input, PageHeader, Toggle } from "../components/ui";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { CodeBlock } from "../components/CodeBlock";
|
||||
|
||||
function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Card className="p-5">
|
||||
<h2 className="text-base font-semibold text-text">{title}</h2>
|
||||
{description && <p className="mb-4 mt-0.5 text-sm text-muted">{description}</p>}
|
||||
<div className={description ? "" : "mt-4"}>{children}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const { user, refresh } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Profile
|
||||
const [displayName, setDisplayName] = useState(user?.display_name ?? "");
|
||||
// Password
|
||||
const [curPw, setCurPw] = useState("");
|
||||
const [newPw, setNewPw] = useState("");
|
||||
// Auto-delete
|
||||
const [autoDelete, setAutoDelete] = useState(user?.auto_delete_enabled ?? false);
|
||||
const [autoDeleteDays, setAutoDeleteDays] = useState(user?.auto_delete_days ?? 30);
|
||||
// 2FA
|
||||
const [setup, setSetup] = useState<{ secret: string; otpauth_url: string } | null>(null);
|
||||
const [totp, setTotp] = useState("");
|
||||
const [disable2faOpen, setDisable2faOpen] = useState(false);
|
||||
const [disablePw, setDisablePw] = useState("");
|
||||
// Delete account
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deletePw, setDeletePw] = useState("");
|
||||
|
||||
const saveProfile = async () => {
|
||||
try {
|
||||
await account.updateProfile(displayName);
|
||||
toast.success("Profile updated");
|
||||
refresh();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
}
|
||||
};
|
||||
|
||||
const savePassword = async () => {
|
||||
try {
|
||||
await account.changePassword(curPw, newPw);
|
||||
toast.success("Password changed");
|
||||
setCurPw("");
|
||||
setNewPw("");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
}
|
||||
};
|
||||
|
||||
const saveAutoDelete = async (enabled: boolean, days: number) => {
|
||||
try {
|
||||
const res = await account.updateSettings({ auto_delete_enabled: enabled, auto_delete_days: days });
|
||||
setAutoDelete(res.auto_delete_enabled);
|
||||
setAutoDeleteDays(res.auto_delete_days);
|
||||
toast.success("Settings saved");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
}
|
||||
};
|
||||
|
||||
const begin2fa = async () => {
|
||||
try {
|
||||
setSetup(await authApi.setup2fa());
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
}
|
||||
};
|
||||
const confirm2fa = async () => {
|
||||
try {
|
||||
await authApi.enable2fa(totp);
|
||||
toast.success("2FA enabled");
|
||||
setSetup(null);
|
||||
setTotp("");
|
||||
refresh();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Invalid code");
|
||||
}
|
||||
};
|
||||
const disable2fa = async () => {
|
||||
try {
|
||||
await authApi.disable2fa(disablePw);
|
||||
toast.success("2FA disabled");
|
||||
setDisable2faOpen(false);
|
||||
setDisablePw("");
|
||||
refresh();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAccount = async () => {
|
||||
try {
|
||||
await account.deleteAccount(deletePw);
|
||||
toast.success("Account deleted");
|
||||
navigate("/login", { replace: true });
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Settings" subtitle="Manage your account, security, and data." />
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<Section title="Profile">
|
||||
<div className="space-y-3">
|
||||
<Input label="Username" value={user?.username ?? ""} disabled />
|
||||
<Input label="Display name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
||||
<Button onClick={saveProfile}>Save profile</Button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Password">
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
label="Current password"
|
||||
type="password"
|
||||
value={curPw}
|
||||
onChange={(e) => setCurPw(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="New password"
|
||||
type="password"
|
||||
value={newPw}
|
||||
onChange={(e) => setNewPw(e.target.value)}
|
||||
hint="At least 8 characters."
|
||||
/>
|
||||
<Button onClick={savePassword} disabled={!curPw || newPw.length < 8}>
|
||||
Change password
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Two-factor authentication" description="Add a TOTP authenticator app for extra security.">
|
||||
{user?.totp_enabled ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-approved">✓ 2FA is enabled</span>
|
||||
<Button variant="danger" onClick={() => setDisable2faOpen(true)}>
|
||||
Disable
|
||||
</Button>
|
||||
</div>
|
||||
) : setup ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted">
|
||||
Add this secret to your authenticator app, then enter the 6-digit code.
|
||||
</p>
|
||||
<CodeBlock code={setup.secret} />
|
||||
<Input
|
||||
label="Authenticator code"
|
||||
value={totp}
|
||||
onChange={(e) => setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="123456"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={confirm2fa} disabled={totp.length !== 6}>
|
||||
Enable 2FA
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setSetup(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={begin2fa}>Set up 2FA</Button>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Auto-delete old requests"
|
||||
description="Disabled by default. When on, requests older than the retention window are permanently deleted (minimum 7 days)."
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Toggle checked={autoDelete} onChange={(v) => saveAutoDelete(v, autoDeleteDays)} label="Enable auto-delete" />
|
||||
{autoDelete && (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between text-sm">
|
||||
<span className="text-muted">Retention window</span>
|
||||
<span className="font-medium text-text">{autoDeleteDays} days</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={7}
|
||||
max={90}
|
||||
value={autoDeleteDays}
|
||||
onChange={(e) => setAutoDeleteDays(Number(e.target.value))}
|
||||
onMouseUp={() => saveAutoDelete(true, autoDeleteDays)}
|
||||
onTouchEnd={() => saveAutoDelete(true, autoDeleteDays)}
|
||||
className="w-full accent-[var(--color-primary)]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Export your data" description="Download all your data (account, agents, requests, notifications) as JSON.">
|
||||
<a href={account.exportUrl} download>
|
||||
<Button variant="secondary">Download export</Button>
|
||||
</a>
|
||||
</Section>
|
||||
|
||||
<Section title="Danger zone" description="Permanently delete your account and all associated data. This cannot be undone.">
|
||||
<Button variant="danger" onClick={() => setDeleteOpen(true)}>
|
||||
Delete account
|
||||
</Button>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={disable2faOpen}
|
||||
onClose={() => setDisable2faOpen(false)}
|
||||
title="Disable 2FA"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setDisable2faOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={disable2fa}>
|
||||
Disable
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label="Confirm your password"
|
||||
type="password"
|
||||
value={disablePw}
|
||||
onChange={(e) => setDisablePw(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
title="Delete account"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setDeleteOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={deleteAccount} disabled={!deletePw}>
|
||||
Permanently delete
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted">
|
||||
This deletes your account and cascades to all your agents, change requests, and notifications.
|
||||
This action is irreversible.
|
||||
</p>
|
||||
<Input
|
||||
label="Confirm your password"
|
||||
type="password"
|
||||
value={deletePw}
|
||||
onChange={(e) => setDeletePw(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user