Patchpass V1
Deploy / Build (push) Successful in 28s
Deploy / Test & Lint (push) Failing after 29s
Deploy / Build and Push Docker Image (push) Has been skipped

This commit is contained in:
Space-Banane
2026-07-18 20:14:44 +02:00
commit 7e05dd918c
101 changed files with 15183 additions and 0 deletions
+288
View File
@@ -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>
);
}
+168
View File
@@ -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>
);
}
+153
View File
@@ -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>
);
}
+107
View File
@@ -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&nbsp;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>
);
}
+109
View File
@@ -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>
);
}
+56
View File
@@ -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>
);
}
+94
View File
@@ -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>
);
}
+205
View File
@@ -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>
);
}
+150
View File
@@ -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>
);
}
+276
View File
@@ -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>
);
}