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