Files
learnharness/components/settings/keys-section.tsx
T
amosblomqvist 21d288e9f9 Keys section polish (wave-5 chunk A, owner design): the minimal key-value table
The owner called the old section messy; the redesign is theirs:

- MINIMAL TABLE: provider label left, masked status right (…tail ·
  chars · env), description hints GONE (they took too much space),
  the 'Get a key ↗' link a tiny muted guest on curated rows. Rows
  read as clean key-value pairs.
- ENV-OWNED ROWS ARE READ-ONLY (owner ruling): a real environment
  variable owns the value — the row dims (graph-view graying) and no
  paste field renders, because a paste box on a key the UI cannot
  make live would mislead. Clicking does nothing.
- OPENROUTER UN-PINNED (owner ruling): one provider among 33 — a row
  only when stored, otherwise it's an option in the Add dropdown like
  everyone else. No synthesis, no lifeline description.
- ROW INTERACTION (owner choice): clicking a writable row unfolds a
  paste field + Save (+ Delete for a file-sourced value) beneath it;
  success folds it back to rest; Esc folds without closing the dialog
  (claimed at document CAPTURE phase — the only stopPropagation that
  outruns both React handlers and the dialog's window listener; the
  React-level attempt leaked and closed the dialog, caught live).
- ADD-PROVIDER = the table's last row, in the app's shared dropdown
  language: new ProviderMenuList (fuzzy search + arrow-walk + Enter
  over the 33 pi-ai providers, label + envVar rows) in the shared
  model-menu.tsx, portaled and fixed-positioned like the role menus.
  Picking one unfolds a paste row; Save stores, Cancel/Esc folds.
- Tools & services (Deepgram + the Google pair) keep their own group,
  same table look, same unfold interaction.
- Unknown auth.json vars stay read-only dimmed rows.

Tests: classifyKeys re-pinned (openrouter rides the normal provider
classification; addable drops stored keys), keyStatusLine minimal
('not set' for absent, ' · env' marker for environment-owned). Gates:
typecheck, lint, 759/759, build.

Live-verified: add→save→replace→delete round-trips, fold-on-save,
Esc layering both flows, env-owned rows read-only, addable count
31→30→31.
2026-09-22 00:16:23 +03:00

488 lines
16 KiB
TypeScript

"use client";
/**
* The Keys section (wave-5 polish, 2026-09-21 — owner design): a MINIMAL
* key-value table. Provider left, masked key right, no description hints
* (they took too much space), the "Get a key ↗" link a tiny muted guest.
* Clicking a writable row unfolds a paste field + Save (+ Delete for a
* file-sourced value) beneath it; Esc or a second click folds it away.
* The Add-provider control is the LAST ROW of the table and uses the
* app's shared dropdown look (ProviderMenuList — fuzzy search over the
* 33 pi-ai providers).
*
* Owner rulings carried in:
* - Env-owned rows (a real environment variable owns the name — dev
* .env.local or launch env) render READ-ONLY + dimmed, no paste field:
* a paste box on a key the UI cannot make live would mislead.
* - OpenRouter is NOT special: one provider among 33, a row only when
* stored, otherwise it lives in the Add dropdown like everyone else.
* - Tools & services (Deepgram, the Google pair) keep their own group,
* same look, same interaction.
*
* API: GET /api/keys (masked statuses + the providers array), PUT
* /api/keys/:id (shape-gated 400s land inline), DELETE. Saves take
* effect immediately. Plaintext never returns from the store.
*/
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
import { SettingsGroup } from "./controls.tsx";
import { ProviderMenuList } from "./model-menu.tsx";
/** One pi-ai provider with plain API-key auth (the Platform seam —
* derived from pi-ai's own env-key table; no drift possible). */
export interface ProviderKeyInfo {
id: string;
envVar: string;
label: string;
}
interface KeysResponse {
keys: AuthKeyStatus[];
providers?: ProviderKeyInfo[];
}
/** The curated tools group, pinned by id (the catalog owns the copy;
* these ids are the contract). Derived from the catalog (search wave,
* 2026-09-20: the Google CSE pair retired, Tavily + Brave joined) — the
* section can never drift from core/auth-keys.ts again. */
const TOOL_IDS = AUTH_KEYS.map((k) => k.id) as readonly string[];
/** The classification driving the table groups. Pure — exported for
* tests. OpenRouter is un-pinned (wave-5 owner ruling): no synthesis,
* no special row — it rides the same provider classification. */
export function classifyKeys(
statuses: readonly AuthKeyStatus[],
providers: readonly ProviderKeyInfo[] | undefined,
): {
/** Stored provider keys (env or file), in GET order. */
providerRows: AuthKeyStatus[];
/** The curated tools rows (synthesized when absent — always shown,
* they carry the docs links a first boot needs). */
toolRows: AuthKeyStatus[];
/** Unknown file vars — preserved, read-only. */
unknownRows: AuthKeyStatus[];
/** Providers without a stored key — the Add dropdown's options. */
addable: ProviderKeyInfo[];
} {
const byId = new Map(statuses.map((s) => [s.id, s]));
const isTool = (id: string) => (TOOL_IDS as readonly string[]).includes(id);
const providerEnv = new Set((providers ?? []).map((p) => p.envVar));
const synth = (id: string): AuthKeyStatus => ({
id,
info: null,
present: false,
masked: null,
source: "none",
shadowedByEnv: false,
});
const providerRows: AuthKeyStatus[] = [];
const unknownRows: AuthKeyStatus[] = [];
for (const s of statuses) {
if (isTool(s.id)) continue;
if (providerEnv.has(s.id) || (!s.info && providers === undefined)) providerRows.push(s);
else if (!s.info) unknownRows.push(s);
// (pre-interlude catalogs: a curated-but-unclassified status still
// renders as a provider row so nothing disappears)
else providerRows.push(s);
}
return {
providerRows,
toolRows: TOOL_IDS.map((id) => byId.get(id) ?? synth(id)),
unknownRows,
addable: (providers ?? []).filter((p) => !byId.has(p.envVar)),
};
}
/** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an
* environment-owned value — the row itself is read-only then). Pure. */
export function keyStatusLine(status: AuthKeyStatus): string {
if (!status.present || !status.masked) return "not set";
const tail = status.masked.replace(/ \((\d+) chars\)$/, "");
const chars = status.masked.match(/\((\d+) chars\)$/)?.[1] ?? "?";
return `${tail} · ${chars}${status.source === "env" ? " · env" : ""}`;
}
/** One table row: label left, tiny docs link + status right. Writable
* rows click-unfold an editor beneath (owner interaction); env-owned
* rows are dimmed and read-only (a paste box there would mislead). */
function KeyTableRow({
label,
docsUrl,
status,
expanded,
onToggle,
onSave,
onDelete,
testid,
}: {
label: string;
/** The "Get a key ↗" target — curated keys carry one. */
docsUrl?: string;
status: AuthKeyStatus;
expanded: boolean;
onToggle: () => void;
onSave: (value: string) => Promise<string | null>;
onDelete?: () => Promise<void>;
testid: string;
}) {
const envOwned = status.source === "env";
const [value, setValue] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function save() {
if (!value.trim() || busy) return;
setBusy(true);
setError(null);
const err = await onSave(value);
if (err) setError(err);
else setValue("");
setBusy(false);
}
return (
<div data-testid={testid} className={envOwned ? "opacity-40" : undefined}>
<button
type="button"
aria-expanded={expanded}
data-testid={`${testid}-row`}
onClick={() => !envOwned && onToggle()}
className={`flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left ${
envOwned ? "cursor-default" : "cursor-pointer transition-colors hover:bg-accent/50"
}`}
>
<span className="font-ui text-xs text-foreground">{label}</span>
<span className="flex items-center gap-2">
{docsUrl && (
<a
href={docsUrl}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-[10px] text-muted-foreground/60 underline-offset-2 hover:text-foreground hover:underline"
>
key ↗
</a>
)}
<span data-testid={`${testid}-status`} className="font-mono text-[11px] text-muted-foreground">
{keyStatusLine(status)}
</span>
</span>
</button>
{expanded && !envOwned && (
<div className="mt-0.5 mb-1 flex items-center gap-2 pl-6" data-testid={`${testid}-editor`}>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void save();
}}
placeholder="Paste key…"
aria-label={`${label} key`}
data-testid={`${testid}-input`}
disabled={busy}
className="h-7 w-64 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
<button
type="button"
onClick={() => void save()}
disabled={busy || !value.trim()}
data-testid={`${testid}-save`}
className="text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
>
Save
</button>
{status.present && status.source === "file" && onDelete && (
<button
type="button"
onClick={() => void onDelete()}
disabled={busy}
data-testid={`${testid}-delete`}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
>
Delete
</button>
)}
</div>
)}
{error && (
<p role="alert" data-testid={`${testid}-error`} className="pl-6 text-xs text-red-500">
{error}
</p>
)}
</div>
);
}
/** The Add-provider control — the table's last row, the app's shared
* dropdown look (portaled menu, the search + arrow-walk machine). */
function AddProviderRow({
addable,
onPick,
}: {
addable: ProviderKeyInfo[];
onPick: (p: ProviderKeyInfo) => void;
}) {
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
const target = e.target as Node;
if (anchorRef.current?.contains(target)) return;
if (target instanceof Element && target.closest("[data-lh-menu]")) return;
if (target instanceof Element && target.closest('[role="dialog"]')) setOpen(false);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
setOpen(false);
}
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
const style = open && anchorRef.current ? (() => {
const r = anchorRef.current.getBoundingClientRect();
return { position: "fixed" as const, right: window.innerWidth - r.right, top: r.bottom + 4, zIndex: 60 };
})() : null;
return (
<div ref={anchorRef} className="relative">
<button
type="button"
aria-expanded={open}
data-testid="add-provider"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left font-ui text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<span>+ Add provider key</span>
{addable.length > 0 && <span className="font-mono text-[11px]">{addable.length}</span>}
</button>
{open && style !== null && createPortal(
<div
data-testid="add-provider-menu"
data-lh-menu=""
style={style}
className="flex w-72 flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
<ProviderMenuList providers={addable} onPick={onPick} />
</div>,
document.body,
)}
</div>
);
}
export function KeysSection() {
const [statuses, setStatuses] = useState<AuthKeyStatus[] | null>(null);
const [providers, setProviders] = useState<ProviderKeyInfo[] | undefined>(undefined);
const [loading, setLoading] = useState(true);
/** The unfolded row (its id), null = all folded. */
const [openRow, setOpenRow] = useState<string | null>(null);
/** The paste editor for a freshly chosen Add-provider target. */
const [adding, setAdding] = useState<ProviderKeyInfo | null>(null);
const [addError, setAddError] = useState<string | null>(null);
// Escape folds any open editor (unfolded row or the Add-provider paste)
// WITHOUT closing the dialog. Claimed at document CAPTURE phase — the
// only stopPropagation that outruns both the input's React handler and
// the dialog's window-level listener.
useEffect(() => {
if (openRow === null && adding === null) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.preventDefault();
e.stopPropagation();
setOpenRow(null);
setAdding(null);
};
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
}, [openRow, adding]);
const reload = () =>
fetch("/api/keys")
.then((r) => r.json())
.then((body: KeysResponse) => {
setStatuses(body.keys ?? []);
if (body.providers) setProviders(body.providers);
})
.catch(() => setStatuses([]))
.finally(() => setLoading(false));
useEffect(() => {
void reload();
}, []);
async function saveKey(id: string, value: string): Promise<string | null> {
const res = await fetch(`/api/keys/${id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ value }),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
return body.error ?? `Save failed (${res.status})`;
}
await reload();
return null;
}
async function deleteKey(id: string): Promise<void> {
await fetch(`/api/keys/${id}`, { method: "DELETE" }).catch(() => undefined);
await reload();
}
if (loading || statuses === null) {
return <p className="px-6 py-5 text-xs text-muted-foreground/70">Loading keys…</p>;
}
const { providerRows, toolRows, unknownRows, addable } = classifyKeys(statuses, providers);
async function saveNewProvider(p: ProviderKeyInfo, value: string) {
const err = await saveKey(p.envVar, value);
if (err) setAddError(err);
else {
setAdding(null);
setAddError(null);
}
}
return (
<>
<SettingsGroup label="Model providers" testid="group-provider-keys">
{providerRows.map((status) => (
<KeyTableRow
key={status.id}
label={providers?.find((p) => p.envVar === status.id)?.label ?? status.info?.label ?? status.id}
docsUrl={status.info?.docsUrl}
status={status}
expanded={openRow === status.id}
onToggle={() => setOpenRow(openRow === status.id ? null : status.id)}
onSave={async (v) => {
const err = await saveKey(status.id, v);
if (!err) setOpenRow(null); // fold on success — rest state
return err;
}}
onDelete={() => deleteKey(status.id)}
testid={`key-${status.id.toLowerCase()}`}
/>
))}
{providers !== undefined && (
<AddProviderRow
addable={addable}
onPick={(p) => {
setAdding(p);
setAddError(null);
}}
/>
)}
{adding && (
<div className="mt-0.5 mb-1 flex items-center gap-2 pl-6" data-testid={`key-${adding.id}-editor`}>
<span className="font-ui text-xs text-foreground">{adding.label}</span>
<AddProviderEditor provider={adding} onSave={(v) => void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} />
</div>
)}
</SettingsGroup>
<SettingsGroup label="Tools & services" testid="group-tool-keys">
{toolRows.map((status) => (
<KeyTableRow
key={status.id}
label={status.info?.label ?? status.id}
docsUrl={status.info?.docsUrl}
status={status}
expanded={openRow === status.id}
onToggle={() => setOpenRow(openRow === status.id ? null : status.id)}
onSave={async (v) => {
const err = await saveKey(status.id, v);
if (!err) setOpenRow(null);
return err;
}}
onDelete={() => deleteKey(status.id)}
testid={`key-${status.id.toLowerCase()}`}
/>
))}
</SettingsGroup>
{unknownRows.length > 0 && (
<SettingsGroup label="Other stored variables" testid="group-unknown-keys">
{unknownRows.map((status) => (
<div
key={status.id}
data-testid={`unknown-${status.id}`}
className="flex items-center justify-between gap-3 px-1 py-1.5 opacity-40"
>
<span className="font-mono text-xs text-foreground">{status.id}</span>
<span className="font-mono text-[11px] text-muted-foreground">{status.masked}</span>
</div>
))}
</SettingsGroup>
)}
</>
);
}
/** The paste field for a provider chosen in the Add dropdown — rendered
* beneath the Add row until saved or cancelled. */
function AddProviderEditor({
provider,
onSave,
onCancel,
error,
}: {
provider: ProviderKeyInfo;
onSave: (value: string) => void;
onCancel: () => void;
error: string | null;
}) {
const [value, setValue] = useState("");
return (
<>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && value.trim()) onSave(value);
}}
placeholder="Paste key…"
aria-label={`${provider.label} key`}
data-testid={`key-${provider.id}-input`}
className="h-7 w-64 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
/>
<button
type="button"
onClick={() => value.trim() && onSave(value)}
disabled={!value.trim()}
data-testid={`key-${provider.id}-save`}
className="text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
>
Save
</button>
<button
type="button"
onClick={onCancel}
data-testid={`key-${provider.id}-cancel`}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
>
Cancel
</button>
{error && (
<p role="alert" className="w-full pl-0 text-xs text-red-500" data-testid={`key-${provider.id}-error`}>
{error}
</p>
)}
</>
);
}