AkurAI Build
Menu

popagent

public

Latest change fadf21d1cd584745f6f92eaa60509e0bef19d242 - fix task orchestration and compact overview cards 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">
      <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>
  );
}