ae67bff5a3
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.
430 lines
27 KiB
TypeScript
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/<name></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/<name></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" }];
|
|
}
|
|
}
|