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
+150
View File
@@ -0,0 +1,150 @@
import type {
Agent,
AdminUser,
AuditLog,
ChangeRequest,
GlobalSettings,
Notification,
RequestState,
User,
} from "./types";
export class ApiError extends Error {
status: number;
data?: unknown;
constructor(message: string, status: number, data?: unknown) {
super(message);
this.status = status;
this.data = data;
}
}
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
credentials: "include",
headers:
opts.body && !(opts.headers && "Content-Type" in opts.headers)
? { "Content-Type": "application/json", ...(opts.headers || {}) }
: opts.headers,
...opts,
});
let body: any = null;
try {
body = await res.json();
} catch {
/* no body */
}
if (!res.ok || body?.status === "FAILED") {
throw new ApiError(body?.message || `Request failed (${res.status})`, res.status, body?.data);
}
return (body?.data ?? body) as T;
}
// ── Auth ──────────────────────────────────────────────────────────────────────
export const auth = {
me: () => api<User>("/api/auth/me"),
login: (username: string, password: string, totp?: string) =>
api<User>("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password, totp }) }),
register: (username: string, display_name: string, password: string) =>
api<User>("/api/auth/register", {
method: "POST",
body: JSON.stringify({ username, display_name, password }),
}),
logout: () => api("/api/auth/logout", { method: "POST" }),
setup2fa: () => api<{ secret: string; otpauth_url: string }>("/api/auth/2fa/setup", { method: "POST" }),
enable2fa: (totp: string) => api("/api/auth/2fa/enable", { method: "POST", body: JSON.stringify({ totp }) }),
disable2fa: (password: string) =>
api("/api/auth/2fa/disable", { method: "POST", body: JSON.stringify({ password }) }),
};
// ── Account ────────────────────────────────────────────────────────────────────
export const account = {
updateProfile: (display_name: string) =>
api("/api/account/profile", { method: "PATCH", body: JSON.stringify({ display_name }) }),
changePassword: (current_password: string, new_password: string) =>
api("/api/account/password", {
method: "POST",
body: JSON.stringify({ current_password, new_password }),
}),
updateSettings: (settings: { auto_delete_enabled?: boolean; auto_delete_days?: number }) =>
api<{ auto_delete_enabled: boolean; auto_delete_days: number }>("/api/account/settings", {
method: "PATCH",
body: JSON.stringify(settings),
}),
deleteAccount: (password: string) =>
api("/api/account", { method: "DELETE", body: JSON.stringify({ password, confirm: "DELETE" }) }),
exportUrl: "/api/account/export",
};
// ── Agents ─────────────────────────────────────────────────────────────────────
export const agents = {
list: () => api<Agent[]>("/api/agents"),
create: (input: Partial<Agent>) => api<Agent>("/api/agents", { method: "POST", body: JSON.stringify(input) }),
update: (id: number, input: Partial<Agent>) =>
api<Agent>(`/api/agents/${id}`, { method: "PATCH", body: JSON.stringify(input) }),
regenerateKey: (id: number) => api<Agent>(`/api/agents/${id}/regenerate-key`, { method: "POST" }),
setDisabled: (id: number, disabled: boolean) =>
api<Agent>(`/api/agents/${id}/disabled`, { method: "POST", body: JSON.stringify({ disabled }) }),
remove: (id: number) => api(`/api/agents/${id}`, { method: "DELETE" }),
};
// ── Change requests (human) ──────────────────────────────────────────────────────
export const requests = {
list: (params: { page?: number; page_size?: number; state?: string; agent_id?: number } = {}) => {
const q = new URLSearchParams();
if (params.page) q.set("page", String(params.page));
if (params.page_size) q.set("page_size", String(params.page_size));
if (params.state) q.set("state", params.state);
if (params.agent_id) q.set("agent_id", String(params.agent_id));
return api<{ page: number; page_size: number; total: number; requests: ChangeRequest[] }>(
`/api/change-requests?${q.toString()}`,
);
},
summary: () => api<{ counts: Record<RequestState, number> }>("/api/change-requests/summary"),
get: (id: string) => api<ChangeRequest>(`/api/change-requests/${id}`),
decide: (id: string, decision: "APPROVE" | "REJECT" | "REQUEST_CHANGES", comment?: string) =>
api<ChangeRequest>(`/api/change-requests/${id}/decision`, {
method: "POST",
body: JSON.stringify({ decision, comment }),
}),
};
// ── Notifications ────────────────────────────────────────────────────────────────
export const notifications = {
list: (params: { page?: number; page_size?: number; unread?: boolean } = {}) => {
const q = new URLSearchParams();
if (params.page) q.set("page", String(params.page));
if (params.page_size) q.set("page_size", String(params.page_size));
if (params.unread) q.set("unread", "true");
return api<{ total: number; unread_count: number; notifications: Notification[] }>(
`/api/notifications?${q.toString()}`,
);
},
markRead: (id: number) => api(`/api/notifications/${id}/read`, { method: "POST" }),
markAllRead: () => api("/api/notifications/read-all", { method: "POST" }),
clear: () => api("/api/notifications", { method: "DELETE" }),
};
// ── Global ───────────────────────────────────────────────────────────────────────
export const global = {
get: () => api<GlobalSettings>("/api/global"),
};
// ── Admin ─────────────────────────────────────────────────────────────────────────
export const admin = {
users: () => api<AdminUser[]>("/api/admin/users"),
updateUser: (id: number, input: { role?: "ADMIN" | "USER"; disabled?: boolean }) =>
api(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(input) }),
deleteUser: (id: number) => api(`/api/admin/users/${id}`, { method: "DELETE" }),
settings: () => api<GlobalSettings>("/api/admin/settings"),
updateSettings: (input: { registration_enabled?: boolean; requests_enabled?: boolean }) =>
api<GlobalSettings>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(input) }),
agents: () => api<(Agent & { owner: { id: number; username: string } })[]>("/api/admin/agents"),
setAgentDisabled: (id: number, disabled: boolean) =>
api(`/api/admin/agents/${id}/disabled`, { method: "POST", body: JSON.stringify({ disabled }) }),
deleteAgent: (id: number) => api(`/api/admin/agents/${id}`, { method: "DELETE" }),
auditLogs: (page = 1) =>
api<{ total: number; page: number; page_size: number; logs: AuditLog[] }>(
`/api/admin/audit-logs?page=${page}`,
),
};
+125
View File
@@ -0,0 +1,125 @@
export type RequestState =
| "PENDING"
| "CHANGES_REQUESTED"
| "APPROVED"
| "REJECTED"
| "EXPIRED"
| "CONSUMED"
| "CANCELLED";
export type User = {
id: number;
username: string;
display_name: string;
role: "ADMIN" | "USER";
totp_enabled: boolean;
auto_delete_enabled: boolean;
auto_delete_days: number;
};
export type Agent = {
id: number;
name: string;
description: string | null;
website: string | null;
icon_url: string | null;
disabled: boolean;
max_pending_requests: number;
created_at: string;
updated_at: string;
api_key?: string;
api_key_masked?: string;
pending_count?: number;
};
export type UnifiedDiffChange = { type: "unified_diff"; path: string; content: string };
export type ConfigChange = {
type: "config";
path: string;
before?: unknown;
after?: unknown;
content_type?: string | null;
};
export type CustomChange = { type: "custom"; label: string; before?: unknown; after?: unknown };
export type Change = UnifiedDiffChange | ConfigChange | CustomChange;
export type Receipt = {
payload: {
request_id: string;
decision: "APPROVED" | "REJECTED";
content_hash: string;
approver_id: number;
decided_at: string;
};
signature: string;
algorithm: string;
consumed: boolean;
consumed_at: string | null;
};
export type ChangeRequest = {
request_id: string;
title: string;
description: string | null;
changes: Change[];
metadata: Record<string, unknown> | null;
content_hash: string;
state: RequestState;
comment: string | null;
expires_at: string;
created_at: string;
updated_at: string;
decided_at: string | null;
consumed_at: string | null;
cancelled_at: string | null;
update_count: number;
resubmitted: boolean;
approval_url: string;
receipt: Receipt | null;
agent?: {
id: number;
name: string;
description: string | null;
website: string | null;
icon_url: string | null;
disabled: boolean;
} | null;
};
export type Notification = {
id: number;
type: string;
title: string;
message: string;
request_id: string | null;
read: boolean;
created_at: string;
};
export type GlobalSettings = {
version?: string;
registration_enabled: boolean;
requests_enabled: boolean;
};
export type AdminUser = {
id: number;
username: string;
display_name: string;
role: "ADMIN" | "USER";
disabled: boolean;
totp_enabled: boolean;
created_at: string;
agent_count: number;
request_count: number;
};
export type AuditLog = {
id: number;
action: string;
detail: string | null;
target_type: string | null;
target_id: string | null;
actor: { id: number; username: string } | null;
created_at: string;
};
+40
View File
@@ -0,0 +1,40 @@
import { useState } from "react";
export function AgentAvatar({
name,
iconUrl,
size = 36,
}: {
name: string;
iconUrl?: string | null;
size?: number;
}) {
const [errored, setErrored] = useState(false);
const initials = name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? "")
.join("");
if (iconUrl && !errored) {
return (
<img
src={iconUrl}
alt={name}
width={size}
height={size}
onError={() => setErrored(true)}
className="rounded-lg border border-border object-cover"
style={{ width: size, height: size }}
/>
);
}
return (
<div
className="flex items-center justify-center rounded-lg border border-border bg-primary-dim/40 font-semibold text-primary"
style={{ width: size, height: size, fontSize: size * 0.4 }}
>
{initials || "?"}
</div>
);
}
+250
View File
@@ -0,0 +1,250 @@
import { useState } from "react";
import { toast } from "react-toastify";
import { agents as agentsApi } from "../api/client";
import type { Agent } from "../api/types";
import { Modal } from "./Modal";
import { Button, Input, Textarea } from "./ui";
import { CodeBlock } from "./CodeBlock";
// ── Create / edit form ───────────────────────────────────────────────────────────
export function AgentFormModal({
open,
onClose,
agent,
onSaved,
}: {
open: boolean;
onClose: () => void;
agent?: Agent | null;
onSaved: (agent: Agent, created: boolean) => void;
}) {
const editing = !!agent;
const [name, setName] = useState(agent?.name ?? "");
const [description, setDescription] = useState(agent?.description ?? "");
const [website, setWebsite] = useState(agent?.website ?? "");
const [iconUrl, setIconUrl] = useState(agent?.icon_url ?? "");
const [maxPending, setMaxPending] = useState(agent?.max_pending_requests ?? 5);
const [loading, setLoading] = useState(false);
const submit = async () => {
if (!name.trim()) return toast.error("Name is required");
setLoading(true);
try {
const payload = {
name: name.trim(),
description: description.trim() || null,
website: website.trim() || null,
icon_url: iconUrl.trim() || null,
max_pending_requests: maxPending,
};
const saved = editing
? await agentsApi.update(agent!.id, payload)
: await agentsApi.create(payload);
toast.success(editing ? "Agent updated" : "Agent created");
onSaved(saved, !editing);
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed");
} finally {
setLoading(false);
}
};
return (
<Modal
open={open}
onClose={onClose}
title={editing ? "Edit agent" : "New agent"}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={submit} loading={loading}>
{editing ? "Save" : "Create agent"}
</Button>
</>
}
>
<div className="space-y-4">
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
<Textarea
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
/>
<Input
label="Website (optional)"
value={website}
onChange={(e) => setWebsite(e.target.value)}
placeholder="https://…"
/>
<Input
label="Icon URL (optional)"
value={iconUrl}
onChange={(e) => setIconUrl(e.target.value)}
placeholder="https://…/icon.png"
hint="Verified on save. Must be a JPG, PNG, or GIF under 1 MB."
/>
<div>
<div className="mb-1 flex items-center justify-between text-sm">
<span className="text-muted">Max pending requests</span>
<span className="font-medium text-text">{maxPending}</span>
</div>
<input
type="range"
min={1}
max={10}
value={maxPending}
onChange={(e) => setMaxPending(Number(e.target.value))}
className="w-full accent-[var(--color-primary)]"
/>
<p className="mt-1 text-xs text-faint">
How many requests this agent may have awaiting review at once (110).
</p>
</div>
</div>
</Modal>
);
}
// ── Connect (config + key) ─────────────────────────────────────────────────────────
type Tab = "openclaw" | "mcp" | "rest";
export function ConnectModal({
open,
onClose,
agent,
revealedKey,
}: {
open: boolean;
onClose: () => void;
agent: Agent;
revealedKey?: string | null;
}) {
const [tab, setTab] = useState<Tab>("openclaw");
const origin = window.location.origin;
const key = revealedKey ?? "YOUR_API_KEY";
const hasKey = !!revealedKey;
const openclawConfig = JSON.stringify(
{
mcp: {
servers: {
patchpass: {
transport: "streamable-http",
url: `${origin}/mcp`,
headers: { "x-api-key": key },
},
},
},
},
null,
2,
);
const mcpConfig = JSON.stringify(
{
mcpServers: {
patchpass: {
type: "streamable-http",
url: `${origin}/mcp`,
headers: { "x-api-key": key },
},
},
},
null,
2,
);
const restExample = `# Submit a change request
curl -X POST ${origin}/v1/change-requests \\
-H "x-api-key: ${key}" \\
-H "Content-Type: application/json" \\
-d '{
"title": "Update deployment",
"changes": [
{ "type": "config", "path": "timeout", "before": 30, "after": 60, "content_type": "integer" }
]
}'
# Poll for the decision
curl ${origin}/v1/change-requests/{request_id} -H "x-api-key: ${key}"
# Consume the approval before proceeding
curl -X POST ${origin}/v1/change-requests/{request_id}/consume -H "x-api-key: ${key}"`;
const tabs: { id: Tab; label: string }[] = [
{ id: "openclaw", label: "OpenClaw" },
{ id: "mcp", label: "MCP (generic)" },
{ id: "rest", label: "REST API" },
];
return (
<Modal open={open} onClose={onClose} title={`Connect "${agent.name}"`} wide footer={<Button onClick={onClose}>Done</Button>}>
<div className="space-y-4">
{hasKey ? (
<div className="rounded-lg border border-pending/40 bg-pending/10 p-3">
<p className="text-xs font-semibold text-pending">Save this API key now it won't be shown again.</p>
<div className="mt-2">
<CodeBlock code={revealedKey!} />
</div>
</div>
) : (
<p className="text-sm text-muted">
The API key is only shown once, at creation or after regeneration. Configs below use a
placeholder substitute your saved key.
</p>
)}
<div className="flex gap-1 border-b border-border">
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`px-3 py-2 text-sm font-medium transition ${
tab === t.id ? "border-b-2 border-primary text-text" : "text-muted hover:text-text"
}`}
>
{t.label}
</button>
))}
</div>
{tab === "openclaw" && (
<div className="space-y-2">
<p className="text-sm text-muted">
Add this to <code className="text-text">~/.openclaw/openclaw.json</code>, then tell your
agent to request approval via PatchPass before taking action.
</p>
<CodeBlock code={openclawConfig} language="json" />
</div>
)}
{tab === "mcp" && (
<div className="space-y-2">
<p className="text-sm text-muted">
Generic streamable-HTTP MCP client config (Claude Desktop, Cursor, etc.).
</p>
<CodeBlock code={mcpConfig} language="json" />
</div>
)}
{tab === "rest" && (
<div className="space-y-2">
<p className="text-sm text-muted">Or call the REST API directly with the agent key.</p>
<CodeBlock code={restExample} language="bash" />
</div>
)}
<div className="rounded-lg border border-border bg-surface-raised/40 p-3 text-xs text-muted">
<p className="mb-1 font-semibold text-text">Recommended agent instruction</p>
Before taking any consequential action (deploys, config changes, code edits), call
<code className="mx-1 text-text">create_request</code>, wait for approval, then
<code className="mx-1 text-text">consume_approval</code> before proceeding.
</div>
</div>
</Modal>
);
}
+100
View File
@@ -0,0 +1,100 @@
import type { Change } from "../api/types";
function DiffView({ content }: { content: string }) {
const lines = content.split("\n");
return (
<pre className="overflow-x-auto rounded-lg border border-border bg-bg p-3 text-xs leading-relaxed">
<code>
{lines.map((line, i) => {
let cls = "";
if (line.startsWith("+++") || line.startsWith("---")) cls = "diff-meta";
else if (line.startsWith("@@")) cls = "diff-hunk";
else if (line.startsWith("+")) cls = "diff-add";
else if (line.startsWith("-")) cls = "diff-del";
else if (line.startsWith("diff ") || line.startsWith("index ")) cls = "diff-meta";
return (
<div key={i} className={`whitespace-pre px-1 ${cls}`}>
{line || " "}
</div>
);
})}
</code>
</pre>
);
}
function fmtValue(v: unknown): string {
if (v === null || v === undefined) return "∅";
if (typeof v === "object") return JSON.stringify(v);
return String(v);
}
function percentDelta(before: unknown, after: unknown): string | null {
const b = Number(before);
const a = Number(after);
if (!Number.isFinite(b) || !Number.isFinite(a) || b === 0) return null;
const pct = Math.round(((a - b) / Math.abs(b)) * 100);
if (pct === 0) return null;
return `${pct > 0 ? "+" : ""}${pct}%`;
}
function BeforeAfter({ before, after, contentType }: { before: unknown; after: unknown; contentType?: string | null }) {
const delta =
contentType === "integer" || contentType === "number" ? percentDelta(before, after) : null;
return (
<div className="flex flex-wrap items-center gap-2 text-sm">
<code className="diff-del rounded px-2 py-0.5 font-mono">{fmtValue(before)}</code>
<span className="text-faint"></span>
<code className="diff-add rounded px-2 py-0.5 font-mono">{fmtValue(after)}</code>
{delta && <span className="rounded bg-primary-dim/40 px-2 py-0.5 text-xs text-primary">{delta}</span>}
{contentType && <span className="text-xs text-faint">({contentType})</span>}
</div>
);
}
function typeChip(label: string, color: string) {
return <span className={`rounded px-2 py-0.5 text-xs font-medium ${color}`}>{label}</span>;
}
export function ChangeCard({ change, index }: { change: Change; index: number }) {
return (
<div className="rounded-xl border border-border bg-surface-raised/50 p-4">
<div className="mb-3 flex items-center gap-2">
<span className="text-xs font-mono text-faint">#{index + 1}</span>
{change.type === "unified_diff" && (
<>
{typeChip("diff", "bg-primary-dim/40 text-primary")}
<code className="text-sm text-text">{change.path}</code>
</>
)}
{change.type === "config" && (
<>
{typeChip("config", "bg-accent/15 text-accent")}
<code className="text-sm text-text">{change.path}</code>
</>
)}
{change.type === "custom" && (
<>
{typeChip("custom", "bg-changes/15 text-changes")}
<span className="text-sm text-text">{change.label}</span>
</>
)}
</div>
{change.type === "unified_diff" && <DiffView content={change.content} />}
{change.type === "config" && (
<BeforeAfter before={change.before} after={change.after} contentType={change.content_type} />
)}
{change.type === "custom" && <BeforeAfter before={change.before} after={change.after} />}
</div>
);
}
export function ChangeList({ changes }: { changes: Change[] }) {
return (
<div className="space-y-3">
{changes.map((c, i) => (
<ChangeCard key={i} change={c} index={i} />
))}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { useState } from "react";
export function CodeBlock({ code, language }: { code: string; language?: string }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* ignore */
}
};
return (
<div className="relative">
{language && (
<span className="absolute left-3 top-2 text-[10px] uppercase tracking-wide text-faint">
{language}
</span>
)}
<button
onClick={copy}
className="absolute right-2 top-2 rounded-md border border-border-strong bg-surface px-2 py-1 text-xs text-muted hover:text-text"
>
{copied ? "Copied!" : "Copy"}
</button>
<pre className={`overflow-x-auto rounded-lg border border-border bg-bg p-3 ${language ? "pt-7" : ""} text-xs leading-relaxed text-text`}>
<code>{code}</code>
</pre>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import { requests } from "../api/client";
import type { ChangeRequest } from "../api/types";
import { Modal } from "./Modal";
import { Button } from "./ui";
type Decision = "APPROVE" | "REJECT" | "REQUEST_CHANGES";
const MAX = 500;
const meta: Record<Decision, { title: string; verb: string; variant: "success" | "danger" | "primary"; blurb: string; commentRequired: boolean }> = {
APPROVE: {
title: "Approve request",
verb: "Approve",
variant: "success",
blurb: "The agent will be allowed to proceed. A platform-signed receipt will be issued.",
commentRequired: false,
},
REJECT: {
title: "Reject request",
verb: "Reject",
variant: "danger",
blurb: "This is a hard blocker. The agent must submit a new request to try again.",
commentRequired: false,
},
REQUEST_CHANGES: {
title: "Request changes",
verb: "Request changes",
variant: "primary",
blurb: "The agent will see your notes and can update the request for re-review.",
commentRequired: true,
},
};
export function DecisionModal({
request,
decision,
onClose,
onDone,
}: {
request: ChangeRequest;
decision: Decision | null;
onClose: () => void;
onDone: (updated: ChangeRequest) => void;
}) {
const [comment, setComment] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
setComment("");
}, [decision]);
if (!decision) return null;
const m = meta[decision];
const tooLong = comment.length > MAX;
const missingRequired = m.commentRequired && comment.trim().length === 0;
const submit = async () => {
if (tooLong || missingRequired) return;
setLoading(true);
try {
const updated = await requests.decide(request.request_id, decision, comment.trim() || undefined);
toast.success(`${m.verb}d`);
onDone(updated);
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed");
} finally {
setLoading(false);
}
};
return (
<Modal
open={!!decision}
onClose={onClose}
title={m.title}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant={m.variant} onClick={submit} loading={loading} disabled={tooLong || missingRequired}>
{m.verb}
</Button>
</>
}
>
<div className="space-y-4">
<div className="rounded-lg border border-border bg-surface-raised/50 p-3">
<p className="text-sm font-medium text-text">{request.title}</p>
<p className="mt-1 text-xs text-muted">
{request.changes.length} change{request.changes.length === 1 ? "" : "s"} ·{" "}
{request.agent?.name}
</p>
</div>
<p className="text-sm text-muted">{m.blurb}</p>
<div>
<div className="mb-1 flex items-center justify-between">
<span className="text-sm text-muted">
Comment {m.commentRequired ? <span className="text-changes">(required)</span> : "(optional)"}
</span>
<span className={`text-xs ${tooLong ? "text-rejected" : comment.length > MAX * 0.8 ? "text-pending" : "text-faint"}`}>
{comment.length}/{MAX}
</span>
</div>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={4}
autoFocus
placeholder={m.commentRequired ? "Explain what needs to change…" : "Add an optional note…"}
className={`w-full rounded-lg border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-primary ${
tooLong ? "border-rejected" : "border-border-strong"
}`}
/>
{tooLong && <p className="mt-1 text-xs text-rejected">Comment is too long (max {MAX}).</p>}
{missingRequired && (
<p className="mt-1 text-xs text-changes">A comment is required when requesting changes.</p>
)}
</div>
</div>
</Modal>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { ReactNode, useState } from "react";
import { Link, NavLink, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { NotificationBell } from "./NotificationBell";
const navItems = [
{ to: "/", label: "Dashboard", end: true },
{ to: "/requests", label: "Requests" },
{ to: "/agents", label: "Agents" },
{ to: "/settings", label: "Settings" },
];
export function Layout({ children }: { children: ReactNode }) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className="min-h-screen">
<header className="sticky top-0 z-30 border-b border-border bg-bg/80 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
<div className="flex items-center gap-6">
<Link to="/" className="flex items-center gap-2">
<span className="text-xl"></span>
<span className="text-lg font-bold tracking-tight">
Patch<span className="text-primary">Pass</span>
</span>
</Link>
<nav className="hidden items-center gap-1 md:flex">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`rounded-lg px-3 py-1.5 text-sm font-medium transition ${
isActive ? "bg-surface-raised text-text" : "text-muted hover:text-text"
}`
}
>
{item.label}
</NavLink>
))}
{user?.role === "ADMIN" && (
<NavLink
to="/admin"
className={({ isActive }) =>
`rounded-lg px-3 py-1.5 text-sm font-medium transition ${
isActive ? "bg-surface-raised text-accent" : "text-accent/70 hover:text-accent"
}`
}
>
Admin
</NavLink>
)}
</nav>
</div>
<div className="flex items-center gap-2">
<NotificationBell />
<div className="relative">
<button
onClick={() => setMenuOpen((o) => !o)}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-surface-raised"
>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-primary-dim/50 text-xs font-semibold text-primary">
{user?.display_name?.[0]?.toUpperCase()}
</span>
<span className="hidden text-text sm:inline">{user?.display_name}</span>
</button>
{menuOpen && (
<div
className="animate-fade-in absolute right-0 mt-2 w-44 rounded-xl border border-border-strong bg-surface py-1 shadow-2xl"
onMouseLeave={() => setMenuOpen(false)}
>
<Link
to="/settings"
className="block px-4 py-2 text-sm text-muted hover:bg-surface-raised hover:text-text"
onClick={() => setMenuOpen(false)}
>
Settings
</Link>
<Link
to="/notifications"
className="block px-4 py-2 text-sm text-muted hover:bg-surface-raised hover:text-text md:hidden"
onClick={() => setMenuOpen(false)}
>
Notifications
</Link>
<button
onClick={async () => {
await logout();
navigate("/login");
}}
className="block w-full px-4 py-2 text-left text-sm text-rejected hover:bg-surface-raised"
>
Log out
</button>
</div>
)}
</div>
</div>
</div>
{/* mobile nav */}
<nav className="flex items-center gap-1 overflow-x-auto border-t border-border px-4 py-2 md:hidden">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`whitespace-nowrap rounded-lg px-3 py-1.5 text-sm font-medium ${
isActive ? "bg-surface-raised text-text" : "text-muted"
}`
}
>
{item.label}
</NavLink>
))}
{user?.role === "ADMIN" && (
<NavLink to="/admin" className="whitespace-nowrap rounded-lg px-3 py-1.5 text-sm text-accent">
Admin
</NavLink>
)}
</nav>
</header>
<main className="mx-auto max-w-6xl px-4 py-8">{children}</main>
<footer className="mx-auto max-w-6xl px-4 py-8 text-center text-xs text-faint">
<div className="flex flex-wrap items-center justify-center gap-3">
<Link to="/privacy" className="hover:text-muted">
Privacy Policy
</Link>
<span>·</span>
<Link to="/terms" className="hover:text-muted">
Terms of Service
</Link>
<span>·</span>
<span>PatchPass review changes, approve intent, let agents proceed.</span>
</div>
</footer>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { ReactNode, useEffect } from "react";
export function Modal({
open,
onClose,
title,
children,
footer,
wide,
}: {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
footer?: ReactNode;
wide?: boolean;
}) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div
className={`animate-fade-in relative z-10 w-full ${wide ? "max-w-3xl" : "max-w-lg"} rounded-2xl border border-border-strong bg-surface shadow-2xl`}
>
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<h2 className="text-lg font-semibold text-text">{title}</h2>
<button
onClick={onClose}
className="rounded-lg p-1 text-muted hover:bg-surface-raised hover:text-text"
aria-label="Close"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="max-h-[70vh] overflow-y-auto px-5 py-4">{children}</div>
{footer && <div className="flex justify-end gap-2 border-t border-border px-5 py-4">{footer}</div>}
</div>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNotifications } from "../context/NotificationsContext";
import { relativeTime } from "../utils";
export function NotificationBell() {
const { items, unreadCount, markRead, markAllRead, clear } = useNotifications();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((o) => !o)}
className="relative rounded-lg p-2 text-muted transition hover:bg-surface-raised hover:text-text"
aria-label="Notifications"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path
d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 01-3.4 0"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-rejected px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{open && (
<div className="animate-fade-in absolute right-0 z-40 mt-2 w-80 rounded-xl border border-border-strong bg-surface shadow-2xl">
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
<span className="text-sm font-semibold">Notifications</span>
<div className="flex gap-2 text-xs">
<button className="text-muted hover:text-text" onClick={() => markAllRead()}>
Mark all read
</button>
<button className="text-muted hover:text-rejected" onClick={() => clear()}>
Clear
</button>
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{items.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted">No notifications yet</p>
)}
{items.map((n) => (
<button
key={n.id}
onClick={() => {
markRead(n.id);
setOpen(false);
if (n.request_id) navigate(`/requests/${n.request_id}`);
}}
className={`flex w-full flex-col items-start gap-0.5 border-b border-border/60 px-4 py-3 text-left transition hover:bg-surface-raised ${
n.read ? "opacity-60" : ""
}`}
>
<div className="flex w-full items-center gap-2">
{!n.read && <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />}
<span className="text-sm font-medium text-text">{n.title}</span>
</div>
<span className="text-xs text-muted">{n.message}</span>
<span className="text-[11px] text-faint">{relativeTime(n.created_at)}</span>
</button>
))}
</div>
</div>
)}
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { RequestState } from "../api/types";
const config: Record<RequestState, { label: string; cls: string; dot: string }> = {
PENDING: { label: "Pending", cls: "bg-pending/15 text-pending border-pending/30", dot: "bg-pending" },
CHANGES_REQUESTED: {
label: "Changes requested",
cls: "bg-changes/15 text-changes border-changes/30",
dot: "bg-changes",
},
APPROVED: { label: "Approved", cls: "bg-approved/15 text-approved border-approved/30", dot: "bg-approved" },
REJECTED: { label: "Rejected", cls: "bg-rejected/15 text-rejected border-rejected/30", dot: "bg-rejected" },
EXPIRED: { label: "Expired", cls: "bg-expired/15 text-expired border-expired/30", dot: "bg-expired" },
CONSUMED: { label: "Consumed", cls: "bg-consumed/15 text-consumed border-consumed/30", dot: "bg-consumed" },
CANCELLED: { label: "Cancelled", cls: "bg-cancelled/15 text-cancelled border-cancelled/30", dot: "bg-cancelled" },
};
export function StateBadge({ state, className = "" }: { state: RequestState; className?: string }) {
const c = config[state];
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${c.cls} ${className}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${c.dot}`} />
{c.label}
</span>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from "react";
type Variant = "primary" | "secondary" | "ghost" | "danger" | "success";
const variants: Record<Variant, string> = {
primary: "bg-primary text-white hover:brightness-110 border border-transparent",
secondary: "bg-surface-raised text-text hover:bg-border border border-border-strong",
ghost: "bg-transparent text-muted hover:text-text hover:bg-surface-raised border border-transparent",
danger: "bg-rejected/90 text-white hover:bg-rejected border border-transparent",
success: "bg-approved/90 text-[#06231a] hover:bg-approved border border-transparent font-semibold",
};
export function Button({
variant = "primary",
loading,
children,
className = "",
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: Variant; loading?: boolean }) {
return (
<button
{...props}
disabled={props.disabled || loading}
className={`inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition disabled:opacity-50 disabled:cursor-not-allowed ${variants[variant]} ${className}`}
>
{loading && <Spinner className="h-4 w-4" />}
{children}
</button>
);
}
export function Spinner({ className = "h-5 w-5" }: { className?: string }) {
return (
<svg className={`animate-spin ${className}`} viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-90" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
);
}
export function Card({
children,
className = "",
onClick,
}: {
children: ReactNode;
className?: string;
onClick?: () => void;
}) {
return (
<div onClick={onClick} className={`rounded-xl border border-border bg-surface ${className}`}>
{children}
</div>
);
}
export function Input({ label, hint, className = "", ...props }: InputHTMLAttributes<HTMLInputElement> & { label?: string; hint?: string }) {
return (
<label className="block">
{label && <span className="mb-1 block text-sm text-muted">{label}</span>}
<input
{...props}
className={`w-full rounded-lg border border-border-strong bg-bg px-3 py-2 text-sm text-text outline-none focus:border-primary ${className}`}
/>
{hint && <span className="mt-1 block text-xs text-faint">{hint}</span>}
</label>
);
}
export function Textarea({ label, className = "", ...props }: TextareaHTMLAttributes<HTMLTextAreaElement> & { label?: string }) {
return (
<label className="block">
{label && <span className="mb-1 block text-sm text-muted">{label}</span>}
<textarea
{...props}
className={`w-full rounded-lg border border-border-strong bg-bg px-3 py-2 text-sm text-text outline-none focus:border-primary ${className}`}
/>
</label>
);
}
export function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className="inline-flex items-center gap-3"
>
<span
className={`relative h-6 w-11 rounded-full transition ${checked ? "bg-primary" : "bg-border-strong"}`}
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-all ${checked ? "left-[22px]" : "left-0.5"}`}
/>
</span>
{label && <span className="text-sm text-text">{label}</span>}
</button>
);
}
export function EmptyState({ title, subtitle, icon }: { title: string; subtitle?: string; icon?: string }) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-16 text-center">
{icon && <div className="mb-3 text-4xl opacity-70">{icon}</div>}
<p className="text-text font-medium">{title}</p>
{subtitle && <p className="mt-1 max-w-md text-sm text-muted">{subtitle}</p>}
</div>
);
}
export function PageHeader({ title, subtitle, actions }: { title: string; subtitle?: string; actions?: ReactNode }) {
return (
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight text-text">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-muted">{subtitle}</p>}
</div>
{actions && <div className="flex gap-2">{actions}</div>}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { createContext, useCallback, useContext, useEffect, useState, ReactNode } from "react";
import { auth as authApi, global as globalApi } from "../api/client";
import type { GlobalSettings, User } from "../api/types";
type AuthContextValue = {
user: User | null;
loading: boolean;
settings: GlobalSettings | null;
setUser: (u: User | null) => void;
refresh: () => Promise<void>;
logout: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue>({
user: null,
loading: true,
settings: null,
setUser: () => {},
refresh: async () => {},
logout: async () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [settings, setSettings] = useState<GlobalSettings | null>(null);
const refresh = useCallback(async () => {
try {
const me = await authApi.me();
setUser(me);
} catch {
setUser(null);
}
}, []);
useEffect(() => {
(async () => {
globalApi.get().then(setSettings).catch(() => {});
await refresh();
setLoading(false);
})();
}, [refresh]);
const logout = useCallback(async () => {
await authApi.logout().catch(() => {});
setUser(null);
}, []);
return (
<AuthContext.Provider value={{ user, loading, settings, setUser, refresh, logout }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
+118
View File
@@ -0,0 +1,118 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState, ReactNode } from "react";
import { toast } from "react-toastify";
import { notifications as notifApi } from "../api/client";
import type { Notification } from "../api/types";
import { useAuth } from "./AuthContext";
type NotificationsContextValue = {
items: Notification[];
unreadCount: number;
reload: () => Promise<void>;
markRead: (id: number) => Promise<void>;
markAllRead: () => Promise<void>;
clear: () => Promise<void>;
/** Bumps whenever a realtime request event arrives, so lists can refresh. */
requestEventTick: number;
};
const NotificationsContext = createContext<NotificationsContextValue>({
items: [],
unreadCount: 0,
reload: async () => {},
markRead: async () => {},
markAllRead: async () => {},
clear: async () => {},
requestEventTick: 0,
});
export function NotificationsProvider({ children }: { children: ReactNode }) {
const { user } = useAuth();
const [items, setItems] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [requestEventTick, setRequestEventTick] = useState(0);
const wsRef = useRef<WebSocket | null>(null);
const reload = useCallback(async () => {
if (!user) return;
try {
const data = await notifApi.list({ page_size: 30 });
setItems(data.notifications);
setUnreadCount(data.unread_count);
} catch {
/* ignore */
}
}, [user]);
const markRead = useCallback(async (id: number) => {
await notifApi.markRead(id).catch(() => {});
setItems((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)));
setUnreadCount((c) => Math.max(0, c - 1));
}, []);
const markAllRead = useCallback(async () => {
await notifApi.markAllRead().catch(() => {});
setItems((prev) => prev.map((n) => ({ ...n, read: true })));
setUnreadCount(0);
}, []);
const clear = useCallback(async () => {
await notifApi.clear().catch(() => {});
setItems([]);
setUnreadCount(0);
}, []);
useEffect(() => {
if (!user) {
setItems([]);
setUnreadCount(0);
return;
}
reload();
// Realtime websocket
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${window.location.host}/api/ws/notifications`);
wsRef.current = ws;
let heartbeat: ReturnType<typeof setInterval> | null = null;
ws.onopen = () => {
heartbeat = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send("ping");
}, 25000);
};
ws.onmessage = (evt) => {
try {
const event = JSON.parse(evt.data);
if (event.kind === "notification") {
setItems((prev) => [event.notification, ...prev].slice(0, 50));
if (typeof event.unreadCount === "number") setUnreadCount(event.unreadCount);
else setUnreadCount((c) => c + 1);
toast.info(event.notification.title, { autoClose: 4000 });
setRequestEventTick((t) => t + 1);
} else if (event.kind === "request_event") {
setRequestEventTick((t) => t + 1);
}
} catch {
/* ignore */
}
};
ws.onclose = () => {
if (heartbeat) clearInterval(heartbeat);
};
return () => {
if (heartbeat) clearInterval(heartbeat);
ws.close();
};
}, [user, reload]);
return (
<NotificationsContext.Provider
value={{ items, unreadCount, reload, markRead, markAllRead, clear, requestEventTick }}
>
{children}
</NotificationsContext.Provider>
);
}
export const useNotifications = () => useContext(NotificationsContext);
+94
View File
@@ -0,0 +1,94 @@
@import "tailwindcss";
/* PatchPass design tokens (Tailwind v4 @theme) */
@theme {
--color-bg: #0a0e17;
--color-surface: #111726;
--color-surface-raised: #1a2234;
--color-border: #232c40;
--color-border-strong: #313c56;
--color-text: #e6ebf5;
--color-muted: #8b96ad;
--color-faint: #5c6780;
--color-primary: #6d8bff;
--color-primary-dim: #3a4a86;
--color-accent: #5be0c8;
--color-pending: #f4b740;
--color-approved: #3ecf8e;
--color-rejected: #ff6b6b;
--color-changes: #b985ff;
--color-expired: #7a8296;
--color-consumed: #5be0c8;
--color-cancelled: #7a8296;
}
html,
body,
#root {
background-color: var(--color-bg);
color: var(--color-text);
min-height: 100%;
margin: 0;
}
body {
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial,
sans-serif;
-webkit-font-smoothing: antialiased;
}
button:hover,
a:hover {
cursor: pointer;
}
* {
scrollbar-color: var(--color-border-strong) transparent;
}
/* Diff syntax coloring */
.diff-add {
background-color: rgba(62, 207, 142, 0.13);
color: #9ef0c4;
}
.diff-del {
background-color: rgba(255, 107, 107, 0.13);
color: #ffb0b0;
}
.diff-hunk {
color: var(--color-primary);
}
.diff-meta {
color: var(--color-faint);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.18s ease-out;
}
@keyframes pulseRing {
0% {
box-shadow: 0 0 0 0 rgba(185, 133, 255, 0.5);
}
70% {
box-shadow: 0 0 0 8px rgba(185, 133, 255, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(185, 133, 255, 0);
}
}
.animate-pulse-ring {
animation: pulseRing 1.8s ease-out infinite;
}
+73
View File
@@ -0,0 +1,73 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import "./index.css";
import { AuthProvider, useAuth } from "./context/AuthContext";
import { NotificationsProvider } from "./context/NotificationsContext";
import { Layout } from "./components/Layout";
import { Spinner } from "./components/ui";
import { LoginPage } from "./pages/Login";
import { RegisterPage } from "./pages/Register";
import { DashboardPage } from "./pages/Dashboard";
import { RequestsPage } from "./pages/Requests";
import { RequestDetailPage } from "./pages/RequestDetail";
import { AgentsPage } from "./pages/Agents";
import { NotificationsPage } from "./pages/Notifications";
import { SettingsPage } from "./pages/Settings";
import { AdminPage } from "./pages/Admin";
import { PrivacyPage, TermsPage } from "./pages/Legal";
function FullScreenLoader() {
return (
<div className="flex min-h-screen items-center justify-center text-primary">
<Spinner className="h-8 w-8" />
</div>
);
}
function Protected({ children, adminOnly }: { children: React.ReactNode; adminOnly?: boolean }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return <FullScreenLoader />;
if (!user) return <Navigate to="/login" replace state={{ from: location }} />;
if (adminOnly && user.role !== "ADMIN") return <Navigate to="/" replace />;
return (
<NotificationsProvider>
<Layout>{children}</Layout>
</NotificationsProvider>
);
}
function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/terms" element={<TermsPage />} />
<Route path="/" element={<Protected><DashboardPage /></Protected>} />
<Route path="/requests" element={<Protected><RequestsPage /></Protected>} />
<Route path="/requests/:id" element={<Protected><RequestDetailPage /></Protected>} />
<Route path="/agents" element={<Protected><AgentsPage /></Protected>} />
<Route path="/notifications" element={<Protected><NotificationsPage /></Protected>} />
<Route path="/settings" element={<Protected><SettingsPage /></Protected>} />
<Route path="/admin" element={<Protected adminOnly><AdminPage /></Protected>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
<ToastContainer position="bottom-right" theme="dark" newestOnTop />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
);
+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>
);
}
+35
View File
@@ -0,0 +1,35 @@
export function relativeTime(iso: string): string {
const then = new Date(iso).getTime();
const diff = Date.now() - then;
const abs = Math.abs(diff);
const mins = Math.round(abs / 60000);
const suffix = diff >= 0 ? "ago" : "from now";
if (abs < 60000) return "just now";
if (mins < 60) return `${mins}m ${suffix}`;
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours}h ${suffix}`;
const days = Math.round(hours / 24);
if (days < 30) return `${days}d ${suffix}`;
return new Date(iso).toLocaleDateString();
}
export function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
});
}
/** Returns a human "expires in Xm" string, or "expired" if past. */
export function expiresIn(iso: string): { text: string; expired: boolean; urgent: boolean } {
const diff = new Date(iso).getTime() - Date.now();
if (diff <= 0) return { text: "expired", expired: true, urgent: false };
const mins = Math.floor(diff / 60000);
if (mins < 60) return { text: `expires in ${mins}m`, expired: false, urgent: mins < 10 };
const hours = Math.floor(mins / 60);
return { text: `expires in ${hours}h ${mins % 60}m`, expired: false, urgent: false };
}