Files
forge/apps/web/components/screens/auth.tsx
T
nihalashetty ae67bff5a3 feat: deep-agent canvas, live observability, and multi-environment tooling
Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
2026-07-28 01:49:19 +05:30

430 lines
27 KiB
TypeScript

"use client";
/* Auth Providers - master/detail: left list, right Strategy + Credentials forms + masked test. */
import { useCallback, useEffect, useMemo, useState } from "react";
import { Icon } from "../icons";
import { Field, Modal, StatusPill, Tile, Toggle } from "../primitives";
import { VersionHistory } from "../version-history";
import { api, AuthProviderT, Tool } from "@/lib/api";
const KIND_LABEL: Record<string, string> = {
csrf_session: "CSRF + session", oauth2_client_credentials: "OAuth2 client-creds", oauth2_authorization_code: "OAuth2 (user login)", bearer: "Bearer token", basic: "Basic auth", api_key: "API key", custom_script: "Custom script",
};
// One-line "use this when…" per strategy - shown in the create picker so the choice is legible.
const KIND_DESC: Record<string, string> = {
bearer: "A static token sent in a header. The simplest option.",
api_key: "A key sent as a header or query param.",
basic: "Username + password (HTTP Basic).",
oauth2_client_credentials: "Machine-to-machine - Forge trades a client id/secret for a short-lived token.",
oauth2_authorization_code: "A user signs in on the provider's consent page; tokens auto-refresh.",
csrf_session: "Log in to a web app, capture its CSRF token + session cookie, and replay them. For targets with no real API auth.",
};
const TEMPLATES: Record<string, any> = {
bearer: { kind: "bearer", token_ref: "secret://proj/token", header_name: "Authorization", prefix: "Bearer " },
api_key: { kind: "api_key", in: "header", name: "X-API-Key", value_ref: "secret://proj/api_key" },
basic: { kind: "basic", username_ref: "secret://proj/user", password_ref: "secret://proj/pass" },
oauth2_client_credentials: { kind: "oauth2_client_credentials", token_url: "https://idp.example.com/oauth/token", scope: "read", client_id_ref: "secret://proj/client_id", client_secret_ref: "secret://proj/client_secret" },
oauth2_authorization_code: { kind: "oauth2_authorization_code", authorize_url: "https://accounts.example.com/o/oauth2/v2/auth", token_url: "https://oauth2.example.com/token", scope: "openid email", client_id_ref: "secret://proj/client_id", client_secret_ref: "secret://proj/client_secret" },
csrf_session: {
kind: "csrf_session", credentials_ref: "secret://proj/creds",
token_fetch: { method: "POST", url: "https://app.example.com/login", headers: { "Content-Type": "application/json" }, body: { username: "{{cred.username}}", password: "{{cred.password}}" } },
extract: [{ name: "csrf", from: "header", header: "X-CSRF-Token" }, { name: "session", from: "cookie", cookie: "SESSIONID" }],
inject: [{ to: "header", name: "X-CSRF-Token", value: "{{extracted.csrf}}" }, { to: "cookie", name: "SESSIONID", value: "{{extracted.session}}" }],
cache_ttl_seconds: 1800, refresh_on: [401, 403],
},
};
export function AuthProvidersScreen({ project }: { project: any }) {
const [rows, setRows] = useState<AuthProviderT[]>([]);
const [tools, setTools] = useState<Tool[]>([]);
const [selId, setSelId] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const reload = useCallback(() => {
if (!project?.id) return;
api.listAuthProviders(project.id).then((r) => { setRows(r); setSelId((s) => s && r.some((x) => x.id === s) ? s : (r[0]?.id ?? null)); }).catch(() => {});
api.listTools(project.id).then(setTools).catch(() => {});
}, [project?.id]);
useEffect(() => { reload(); }, [reload]);
const toolCount = useMemo(() => {
const m: Record<string, number> = {};
tools.forEach((t) => { if (t.auth_provider_id) m[t.auth_provider_id] = (m[t.auth_provider_id] || 0) + 1; });
return m;
}, [tools]);
const sel = rows.find((r) => r.id === selId) || null;
return (
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
{/* LEFT list */}
<div style={{ width: 280, flex: "none", borderRight: "1px solid var(--line)", display: "flex", flexDirection: "column", background: "var(--bg-1)" }}>
<div className="row spread" style={{ padding: "14px 16px", borderBottom: "1px solid var(--line)" }}>
<div className="t-display">Auth Providers</div>
<button className="btn btn-primary btn-sm" onClick={() => setCreateOpen(true)}><Icon name="plus" size={14} /></button>
</div>
<div className="scroll-y" style={{ flex: 1, padding: 8 }}>
{rows.length === 0 && <div className="fg-2 t-caption" style={{ padding: 12 }}>No providers yet. Click + to add one.</div>}
{rows.map((p) => {
const on = selId === p.id;
return (
<button key={p.id} onClick={() => setSelId(p.id)} className="col" style={{ width: "100%", textAlign: "left", padding: "11px 12px", borderRadius: 9, marginBottom: 4, border: "1px solid " + (on ? "var(--accent)" : "transparent"), background: on ? "var(--accent-glow)" : "transparent", cursor: "pointer", gap: 4 }}>
<div className="row spread"><span className="mono-sm" style={{ fontWeight: 700, color: "var(--fg-0)" }}>{p.name}</span><StatusPill status={(() => { const lt = (p.config as any)?._last_test; return lt ? (lt.ok ? "pass" : "fail") : "untested"; })()} /></div>
<div className="row spread">
<div className="row gap2" style={{ fontSize: 11, color: "var(--fg-2)" }}><span>{KIND_LABEL[p.kind] || p.kind}</span><span>· {toolCount[p.id] || 0} tools</span></div>
<span
className="iconbtn" role="button" title="Delete provider"
onClick={async (e) => {
e.stopPropagation();
const used = toolCount[p.id] || 0;
const warn = used ? ` ${used} tool(s) reference it and will lose auth.` : "";
if (!window.confirm(`Delete auth provider “${p.name}”?${warn}`)) return;
await api.deleteAuthProvider(project.id, p.id);
reload();
}}
><Icon name="trash" size={13} /></span>
</div>
</button>
);
})}
</div>
</div>
{/* RIGHT detail */}
<div className="grow scroll-y" style={{ padding: 24, minWidth: 0 }}>
{sel ? <ProviderDetail key={sel.id} project={project} provider={sel} onSaved={reload} /> : (
<div className="col center" style={{ height: "100%", gap: 8, color: "var(--fg-2)" }}><Tile icon="auth" color="var(--accent)" size={48} glow /><div className="t-h2">Select or add a provider</div></div>
)}
</div>
<CreateModal open={createOpen} onClose={() => setCreateOpen(false)} onCreate={async (name, kind) => {
const cfg = TEMPLATES[kind] || { kind };
const ap = await api.createAuthProvider(project.id, { name: name || kind, kind, config: cfg, credentials_ref: cfg.credentials_ref });
setCreateOpen(false); reload(); setSelId(ap.id);
}} />
</div>
);
}
function CreateModal({ open, onClose, onCreate }: { open: boolean; onClose: () => void; onCreate: (name: string, kind: string) => void }) {
const [name, setName] = useState("");
const [kind, setKind] = useState("bearer");
return (
<Modal open={open} onClose={onClose} title="New auth provider" width={520}
footer={<><button className="btn btn-ghost" onClick={onClose}>Cancel</button><button className="btn btn-primary" onClick={() => onCreate(name.trim().replace(/\s+/g, "_"), kind)}>Create</button></>}>
<Field label="Strategy" help="How the target API expects to be authenticated - pick whichever scheme it requires.">
<div className="col gap2">
{Object.keys(TEMPLATES).map((k) => {
const on = kind === k;
return (
<button key={k} type="button" onClick={() => setKind(k)} className="col"
style={{ width: "100%", textAlign: "left", padding: "10px 12px", borderRadius: 9, gap: 3, cursor: "pointer", border: "1px solid " + (on ? "var(--accent)" : "var(--line)"), background: on ? "var(--accent-glow)" : "var(--bg-1)" }}>
<span style={{ fontWeight: 700, color: "var(--fg-0)" }}>{KIND_LABEL[k]}</span>
<span className="t-caption fg-2">{KIND_DESC[k]}</span>
</button>
);
})}
</div>
</Field>
<Field label="Name"><input className="input mono" value={name} onChange={(e) => setName(e.target.value)} placeholder="orders_api" /></Field>
</Modal>
);
}
function ProviderDetail({ project, provider, onSaved }: { project: any; provider: AuthProviderT; onSaved: () => void }) {
const [cfg, setCfg] = useState<any>(() => ({ ...(provider.config || {}), kind: provider.kind }));
const [name, setName] = useState(provider.name);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [test, setTest] = useState<any>(null);
const [reveal, setReveal] = useState(false);
const kind = cfg.kind;
function setPath(path: string[], value: any) {
setCfg((c: any) => {
const next = structuredClone(c); let o = next;
for (let i = 0; i < path.length - 1; i++) { o[path[i]] = o[path[i]] ?? {}; o = o[path[i]]; }
o[path[path.length - 1]] = value; return next;
});
setSaved(false);
}
const get = (path: string[], dflt: any = "") => path.reduce((o, k) => (o == null ? o : o[k]), cfg) ?? dflt;
// Per-user ("external") auth: bearer/api_key providers can be marked so EACH user supplies their
// own token (stored per-user) instead of one shared secret - the mechanism behind per-user MCP +
// on-behalf-of calls. Marked by per_user_context_keys containing "end_user_id".
const supportsPerUser = kind === "bearer" || kind === "api_key";
const perUser = ((cfg.per_user_context_keys as string[]) || []).includes("end_user_id");
function setPerUser(on: boolean) {
setCfg((c: any) => {
const next = structuredClone(c);
if (on) next.per_user_context_keys = ["end_user_id"];
else delete next.per_user_context_keys;
return next;
});
setSaved(false);
}
async function save() {
setSaving(true);
try {
const updated = await api.updateAuthProvider(project.id, provider.id, { name, kind, config: cfg, credentials_ref: cfg.credentials_ref });
setSaved(true); onSaved();
} catch { /* */ } finally { setSaving(false); }
}
async function runTest() {
try {
const r = await api.testAuthProvider(project.id, provider.id, {});
setTest(r);
// Persist a lightweight pass/fail marker (merged into the last-saved config, not the
// in-progress edits) so the list StatusPill reflects the real result - mirrors how
// tools store _last_test. Best-effort: the test result still shows regardless.
try {
await api.updateAuthProvider(project.id, provider.id, { config: { ...(provider.config || {}), _last_test: { ok: !!r?.ok, at: Date.now() } } });
onSaved();
} catch { /* marker is best-effort */ }
} catch (e: any) {
setTest({ ok: false, error: e?.message || String(e) });
}
}
// csrf_session uses extract/inject arrays; surface the first CSRF rule for editing.
const extract = cfg.extract || [];
const csrfRule = extract.find((e: any) => e.from === "header") || extract[0] || {};
const jsonRule = extract.find((e: any) => e.from === "json") || {};
function setExtractField(field: string, value: string) {
const ext = [...(cfg.extract || [])];
const idx = ext.findIndex((e: any) => e === csrfRule);
if (idx >= 0) ext[idx] = { ...ext[idx], [field]: value };
setPath(["extract"], ext);
}
return (
<div style={{ maxWidth: 960 }}>
<div className="row spread" style={{ marginBottom: 18 }}>
<div className="row gap3">
<Tile icon="auth" color="var(--accent)" size={40} glow />
<div><div className="t-display mono" style={{ fontSize: 18 }}>{provider.name}</div><div className="fg-2 t-caption">{KIND_LABEL[kind] || kind} · ttl {cfg.cache_ttl_seconds || 1800}s</div></div>
</div>
<div className="row gap2">
<VersionHistory entityType="auth_provider" entityId={provider.id} entityLabel={provider.name} buttonClassName="btn btn-secondary" onRestored={onSaved} />
<button className="btn btn-secondary" onClick={runTest}><Icon name="validate" size={15} />Test connection</button>
<button className="btn btn-primary" onClick={save} disabled={saving}><Icon name="save" size={15} />{saving ? "Saving…" : saved ? "Saved ✓" : "Save"}</button>
</div>
</div>
{test && (
<div className="card" style={{ padding: 12, marginBottom: 16, background: "var(--bg-3)" }}>
{test.ok
? <div className="col gap1"><div className="t-caption fg-2">Would inject (masked):</div><pre className="mono-sm" style={{ margin: 0 }}>{JSON.stringify({ headers: test.headers, cookies: test.cookies, params: test.params }, null, 2)}</pre></div>
: <div className="t-caption" style={{ color: "var(--err)" }}>{test.error}</div>}
</div>
)}
{/* Strategy */}
<div className="card" style={{ padding: 18, marginBottom: 16 }}>
<div className="t-h2" style={{ marginBottom: 14 }}>Strategy</div>
<Field label="Name"><input className="input mono" value={name} onChange={(e) => { setName(e.target.value); setSaved(false); }} /></Field>
<Field label="Type">
<div style={{ position: "relative" }}>
<select className="select" value={kind} onChange={(e) => { const k = e.target.value; setCfg({ ...(TEMPLATES[k] || { kind: k }) }); setSaved(false); }}>
{Object.entries(KIND_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
</Field>
{kind === "csrf_session" && (
<>
<div className="row gap4">
<Field label="Login URL"><input className="input mono" value={get(["token_fetch", "url"])} onChange={(e) => setPath(["token_fetch", "url"], e.target.value)} /></Field>
<Field label="Method"><input className="input mono" value={get(["token_fetch", "method"], "POST")} onChange={(e) => setPath(["token_fetch", "method"], e.target.value)} /></Field>
</div>
<div className="row gap4">
<Field label="CSRF header"><input className="input mono" value={csrfRule.header || ""} onChange={(e) => setExtractField("header", e.target.value)} /></Field>
<Field label="CSRF JSON path"><input className="input mono" value={jsonRule.json_path || ""} onChange={(e) => setExtractField("json_path", e.target.value)} placeholder="data.csrfToken" /></Field>
</div>
<Field label="Session TTL" help="Auto re-login on expiry or 401."><input className="input mono" value={get(["cache_ttl_seconds"], 1800)} onChange={(e) => setPath(["cache_ttl_seconds"], Number(e.target.value) || 0)} /></Field>
</>
)}
{kind === "oauth2_client_credentials" && (
<>
<div className="row gap4">
<Field label="Token URL"><input className="input mono" value={get(["token_url"])} onChange={(e) => setPath(["token_url"], e.target.value)} /></Field>
<Field label="Scope"><input className="input mono" value={get(["scope"])} onChange={(e) => setPath(["scope"], e.target.value)} /></Field>
</div>
</>
)}
{kind === "oauth2_authorization_code" && (
<>
<Field label="Authorize URL" help="The provider's consent page the user is redirected to."><input className="input mono" value={get(["authorize_url"])} onChange={(e) => setPath(["authorize_url"], e.target.value)} /></Field>
<div className="row gap4">
<Field label="Token URL"><input className="input mono" value={get(["token_url"])} onChange={(e) => setPath(["token_url"], e.target.value)} /></Field>
<Field label="Scope"><input className="input mono" value={get(["scope"])} onChange={(e) => setPath(["scope"], e.target.value)} /></Field>
</div>
<OAuthConnect project={project} provider={provider} />
</>
)}
{kind === "api_key" && (
<div className="row gap4">
<Field label="In"><div style={{ position: "relative" }}><select className="select" value={get(["in"], "header")} onChange={(e) => setPath(["in"], e.target.value)}><option value="header">header</option><option value="query">query</option></select></div></Field>
<Field label="Param name"><input className="input mono" value={get(["name"])} onChange={(e) => setPath(["name"], e.target.value)} /></Field>
</div>
)}
{kind === "bearer" && (
<div className="row gap4">
<Field label="Header name"><input className="input mono" value={get(["header_name"], "Authorization")} onChange={(e) => setPath(["header_name"], e.target.value)} /></Field>
<Field label="Prefix"><input className="input mono" value={get(["prefix"], "Bearer ")} onChange={(e) => setPath(["prefix"], e.target.value)} /></Field>
</div>
)}
{supportsPerUser && (
<label className="card row spread" style={{ padding: 12, marginTop: 12, background: "var(--bg-3)", cursor: "pointer" }}>
<div className="col gap1" style={{ maxWidth: 640 }}>
<span className="t-body-sm" style={{ fontWeight: 600 }}>Per-user credential</span>
<span className="t-caption fg-2">Each user supplies their OWN token instead of one shared secret tools then act as the calling user downstream (works for MCP and on-behalf-of runs). Users set theirs on their token page; you can set yours below.</span>
</div>
<Toggle on={perUser} onChange={setPerUser} />
</label>
)}
</div>
{/* Credentials */}
<div className="card" style={{ padding: 18 }}>
<div className="row spread" style={{ marginBottom: 12 }}><div className="t-h2">Credentials</div><span className="chip" style={{ color: "var(--fg-2)" }}><Icon name="secret" size={12} />{perUser ? "per-user (each user connects)" : "from secret store"}</span></div>
{perUser ? (
<PerUserConnect project={project} provider={provider} />
) : (
<>
{credentialFields(kind).map((cf) => (
<Field key={cf.path} label={cf.label} help={cf.help}>
<div className="row gap2">
<input className="input mono" type={reveal ? "text" : "password"} value={get([cf.path])} onChange={(e) => setPath([cf.path], e.target.value)} style={{ flex: 1 }} placeholder="secret://proj/…" />
<button className="iconbtn" style={{ border: "1px solid var(--line-strong)" }} title={reveal ? "Hide" : "Reveal"} onClick={() => setReveal((r) => !r)}><Icon name={reveal ? "eyeoff" : "eye"} size={15} /></button>
</div>
</Field>
))}
<div className="fg-2 t-caption" style={{ marginTop: 4 }}>Secret values live in Settings Secrets. Reference them as <span className="mono-sm">secret://proj/&lt;name&gt;</span>.</div>
</>
)}
</div>
{/* Extra headers: fixed headers stamped on EVERY call, alongside the auth header. Values may
be a literal or a secret:// ref, so a shared service token stays in the store (not per-tool). */}
<div className="card" style={{ padding: 18, marginTop: 16 }}>
<div className="row spread" style={{ marginBottom: 8 }}><div className="t-h2">Extra headers</div><span className="chip" style={{ color: "var(--fg-2)" }}><Icon name="secret" size={12} />literal or secret://</span></div>
<div className="fg-2 t-caption" style={{ marginBottom: 12 }}>Sent on every call alongside the auth header use for fixed service / attestation headers (e.g. a client id, a service token). Each value is a literal <em>or</em> a <span className="mono-sm">secret://proj/&lt;name&gt;</span> ref, so a shared secret stays in Settings → Secrets instead of hardcoded per tool.</div>
<ExtraHeadersEditor value={cfg.extra_headers || {}} onChange={(v) => setPath(["extra_headers"], v)} />
</div>
</div>
);
}
/* Rows editor for a provider's extra_headers (name -> value). Values are literals or secret:// refs;
the resolver stamps them on every call and resolves any secret ref from the store. */
function ExtraHeadersEditor({ value, onChange }: { value: Record<string, string>; onChange: (v: Record<string, string>) => void }) {
const rows = Object.entries(value || {});
const rebuild = (next: [string, string][]) => onChange(Object.fromEntries(next.filter(([k]) => k.trim())));
return (
<div className="col gap2">
{rows.map(([k, v], i) => (
<div key={i} className="row gap2">
<input className="input mono" placeholder="Header name" value={k} style={{ flex: 1 }}
onChange={(e) => rebuild(rows.map((r, idx): [string, string] => (idx === i ? [e.target.value, r[1]] : r)))} />
<input className="input mono" placeholder="value or secret://proj/name" value={v} style={{ flex: 2 }}
onChange={(e) => rebuild(rows.map((r, idx): [string, string] => (idx === i ? [r[0], e.target.value] : r)))} />
<button className="iconbtn" style={{ border: "1px solid var(--line-strong)" }} title="Remove"
onClick={() => rebuild(rows.filter((_, idx) => idx !== i))}><Icon name="trash" size={14} /></button>
</div>
))}
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }}
onClick={() => onChange({ ...(value || {}), "": "" })}><Icon name="plus" size={13} />Add header</button>
</div>
);
}
function OAuthConnect({ project, provider }: { project: any; provider: AuthProviderT }) {
const [status, setStatus] = useState<{ connected: boolean; scope?: string | null } | null>(null);
const [err, setErr] = useState<string | null>(null);
const refresh = useCallback(() => { api.oauthStatus(project.id, provider.id).then(setStatus).catch(() => setStatus(null)); }, [project.id, provider.id]);
useEffect(() => { refresh(); }, [refresh]);
async function connect() {
setErr(null);
try {
const { authorize_url } = await api.oauthStart(project.id, provider.id);
const w = window.open(authorize_url, "_blank", "width=620,height=760");
// Poll status a few times after the popup so the badge flips to "connected".
const t = setInterval(() => refresh(), 2500);
setTimeout(() => { clearInterval(t); try { w?.close(); } catch { /* ignore */ } }, 60000);
} catch {
setErr("Could not start OAuth - save the provider first and set the client_id secret in Settings → Secrets.");
}
}
return (
<div className="card" style={{ padding: 14, marginTop: 8, background: "var(--bg-3)" }}>
<div className="row spread">
<div className="row gap2">
<Icon name="link" size={15} />
<span className="t-body-sm" style={{ fontWeight: 600 }}>User authorization</span>
{status?.connected ? <span className="pill pill-ok">connected</span> : <span className="pill pill-muted">not connected</span>}
</div>
<button className="btn btn-primary btn-sm" onClick={connect}><Icon name="external" size={13} />{status?.connected ? "Reconnect" : "Connect"}</button>
</div>
{status?.scope && <div className="fg-2 t-caption" style={{ marginTop: 6 }}>scope: {status.scope}</div>}
{err && <div className="t-caption" style={{ color: "var(--err)", marginTop: 6 }}>{err}</div>}
<div className="fg-2 t-caption" style={{ marginTop: 6 }}>Save the provider, set the client_id/secret secrets, then Connect. A popup completes the grant; tokens auto-refresh.</div>
</div>
);
}
/* Per-user credential: the CURRENT user's own downstream token for a per-user provider. Stored
server-side keyed by their user id (same identity the MCP PAT resolves to), so tools act as them
without a shared secret. The same box appears on the connector token page. */
function PerUserConnect({ project, provider }: { project: any; provider: AuthProviderT }) {
const [status, setStatus] = useState<{ connected: boolean } | null>(null);
const [token, setToken] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const refresh = useCallback(() => { api.getMyConnection(project.id, provider.id).then(setStatus).catch(() => setStatus(null)); }, [project.id, provider.id]);
useEffect(() => { refresh(); }, [refresh]);
async function save() {
setErr(null); setBusy(true);
try {
const res = await api.setMyConnection(project.id, provider.id, token.trim());
if (!res.ok) throw new Error(res.status === 400 ? "Save this provider as per-user first, then set your token." : "Could not save token.");
setToken(""); refresh();
} catch (e: any) { setErr(e?.message || String(e)); } finally { setBusy(false); }
}
async function clear() { setErr(null); try { await api.clearMyConnection(project.id, provider.id); } catch { /* best-effort */ } refresh(); }
return (
<div className="card" style={{ padding: 14, background: "var(--bg-3)" }}>
<div className="row spread" style={{ marginBottom: 8 }}>
<div className="row gap2"><Icon name="link" size={15} /><span className="t-body-sm" style={{ fontWeight: 600 }}>Your token</span>{status?.connected ? <span className="pill pill-ok">connected</span> : <span className="pill pill-muted">not connected</span>}</div>
{status?.connected && <button className="btn btn-ghost btn-sm" onClick={clear}>Clear</button>}
</div>
<div className="row gap2">
<input className="input mono" type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="paste your token…" style={{ flex: 1 }} />
<button className="btn btn-primary" onClick={save} disabled={busy || !token.trim()}>{busy ? "Saving…" : "Save"}</button>
</div>
{err && <div className="t-caption" style={{ color: "var(--err)", marginTop: 6 }}>{err}</div>}
<div className="fg-2 t-caption" style={{ marginTop: 6 }}>Stored per-user and encrypted; used only for calls made as you, and never shown again after saving.</div>
</div>
);
}
function credentialFields(kind: string): { path: string; label: string; help?: string }[] {
switch (kind) {
case "csrf_session": return [{ path: "credentials_ref", label: "Credentials secret ref", help: "Holds { username, password } for the login call." }];
case "bearer": return [{ path: "token_ref", label: "Token secret ref" }];
case "api_key": return [{ path: "value_ref", label: "API key secret ref" }];
case "basic": return [{ path: "username_ref", label: "Username secret ref" }, { path: "password_ref", label: "Password secret ref" }];
case "oauth2_client_credentials":
case "oauth2_authorization_code": return [{ path: "client_id_ref", label: "Client ID secret ref" }, { path: "client_secret_ref", label: "Client secret ref" }];
default: return [{ path: "credentials_ref", label: "Credentials secret ref" }];
}
}