SPB Git

spb/akos

Public
8.1 KB · 69 lines tsx
1import { useState, type FormEvent } from "react";2import { useSearchParams } from "react-router";3import { api, ApiError, type AuditEvent, type Credential, type Project } from "../api";4import { useFetch, fmtDate } from "../hooks";5import { Badge, Confirm, Empty, ErrorBox, JsonTree, KV, Loading, Td } from "../components/ui";6import { useMe } from "../App";78export default function Credentials() {9  const { me } = useMe();10  const { data, error, loading, reload } = useFetch<{ credentials: Credential[] }>("credentials");11  const projects = useFetch<{ projects: Project[] }>("projects");12  const providers = useFetch<{ providers: { id: string; slug: string; name: string }[] }>("providers");13  const [sp] = useSearchParams();14  const [sel, setSel] = useState<string | null>(sp.get("focus"));15  const detail = useFetch<Credential>(sel ? `credentials/${sel}` : null);16  const audit = useFetch<{ events: AuditEvent[] }>(sel ? `credentials/${sel}/audit` : null);17  const [show, setShow] = useState(false);18  const [form, setForm] = useState({ name: "", provider_id: "", scheme: "bearer", placement: "", value: "", scopes: "", allowed_projects: [] as string[], environment: "development", expires_at: "" });19  const [err, setErr] = useState<ApiError | null>(null); const [busy, setBusy] = useState(false); const [rot, setRot] = useState("");20  const canWrite = me?.role === "admin" || me?.role === "operator";21  const submit = async (e: FormEvent) => {22    e.preventDefault(); setBusy(true); setErr(null);23    try { await api("credentials", { body: { ...form, provider_id: form.provider_id || null, placement: form.placement || null, scopes: form.scopes.split(",").map((s) => s.trim()).filter(Boolean), expires_at: form.expires_at || null } }); setForm({ ...form, name: "", value: "" }); setShow(false); reload(); }24    catch (ex) { setErr(ex as ApiError); } finally { setBusy(false); }25  };26  const act = async (fn: () => Promise<unknown>) => { setErr(null); try { await fn(); reload(); detail.reload(); audit.reload(); } catch (ex) { setErr(ex as ApiError); } };27  return (28    <div>29      <div className="row between"><h1>Credential Vault</h1>{canWrite && <button className="btn primary" onClick={() => setShow((s) => !s)}>{show ? "Close" : "Onboard credential"}</button>}</div>30      <p className="hint">AKOS stores only <code>vault://</code> references; values live in the macOS keychain of the node and are injected into governed executions. Values are never displayed, logged or exported.</p>31      <ErrorBox error={err} />32      {show && (33        <form className="card" onSubmit={submit} autoComplete="off">34          <div className="form-grid">35            <div className="field"><label htmlFor="c-name">Name (alphanumeric, - _)</label><input id="c-name" required pattern="[A-Za-z0-9_\-]+" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></div>36            <div className="field"><label htmlFor="c-prov">Provider</label><select id="c-prov" value={form.provider_id} onChange={(e) => setForm({ ...form, provider_id: e.target.value })}><option value="">—</option>{providers.data?.providers.map((p) => <option key={p.id} value={p.slug}>{p.name}</option>)}</select></div>37            <div className="field"><label htmlFor="c-scheme">Scheme</label><select id="c-scheme" value={form.scheme} onChange={(e) => setForm({ ...form, scheme: e.target.value })}>{["bearer", "header", "query", "basic"].map((s) => <option key={s}>{s}</option>)}</select></div>38            <div className="field"><label htmlFor="c-place">Placement (header name or query param)</label><input id="c-place" value={form.placement} onChange={(e) => setForm({ ...form, placement: e.target.value })} placeholder={form.scheme === "header" ? "X-API-Key" : form.scheme === "query" ? "api_key" : "—"} /></div>39            <div className="field span2"><label htmlFor="c-val">Secret value (write-only; sent once to the keychain)</label><input id="c-val" type="password" required autoComplete="new-password" value={form.value} onChange={(e) => setForm({ ...form, value: e.target.value })} /></div>40            <div className="field"><label htmlFor="c-scopes">Scopes (comma separated)</label><input id="c-scopes" value={form.scopes} onChange={(e) => setForm({ ...form, scopes: e.target.value })} /></div>41            <div className="field"><label htmlFor="c-env">Environment</label><select id="c-env" value={form.environment} onChange={(e) => setForm({ ...form, environment: e.target.value })}><option>development</option><option>production</option></select></div>42            <div className="field"><label htmlFor="c-proj">Allowed projects (empty = all)</label><select id="c-proj" multiple value={form.allowed_projects} onChange={(e) => setForm({ ...form, allowed_projects: Array.from(e.target.selectedOptions).map((o) => o.value) })}>{projects.data?.projects.map((p) => <option key={p.id} value={p.slug}>{p.name}</option>)}</select></div>43            <div className="field"><label htmlFor="c-exp">Expires at (ISO, optional)</label><input id="c-exp" value={form.expires_at} onChange={(e) => setForm({ ...form, expires_at: e.target.value })} placeholder="2027-01-01T00:00:00Z" /></div>44          </div>45          <button className="btn primary" disabled={busy}>{busy ? "Storing…" : "Store in vault"}</button>46        </form>)}47      {loading && <Loading />}<ErrorBox error={error} />48      {data && data.credentials.length === 0 && <Empty>No credentials onboarded.</Empty>}49      {data && data.credentials.length > 0 && (50        <table className="tbl"><thead><tr><th>Name</th><th>Reference</th><th>Scheme</th><th>Env</th><th>Version</th><th>Projects</th><th>Uses</th><th>Status</th></tr></thead><tbody>51          {data.credentials.map((c) => <tr key={c.id} className="click" onClick={() => setSel(c.id)}><Td l="name"><b>{c.name}</b>{c.provider_name && <div className="hint">{c.provider_name}</div>}</Td><Td l="reference" wide><code>{c.ref}</code></Td><Td l="scheme">{c.scheme}{c.placement ? ` (${c.placement})` : ""}</Td><Td l="env"><Badge v={c.environment} /></Td><Td l="version" className="num">{c.version}</Td><Td l="projects">{c.allowed_projects.length ? `${c.allowed_projects.length} scoped` : "all"}</Td><Td l="uses" className="num">{c.uses ?? 0}</Td><Td l="status">{c.revoked_at ? <Badge v="revoked" /> : <Badge v="ok" label="active" />}</Td></tr>)}52        </tbody></table>)}53      {sel && (54        <div className="card">55          <div className="row between"><h3>Credential detail</h3><button className="btn sm" onClick={() => setSel(null)}>close</button></div>56          {detail.loading && <Loading />}<ErrorBox error={detail.error} />57          {detail.data && (<>58            <KV items={[["Name", detail.data.name], ["Reference", <code>{detail.data.ref}</code>], ["Vault item present", <Badge v={detail.data.vault_present ? "ok" : "fail"} label={detail.data.vault_present ? "yes" : "no"} />], ["Fingerprint", <code>{detail.data.fingerprint}</code>], ["Version", detail.data.version], ["Rotated", fmtDate(detail.data.rotated_at)], ["Revoked", fmtDate(detail.data.revoked_at)], ["Expires", fmtDate(detail.data.expires_at)], ["Packages using it", detail.data.packages?.length ? detail.data.packages.map((p) => `${p.akp_id} v${p.version}`).join(", ") : "none"]]} />59            {canWrite && !detail.data.revoked_at && <div className="row" style={{ marginTop: 10 }}>60              <input aria-label="New secret value" type="password" placeholder="new value" autoComplete="new-password" style={{ maxWidth: 260 }} value={rot} onChange={(e) => setRot(e.target.value)} />61              <button className="btn sm" disabled={!rot} onClick={() => act(async () => { await api(`credentials/${sel}/rotate`, { body: { value: rot } }); setRot(""); })}>Rotate</button>62              <Confirm label="Revoke" onConfirm={() => act(() => api(`credentials/${sel}/revoke`, { method: "POST" }))} /></div>}63            <h3>Audit</h3>{audit.data?.events.length ? audit.data.events.map((a) => <details key={a.seq} className="card tight"><summary>#{a.seq} <b>{a.action}</b> <span className="hint">{fmtDate(a.ts)} · {a.actor_type}:{a.actor_id?.slice(0, 8)}</span></summary><JsonTree data={a.details} open /></details>) : <span className="hint">no events</span>}64          </>)}65        </div>)}66    </div>67  );68}69
Files · main
Menu

Theme