Menu
popagent
publicLatest change 1ce5ab5f17eefb3cef070f9d98b28f2bb1e5c879 - Secure agent secrets behind origin-bound browser use by AkurAI Build
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import { KeyRound, Pencil, Plus, ShieldCheck, Trash2, X } from "lucide-react";
import type { AgentSecret, AgentSecretListResponse } from "../api-types";
import { apiFetch } from "./api";
import { toast } from "./toast";
import { useFocusTrap } from "./use-focus-trap";
type SecretForm = {
mode: "create" | "edit";
name: string;
allowedOrigin: string;
value: string;
};
export function SecretList({
secrets,
onEdit,
onDelete,
}: {
secrets: AgentSecret[];
onEdit: (secret: AgentSecret) => void;
onDelete: (secret: AgentSecret) => void;
}) {
if (!secrets.length) {
return (
<div className="border border-dashed border-line bg-bg px-5 py-10 text-center">
<KeyRound className="mx-auto size-5 text-accent" />
<p className="mt-3 text-sm font-medium">No credentials provisioned</p>
<p className="mt-1 text-xs text-ink-dim">Add a credential and bind it to one exact HTTPS origin.</p>
</div>
);
}
return (
<div className="divide-y divide-line border border-line bg-bg">
{secrets.map((secret) => (
<article key={secret.name} className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<KeyRound className="size-4 shrink-0 text-accent" />
<code className="truncate text-sm font-medium text-ink">{secret.name}</code>
</div>
<p className={`mt-1 truncate pl-6 text-xs ${secret.allowedOrigin ? "text-ink-dim" : "text-amber-500"}`}>
{secret.allowedOrigin ?? "Unbound — edit before use"}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 pl-6 sm:pl-0">
<button type="button" onClick={() => onEdit(secret)} className="liquid-control min-h-11 rounded px-3 text-xs">
<Pencil className="mr-2 inline size-3.5" />Edit binding
</button>
<button type="button" aria-label={`Delete ${secret.name}`} onClick={() => onDelete(secret)} className="liquid-control flex size-11 items-center justify-center rounded text-red-400">
<Trash2 className="size-4" />
</button>
</div>
</article>
))}
</div>
);
}
export function SecretSettingsPanel() {
const [secrets, setSecrets] = useState<AgentSecret[]>();
const [loadError, setLoadError] = useState("");
const [form, setForm] = useState<SecretForm>();
const [formError, setFormError] = useState("");
const [saving, setSaving] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<AgentSecret>();
const [deleting, setDeleting] = useState(false);
const formRef = useRef<HTMLDivElement>(null);
const deleteRef = useRef<HTMLDivElement>(null);
useFocusTrap(formRef, Boolean(form));
useFocusTrap(deleteRef, Boolean(deleteTarget));
const load = useCallback(async () => {
const response = await apiFetch("/api/secrets");
if (!response.ok) throw new Error("Unable to load credentials");
setSecrets((await response.json() as AgentSecretListResponse).secrets);
setLoadError("");
}, []);
useEffect(() => {
let active = true;
void load().catch((error) => {
if (active) setLoadError(error instanceof Error ? error.message : "Unable to load credentials");
});
return () => { active = false; };
}, [load]);
useEffect(() => {
if (!form && !deleteTarget) return;
const close = (event: KeyboardEvent) => {
if (event.key !== "Escape" || saving || deleting) return;
setForm(undefined);
setDeleteTarget(undefined);
setFormError("");
};
document.addEventListener("keydown", close);
return () => document.removeEventListener("keydown", close);
}, [deleteTarget, deleting, form, saving]);
const openCreate = () => {
setForm({ mode: "create", name: "", allowedOrigin: "", value: "" });
setFormError("");
};
const openEdit = (secret: AgentSecret) => {
setForm({ mode: "edit", name: secret.name, allowedOrigin: secret.allowedOrigin ?? "", value: "" });
setFormError("");
};
const closeForm = () => {
if (saving) return;
setForm(undefined);
setFormError("");
};
const save = async (event: FormEvent) => {
event.preventDefault();
if (!form) return;
setSaving(true);
setFormError("");
const create = form.mode === "create";
const body = create
? { name: form.name.trim(), value: form.value, allowedOrigin: form.allowedOrigin.trim() }
: {
allowedOrigin: form.allowedOrigin.trim(),
...(form.value ? { value: form.value } : {}),
};
try {
const response = await apiFetch(create ? "/api/secrets" : `/api/secrets/${encodeURIComponent(form.name)}`, {
method: create ? "POST" : "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (response.ok) {
setForm(undefined);
await load().catch(() => setLoadError("Credential saved, but the list could not be refreshed"));
toast(create ? "Credential added" : "Credential binding updated");
} else {
const result = await response.json().catch(() => ({})) as { error?: string };
setFormError(result.error ?? "Unable to save credential");
}
} catch {
setFormError("Unable to reach the credential service");
} finally {
setSaving(false);
}
};
const remove = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
const response = await apiFetch(`/api/secrets/${encodeURIComponent(deleteTarget.name)}`, { method: "DELETE" });
if (response.ok) {
setDeleteTarget(undefined);
await load().catch(() => setLoadError("Credential deleted, but the list could not be refreshed"));
toast("Credential deleted");
} else toast("Unable to delete credential");
} catch {
toast("Unable to reach the credential service");
} finally {
setDeleting(false);
}
};
return (
<div className="w-full max-w-4xl">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-xl font-semibold">Secrets</h1>
<p className="mt-1 text-sm text-ink-dim">Provision credentials for server-side use without exposing values to agents.</p>
</div>
<button type="button" onClick={openCreate} className="min-h-11 shrink-0 rounded bg-accent px-4 text-sm font-medium text-white">
<Plus className="mr-2 inline size-4" />Add credential
</button>
</div>
<section className="mt-6 border border-accent/25 bg-accent-soft p-4">
<div className="flex items-start gap-3">
<ShieldCheck className="mt-0.5 size-5 shrink-0 text-accent" />
<div>
<h2 className="text-sm font-medium">Values stay outside the intelligence layer</h2>
<p className="mt-1 text-xs leading-relaxed text-ink-dim">Popagent encrypts values at rest, never returns them from the API, and can only fill them into a browser element whose page and frame match the exact allowed HTTPS origin.</p>
</div>
</div>
</section>
<section className="mt-5">
<div className="mb-3 flex items-end justify-between gap-4">
<div><h2 className="font-medium">Provisioned credentials</h2><p className="mt-1 text-xs text-ink-dim">Names and destination bindings are visible; values are not.</p></div>
{secrets ? <span className="text-xs text-ink-dim">{secrets.length} total</span> : null}
</div>
{loadError ? (
<div role="alert" className="border border-red-500/30 bg-panel p-4 text-sm text-red-400">
{loadError}
<button type="button" onClick={() => void load().catch(() => undefined)} className="ml-3 min-h-11 border border-line px-3 text-ink">Retry</button>
</div>
) : secrets ? <SecretList secrets={secrets} onEdit={openEdit} onDelete={setDeleteTarget} /> : <p role="status" className="py-8 text-sm text-ink-dim">Loading credentials…</p>}
</section>
{form ? (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 p-4">
<div ref={formRef} role="dialog" aria-modal="true" aria-labelledby="secret-form-title" className="w-full max-w-lg border border-line bg-panel p-5 shadow-2xl">
<div className="flex items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded bg-accent-soft"><KeyRound className="size-4 text-accent" /></div>
<div className="min-w-0 flex-1"><h2 id="secret-form-title" className="font-semibold">{form.mode === "create" ? "Add credential" : "Edit credential binding"}</h2><p className="mt-1 text-xs text-ink-dim">The value is write-only and cannot be recovered through this interface.</p></div>
<button type="button" aria-label="Close credential editor" onClick={closeForm} className="flex size-11 items-center justify-center rounded hover:bg-panel-2"><X className="size-4" /></button>
</div>
<form onSubmit={(event) => void save(event)} className="mt-5 space-y-4">
<label className="block text-sm font-medium">Name<input autoFocus={form.mode === "create"} aria-label="Credential name" disabled={form.mode === "edit"} autoComplete="off" spellCheck={false} value={form.name} onChange={(event) => setForm({ ...form, name: event.currentTarget.value })} placeholder="accounts/password" className="setting-input disabled:opacity-60" /></label>
<label className="block text-sm font-medium">Allowed HTTPS origin<input autoFocus={form.mode === "edit"} aria-label="Allowed HTTPS origin" inputMode="url" autoComplete="off" spellCheck={false} value={form.allowedOrigin} onChange={(event) => setForm({ ...form, allowedOrigin: event.currentTarget.value })} placeholder="https://accounts.example.com" className="setting-input" /></label>
<label className="block text-sm font-medium">{form.mode === "create" ? "Secret value" : "Replace value (optional)"}<input aria-label={form.mode === "create" ? "Secret value" : "Replacement secret value"} type="password" autoComplete="new-password" spellCheck={false} value={form.value} onChange={(event) => setForm({ ...form, value: event.currentTarget.value })} className="setting-input font-mono" /></label>
{formError ? <p role="alert" className="text-sm text-red-400">{formError}</p> : null}
<div className="flex items-center justify-end gap-2 pt-2"><button type="button" onClick={closeForm} disabled={saving} className="liquid-control min-h-11 rounded px-4 text-sm">Cancel</button><button type="submit" disabled={saving || !form.name.trim() || !form.allowedOrigin.trim() || (form.mode === "create" && !form.value)} className="min-h-11 rounded bg-accent px-4 text-sm font-medium text-white disabled:opacity-50">{saving ? "Saving…" : form.mode === "create" ? "Add credential" : "Save binding"}</button></div>
</form>
</div>
</div>
) : null}
{deleteTarget ? (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 p-4">
<div ref={deleteRef} role="alertdialog" aria-modal="true" aria-labelledby="delete-secret-title" aria-describedby="delete-secret-description" className="w-full max-w-md border border-line bg-panel p-5 shadow-2xl">
<h2 id="delete-secret-title" className="font-semibold">Delete credential?</h2>
<p id="delete-secret-description" className="mt-2 text-sm leading-relaxed text-ink-dim"><code className="text-ink">{deleteTarget.name}</code> will be permanently removed. Agents using this binding will fail closed.</p>
<div className="mt-5 flex justify-end gap-2"><button type="button" autoFocus onClick={() => setDeleteTarget(undefined)} disabled={deleting} className="liquid-control min-h-11 rounded px-4 text-sm">Cancel</button><button type="button" onClick={() => void remove()} disabled={deleting} className="min-h-11 rounded border border-red-500/40 bg-red-500/10 px-4 text-sm font-medium text-red-400 disabled:opacity-50">{deleting ? "Deleting…" : "Delete credential"}</button></div>
</div>
</div>
) : null}
</div>
);
}