History

feat(mcp): support manual headers for remote sources (#135)

* refactor(react): extract secret-backed header auth UI * feat(mcp): support manual headers for remote sources

Saatvik Arya committed 7 months ago

Commit 9de597b1 parent 36a79e65 files · +731 −542Browse files

Unified · Split

5 files
  1. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +88 −169
  2. packages/plugins/mcp/src/react/AddMcpSource.tsx +262 −46
  3. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +10 −327
  4. packages/react/src/plugins/secret-header-auth.tsx +350 −0
  5. packages/react/src/plugins/use-secret-picker-secrets.tsx +21 −0
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+88−169
@@ -1,10 +1,9 @@
11 import { useState } from "react";
2−import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";
2+import { useAtomSet } from "@effect-atom/atom-react";
33
4−import { secretsAtom, setSecret } from "@executor/react/api/atoms";
54 import { useScope } from "@executor/react/api/scope-context";
6−import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker";
7−import { SecretId } from "@executor/sdk";
5+import { SecretHeaderAuthRow } from "@executor/react/plugins/secret-header-auth";
6+import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";
87 import { Button } from "@executor/react/components/button";
98 import { Input } from "@executor/react/components/input";
109 import { Label } from "@executor/react/components/label";
@@ -12,144 +11,19 @@ import { Spinner } from "@executor/react/components/spinner"; · 1 unmodified line
1211 import { addGraphqlSource } from "./atoms";
1312 import type { HeaderValue } from "../sdk/types";
1413
15−// ---------------------------------------------------------------------------
16−// Inline secret creation
17−// ---------------------------------------------------------------------------
14+type HeaderEntry = {
15+ name: string;
16+ prefix?: string;
17+ presetKey?: string;
18+ secretId: string | null;
19+};
1820
19−function InlineCreateSecret(props: {
20− headerName: string;
21− suggestedId: string;
22− onCreated: (secretId: string) => void;
23− onCancel: () => void;
24−}) {
25− const [secretId, setSecretId] = useState(props.suggestedId);
26− const [secretName, setSecretName] = useState(props.headerName);
27− const [secretValue, setSecretValue] = useState("");
28− const [saving, setSaving] = useState(false);
29− const [error, setError] = useState<string | null>(null);
30− const scopeId = useScope();
31− const doSet = useAtomSet(setSecret, { mode: "promise" });
32− const refreshSecrets = useAtomRefresh(secretsAtom(scopeId));
33−
34− const handleSave = async () => {
35− if (!secretId.trim() || !secretValue.trim()) return;
36− setSaving(true);
37− setError(null);
38− try {
39− await doSet({
40− path: { scopeId },
41− payload: {
42− id: SecretId.make(secretId.trim()),
43− name: secretName.trim() || secretId.trim(),
44− value: secretValue.trim(),
45− purpose: `Auth header: ${props.headerName}`,
46− },
47− });
48− refreshSecrets();
49− props.onCreated(secretId.trim());
50− } catch (e) {
51− setError(e instanceof Error ? e.message : "Failed to save secret");
52− setSaving(false);
53− }
54− };
55−
56− return (
57− <div className="rounded-lg border border-primary/20 bg-primary/[0.02] p-3 space-y-2.5">
58− <p className="text-[11px] font-semibold text-primary tracking-wide uppercase">New secret</p>
59− <div className="grid grid-cols-2 gap-2">
60− <div className="space-y-1">
61− <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">ID</Label>
62− <Input
63− value={secretId}
64− onChange={(e) => setSecretId((e.target as HTMLInputElement).value)}
65− placeholder="my-api-token"
66− className="h-8 text-xs font-mono"
67− />
68− </div>
69− <div className="space-y-1">
70− <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Label</Label>
71− <Input
72− value={secretName}
73− onChange={(e) => setSecretName((e.target as HTMLInputElement).value)}
74− placeholder="API Token"
75− className="h-8 text-xs"
76− />
77− </div>
78− </div>
79− <div className="space-y-1">
80− <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Value</Label>
81− <Input
82− type="password"
83− value={secretValue}
84− onChange={(e) => setSecretValue((e.target as HTMLInputElement).value)}
85− placeholder="paste your token or key..."
86− className="h-8 text-xs font-mono"
87− />
88− </div>
89− {error && <p className="text-[11px] text-destructive">{error}</p>}
90− <div className="flex gap-1.5 pt-0.5">
91− <Button variant="outline" size="xs" onClick={props.onCancel}>
92− Cancel
93− </Button>
94− <Button
95− size="xs"
96− onClick={handleSave}
97− disabled={!secretId.trim() || !secretValue.trim() || saving}
98− >
99− {saving ? "Saving..." : "Create & use"}
100− </Button>
101− </div>
102− </div>
103− );
104−}
105−
106−// ---------------------------------------------------------------------------
107−// Auth header row
108−// ---------------------------------------------------------------------------
109−
110−function AuthHeaderRow(props: {
111− selectedSecretId: string | null;
112− onSelect: (secretId: string) => void;
113− existingSecrets: readonly SecretPickerSecret[];
114−}) {
115− const [creating, setCreating] = useState(false);
116− const { selectedSecretId, onSelect, existingSecrets } = props;
117−
118− if (creating) {
119− return (
120− <InlineCreateSecret
121− headerName="Authorization"
122− suggestedId="graphql-auth-token"
123− onCreated={(id) => {
124− onSelect(id);
125− setCreating(false);
126− }}
127− onCancel={() => setCreating(false)}
128− />
129− );
130− }
131−
132− return (
133− <div className="space-y-1.5">
134− <div className="flex items-center gap-1.5">
135− <div className="flex-1 min-w-0">
136− <SecretPicker
137− value={selectedSecretId}
138− onSelect={onSelect}
139− secrets={existingSecrets}
140− />
141− </div>
142− <Button variant="outline" size="sm" className="shrink-0" onClick={() => setCreating(true)}>
143− + New
144− </Button>
145− </div>
146− </div>
147− );
148−}
149−
150−// ---------------------------------------------------------------------------
151−// Main component
152−// ---------------------------------------------------------------------------
21+const initialHeader = (): HeaderEntry => ({
22+ name: "Authorization",
23+ prefix: "Bearer ",
24+ presetKey: "bearer",
25+ secretId: null,
26+});
15327
15428 export default function AddGraphqlSource(props: {
15529 onComplete: () => void;
@@ -158,34 +32,53 @@ export default function AddGraphqlSource(props: { · 2 unmodified lines
15832 }) {
15933 const [endpoint, setEndpoint] = useState(props.initialUrl ?? "");
16034 const [namespace, setNamespace] = useState("");
161− const [authSecretId, setAuthSecretId] = useState<string | null>(null);
35+ const [headers, setHeaders] = useState<HeaderEntry[]>([initialHeader()]);
16236 const [adding, setAdding] = useState(false);
16337 const [addError, setAddError] = useState<string | null>(null);
16438
16539 const scopeId = useScope();
16640 const doAdd = useAtomSet(addGraphqlSource, { mode: "promise" });
167− const secrets = useAtomValue(secretsAtom(scopeId));
41+ const secretList = useSecretPickerSecrets();
16842
169− const secretList: readonly SecretPickerSecret[] = Result.match(secrets, {
170− onInitial: () => [] as SecretPickerSecret[],
171− onFailure: () => [] as SecretPickerSecret[],
172− onSuccess: ({ value }) =>
173− value.map((s) => ({
174− id: s.id,
175− name: s.name,
176− provider: s.provider ? String(s.provider) : undefined,
177− })),
178− });
43+ const headersValid = headers.every(
44+ (header) => header.name.trim() && header.secretId,
45+ );
46+ const canAdd =
47+ endpoint.trim().length > 0 && (headers.length === 0 || headersValid);
48+
49+ const updateHeader = (
50+ index: number,
51+ update: Partial<{ name: string; prefix?: string; presetKey?: string; secretId: string | null }>,
52+ ) => {
53+ setHeaders((current) =>
54+ current.map((header, i) => (i === index ? { ...header, ...update } : header)),
55+ );
56+ };
17957
180− const canAdd = endpoint.trim().length > 0;
58+ const removeHeader = (index: number) => {
59+ setHeaders((current) => current.filter((_, i) => i !== index));
60+ };
61+
62+ const addHeader = () => {
63+ setHeaders((current) => [
64+ ...current,
65+ { name: "", prefix: undefined, presetKey: undefined, secretId: null },
66+ ]);
67+ };
18168
18269 const handleAdd = async () => {
18370 setAdding(true);
18471 setAddError(null);
18572 try {
186− const headers: Record<string, HeaderValue> = {};
187− if (authSecretId) {
188− headers["Authorization"] = { secretId: authSecretId, prefix: "Bearer " };
73+ const headerMap: Record<string, HeaderValue> = {};
74+ for (const header of headers) {
75+ const name = header.name.trim();
76+ if (name && header.secretId) {
77+ headerMap[name] = {
78+ secretId: header.secretId,
79+ ...(header.prefix ? { prefix: header.prefix } : {}),
80+ };
81+ }
18982 }
19083
19184 await doAdd({
@@ -193,7 +86,7 @@ export default function AddGraphqlSource(props: { · 1 unmodified line
19386 payload: {
19487 endpoint: endpoint.trim(),
19588 namespace: namespace.trim() || undefined,
196− ...(Object.keys(headers).length > 0 ? { headers } : {}),
89+ ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}),
19790 },
19891 });
19992 props.onComplete();
@@ -239,17 +132,43 @@ export default function AddGraphqlSource(props: { · 39 unmodified lines
239132
240133 {/* Authentication */}
241134 <section className="space-y-2.5">
242− <Label>
243− Authentication <span className="text-muted-foreground font-normal">(optional)</span>
244− </Label>
245− <p className="text-[12px] text-muted-foreground">
246− Select a secret for the Bearer token sent with every request, including introspection.
247− </p>
248− <AuthHeaderRow
249− selectedSecretId={authSecretId}
250− onSelect={setAuthSecretId}
251− existingSecrets={secretList}
252− />
135+ <div className="flex items-center justify-between gap-3">
136+ <div>
137+ <Label>
138+ Authentication <span className="text-muted-foreground font-normal">(optional)</span>
139+ </Label>
140+ <p className="mt-1 text-[12px] text-muted-foreground">
141+ Secret-backed headers sent with every request, including introspection.
142+ </p>
143+ </div>
144+ <Button
145+ type="button"
146+ variant="outline"
147+ size="sm"
148+ className="shrink-0"
149+ onClick={addHeader}
150+ >
151+ + Add header
152+ </Button>
153+ </div>
154+
155+ {headers.length > 0 && (
156+ <div className="space-y-2">
157+ {headers.map((header, index) => (
158+ <SecretHeaderAuthRow
159+ key={index}
160+ name={header.name}
161+ prefix={header.prefix}
162+ presetKey={header.presetKey}
163+ secretId={header.secretId}
164+ onChange={(update) => updateHeader(index, update)}
165+ onSelectSecret={(secretId) => updateHeader(index, { secretId })}
166+ onRemove={() => removeHeader(index)}
167+ existingSecrets={secretList}
168+ />
169+ ))}
170+ </div>
171+ )}
253172 </section>
254173
255174 {/* Error */}
packages/plugins/mcp/src/react/AddMcpSource.tsx+262−46
@@ -6,7 +6,10 @@ import { Button } from "@executor/react/components/button"; · 5 unmodified lines
66 import { Input } from "@executor/react/components/input";
77 import { Label } from "@executor/react/components/label";
88 import { Badge } from "@executor/react/components/badge";
9+import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group";
910 import { Spinner } from "@executor/react/components/spinner";
11+import { SecretHeaderAuthRow } from "@executor/react/plugins/secret-header-auth";
12+import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";
1013 import { probeMcpEndpoint, addMcpSource, startMcpOAuth } from "./atoms";
1114 import { mcpPresets, type McpPreset } from "../sdk/presets";
1215
@@ -40,6 +43,13 @@ type ProbeResult = { · 27 unmodified lines
4043 serverName: string | null;
4144 };
4245
46+type RemoteAuthMode = "none" | "header" | "oauth2";
47+
48+type PlainHeader = {
49+ name: string;
50+ value: string;
51+};
52+
4353 type State =
4454 | { step: "url"; url: string }
4555 | { step: "probing"; url: string }
@@ -230,6 +240,21 @@ export default function AddMcpSource(props: { · 184 unmodified lines
230240 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" });
231241 const doAdd = useAtomSet(addMcpSource, { mode: "promise" });
232242 const doStartOAuth = useAtomSet(startMcpOAuth, { mode: "promise" });
243+ const secretList = useSecretPickerSecrets();
244+
245+ const [remoteAuthMode, setRemoteAuthMode] = useState<RemoteAuthMode>("none");
246+ const [remoteHeaderAuth, setRemoteHeaderAuth] = useState<{
247+ name: string;
248+ prefix?: string;
249+ presetKey?: string;
250+ secretId: string | null;
251+ }>({
252+ name: "Authorization",
253+ prefix: "Bearer ",
254+ presetKey: "bearer",
255+ secretId: null,
256+ });
257+ const [remoteHeaders, setRemoteHeaders] = useState<PlainHeader[]>([]);
233258
234259 const probe = "probe" in state ? state.probe : null;
235260 const tokens = "tokens" in state ? state.tokens : null;
@@ -237,8 +262,16 @@ export default function AddMcpSource(props: { · 1 unmodified line
237262 const isProbing = state.step === "probing";
238263 const isAdding = state.step === "adding";
239264 const isOAuthBusy = state.step === "oauth-starting" || state.step === "oauth-waiting";
240− const needsOAuth = probe?.requiresOAuth === true && !tokens;
241− const canAdd = probe && !needsOAuth && !isAdding && !isOAuthBusy;
265+ const canUseNone = probe?.requiresOAuth !== true;
266+ const headerAuthComplete = Boolean(remoteHeaderAuth.name.trim() && remoteHeaderAuth.secretId);
267+ const remoteHeadersComplete = remoteHeaders.every(
268+ (header) => header.name.trim() && header.value.trim(),
269+ );
270+ const authReady =
271+ remoteAuthMode === "none" ? canUseNone
272+ : remoteAuthMode === "header" ? headerAuthComplete
273+ : tokens !== null;
274+ const canAdd = Boolean(probe) && authReady && remoteHeadersComplete && !isAdding && !isOAuthBusy;
242275 const error = state.step === "error" ? state.error : null;
243276
244277 // ---- Remote actions ----
@@ -250,6 +283,7 @@ export default function AddMcpSource(props: { · 5 unmodified lines
250283 path: { scopeId },
251284 payload: { endpoint: state.url.trim() },
252285 });
286+ setRemoteAuthMode(result.requiresOAuth ? "oauth2" : "none");
253287 dispatch({ type: "probe-ok", probe: result });
254288 } catch (e) {
255289 dispatch({ type: "probe-fail", error: e instanceof Error ? e.message : "Failed to connect" });
@@ -316,13 +350,15 @@ export default function AddMcpSource(props: { · 60 unmodified lines
316350 if (!probe) return;
317351 dispatch({ type: "add-start" });
318352 try {
319− await doAdd({
320− path: { scopeId },
321− payload: {
322− transport: "remote" as const,
323− name: probe.serverName ?? probe.name,
324− endpoint: state.url.trim(),
325− auth: tokens
353+ const auth =
354+ remoteAuthMode === "header"
355+ ? {
356+ kind: "header" as const,
357+ headerName: remoteHeaderAuth.name.trim(),
358+ secretId: remoteHeaderAuth.secretId!,
359+ ...(remoteHeaderAuth.prefix ? { prefix: remoteHeaderAuth.prefix } : {}),
360+ }
361+ : remoteAuthMode === "oauth2" && tokens
326362 ? {
327363 kind: "oauth2" as const,
328364 accessTokenSecretId: tokens.accessTokenSecretId,
@@ -331,14 +367,28 @@ export default function AddMcpSource(props: { · 2 unmodified lines
331367 expiresAt: tokens.expiresAt,
332368 scope: tokens.scope,
333369 }
334− : { kind: "none" as const },
370+ : { kind: "none" as const };
371+ const headers = Object.fromEntries(
372+ remoteHeaders
373+ .map((header) => [header.name.trim(), header.value.trim()] as const)
374+ .filter(([name, value]) => name && value),
375+ );
376+
377+ await doAdd({
378+ path: { scopeId },
379+ payload: {
380+ transport: "remote" as const,
381+ name: probe.serverName ?? probe.name,
382+ endpoint: state.url.trim(),
383+ auth,
384+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
335385 },
336386 });
337387 props.onComplete();
338388 } catch (e) {
339389 dispatch({ type: "add-fail", error: e instanceof Error ? e.message : "Failed to add source" });
340390 }
341− }, [probe, tokens, state.url, doAdd, props]);
391+ }, [probe, remoteAuthMode, remoteHeaderAuth, remoteHeaders, tokens, state.url, doAdd, props]);
342392
343393 // ---- Stdio actions ----
344394
@@ -483,51 +533,217 @@ export default function AddMcpSource(props: { · 138 unmodified lines
483533 </div>
484534 )}
485535
486− {/* OAuth section */}
487− {probe?.requiresOAuth && !tokens && (
536+ {/* Authentication */}
537+ {probe && (
488538 <section className="space-y-2.5">
489− {state.step === "probed" && (
490− <Button onClick={handleOAuth} className="w-full" variant="outline">
491− <svg viewBox="0 0 16 16" fill="none" className="mr-1.5 size-3.5">
492− <path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1z" stroke="currentColor" strokeWidth="1.2" />
493− <path d="M8 4v4l2.5 1.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
494− </svg>
495− Sign in with OAuth
496− </Button>
539+ <Label>Authentication</Label>
540+
541+ <RadioGroup
542+ value={remoteAuthMode}
543+ onValueChange={(value) => setRemoteAuthMode(value as RemoteAuthMode)}
544+ className="gap-1.5"
545+ >
546+ {!probe.requiresOAuth && (
547+ <label
548+ className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
549+ remoteAuthMode === "none"
550+ ? "border-primary/50 bg-primary/[0.03]"
551+ : "border-border hover:bg-accent/50"
552+ }`}
553+ >
554+ <RadioGroupItem value="none" />
555+ <span className="text-xs font-medium text-foreground">None</span>
556+ <span className="ml-auto text-[10px] text-muted-foreground">no auth header</span>
557+ </label>
558+ )}
559+
560+ <label
561+ className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
562+ remoteAuthMode === "header"
563+ ? "border-primary/50 bg-primary/[0.03]"
564+ : "border-border hover:bg-accent/50"
565+ }`}
566+ >
567+ <RadioGroupItem value="header" />
568+ <span className="text-xs font-medium text-foreground">Header</span>
569+ <span className="ml-auto text-[10px] text-muted-foreground">use a secret-backed auth header</span>
570+ </label>
571+
572+ {probe.requiresOAuth && (
573+ <label
574+ className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
575+ remoteAuthMode === "oauth2"
576+ ? "border-primary/50 bg-primary/[0.03]"
577+ : "border-border hover:bg-accent/50"
578+ }`}
579+ >
580+ <RadioGroupItem value="oauth2" />
581+ <span className="text-xs font-medium text-foreground">OAuth</span>
582+ <span className="ml-auto text-[10px] text-muted-foreground">sign in with the server&apos;s OAuth flow</span>
583+ </label>
584+ )}
585+ </RadioGroup>
586+
587+ {remoteAuthMode === "header" && (
588+ <SecretHeaderAuthRow
589+ label="Auth header"
590+ name={remoteHeaderAuth.name}
591+ prefix={remoteHeaderAuth.prefix}
592+ presetKey={remoteHeaderAuth.presetKey}
593+ secretId={remoteHeaderAuth.secretId}
594+ onChange={(update) =>
595+ setRemoteHeaderAuth((current) => ({
596+ ...current,
597+ ...update,
598+ }))
599+ }
600+ onSelectSecret={(secretId) =>
601+ setRemoteHeaderAuth((current) => ({
602+ ...current,
603+ secretId,
604+ }))
605+ }
606+ existingSecrets={secretList}
607+ />
497608 )}
498609
499− {state.step === "oauth-starting" && (
500− <div className="flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2.5">
501− <Spinner className="size-3.5" />
502− <span className="text-xs text-muted-foreground">Starting authorization…</span>
503− </div>
610+ {probe.requiresOAuth && remoteAuthMode === "oauth2" && !tokens && (
611+ <>
612+ {state.step === "probed" && (
613+ <Button onClick={handleOAuth} className="w-full" variant="outline">
614+ <svg viewBox="0 0 16 16" fill="none" className="mr-1.5 size-3.5">
615+ <path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1z" stroke="currentColor" strokeWidth="1.2" />
616+ <path d="M8 4v4l2.5 1.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
617+ </svg>
618+ Sign in with OAuth
619+ </Button>
620+ )}
621+
622+ {state.step === "oauth-starting" && (
623+ <div className="flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2.5">
624+ <Spinner className="size-3.5" />
625+ <span className="text-xs text-muted-foreground">Starting authorization…</span>
626+ </div>
627+ )}
628+
629+ {state.step === "oauth-waiting" && (
630+ <div className="flex items-center gap-2 rounded-lg border border-blue-500/30 bg-blue-500/5 px-3 py-2.5">
631+ <Spinner className="size-3.5 text-blue-500" />
632+ <span className="text-xs text-blue-600 dark:text-blue-400">Waiting for authorization in popup…</span>
633+ <Button
634+ variant="ghost"
635+ size="sm"
636+ onClick={handleCancelOAuth}
637+ className="ml-auto h-7 px-2 text-xs"
638+ >
639+ Cancel
640+ </Button>
641+ </div>
642+ )}
643+ </>
504644 )}
505645
506− {state.step === "oauth-waiting" && (
507− <div className="flex items-center gap-2 rounded-lg border border-blue-500/30 bg-blue-500/5 px-3 py-2.5">
508− <Spinner className="size-3.5 text-blue-500" />
509− <span className="text-xs text-blue-600 dark:text-blue-400">Waiting for authorization in popup…</span>
510− <Button
511− variant="ghost"
512− size="sm"
513− onClick={handleCancelOAuth}
514− className="ml-auto h-7 px-2 text-xs"
515− >
516− Cancel
517− </Button>
646+ {probe.requiresOAuth && remoteAuthMode === "oauth2" && tokens && (
647+ <div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5">
648+ <svg viewBox="0 0 16 16" fill="none" className="size-3.5 text-emerald-500">
649+ <path d="M3 8.5l3 3 7-7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
650+ </svg>
651+ <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">Authenticated</span>
518652 </div>
519653 )}
654+
655+ {remoteAuthMode === "none" && probe.requiresOAuth && (
656+ <p className="text-[12px] text-amber-600 dark:text-amber-400">
657+ This server requires authentication before it can be added.
658+ </p>
659+ )}
520660 </section>
521661 )}
522662
523− {/* OAuth success */}
524− {tokens && (
525− <div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5">
526− <svg viewBox="0 0 16 16" fill="none" className="size-3.5 text-emerald-500">
527− <path d="M3 8.5l3 3 7-7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
528− </svg>
529− <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">Authenticated</span>
530− </div>
663+ {/* Additional headers */}
664+ {probe && (
665+ <section className="space-y-2.5">
666+ <div className="flex items-center justify-between gap-3">
667+ <div>
668+ <Label>Additional headers</Label>
669+ <p className="mt-1 text-[12px] text-muted-foreground">
670+ Plaintext headers sent with every request. Use authentication for secret-backed auth headers.
671+ </p>
672+ </div>
673+ <Button
674+ type="button"
675+ variant="outline"
676+ size="sm"
677+ className="shrink-0"
678+ onClick={() => setRemoteHeaders((headers) => [...headers, { name: "", value: "" }])}
679+ >
680+ + Add header
681+ </Button>
682+ </div>
683+
684+ {remoteHeaders.length > 0 && (
685+ <div className="space-y-2">
686+ {remoteHeaders.map((header, index) => (
687+ <div key={index} className="rounded-lg border border-border bg-card p-3 space-y-2">
688+ <div className="flex items-center justify-between">
689+ <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">
690+ Header
691+ </Label>
692+ <Button
693+ type="button"
694+ variant="ghost"
695+ size="xs"
696+ className="text-muted-foreground hover:text-destructive"
697+ onClick={() =>
698+ setRemoteHeaders((headers) =>
699+ headers.filter((_, headerIndex) => headerIndex !== index),
700+ )
701+ }
702+ >
703+ Remove
704+ </Button>
705+ </div>
706+ <div className="grid grid-cols-2 gap-2">
707+ <div className="space-y-1">
708+ <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Name</Label>
709+ <Input
710+ value={header.name}
711+ onChange={(event) =>
712+ setRemoteHeaders((headers) =>
713+ headers.map((current, headerIndex) =>
714+ headerIndex === index
715+ ? { ...current, name: (event.target as HTMLInputElement).value }
716+ : current,
717+ ),
718+ )
719+ }
720+ placeholder="X-Organization-Id"
721+ className="h-8 text-xs font-mono"
722+ />
723+ </div>
724+ <div className="space-y-1">
725+ <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Value</Label>
726+ <Input
727+ value={header.value}
728+ onChange={(event) =>
729+ setRemoteHeaders((headers) =>
730+ headers.map((current, headerIndex) =>
731+ headerIndex === index
732+ ? { ...current, value: (event.target as HTMLInputElement).value }
733+ : current,
734+ ),
735+ )
736+ }
737+ placeholder="workspace-id"
738+ className="h-8 text-xs font-mono"
739+ />
740+ </div>
741+ </div>
742+ </div>
743+ ))}
744+ </div>
745+ )}
746+ </section>
531747 )}
532748
533749 {/* Error */}
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+10−327
@@ -1,11 +1,10 @@
11 import { useState, useEffect, useRef } from "react";
2−import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";
2+import { useAtomSet } from "@effect-atom/atom-react";
33 import { Option } from "effect";
44
5−import { secretsAtom, setSecret, resolveSecret } from "@executor/react/api/atoms";
65 import { useScope } from "@executor/react/api/scope-context";
7−import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker";
8−import { SecretId } from "@executor/sdk";
6+import { SecretHeaderAuthRow, defaultHeaderAuthPresets } from "@executor/react/plugins/secret-header-auth";
7+import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";
98 import { Button } from "@executor/react/components/button";
109 import { Input } from "@executor/react/components/input";
1110 import { Label } from "@executor/react/components/label";
@@ -17,309 +16,6 @@ import { previewOpenApiSpec, addOpenApiSpec } from "./atoms"; · 5 unmodified lines
1716 import type { SpecPreview, HeaderPreset } from "../sdk/preview";
1817 import type { HeaderValue } from "../sdk/types";
1918
20−// ---------------------------------------------------------------------------
21−// Inline secret creation
22−// ---------------------------------------------------------------------------
23−
24−function InlineCreateSecret(props: {
25− headerName: string;
26− suggestedId: string;
27− onCreated: (secretId: string) => void;
28− onCancel: () => void;
29−}) {
30− const [secretId, setSecretId] = useState(props.suggestedId);
31− const [secretName, setSecretName] = useState(props.headerName);
32− const [secretValue, setSecretValue] = useState("");
33− const [saving, setSaving] = useState(false);
34− const [error, setError] = useState<string | null>(null);
35− const scopeId = useScope();
36− const doSet = useAtomSet(setSecret, { mode: "promise" });
37− const refreshSecrets = useAtomRefresh(secretsAtom(scopeId));
38−
39− const handleSave = async () => {
40− if (!secretId.trim() || !secretValue.trim()) return;
41− setSaving(true);
42− setError(null);
43− try {
44− await doSet({
45− path: { scopeId },
46− payload: {
47− id: SecretId.make(secretId.trim()),
48− name: secretName.trim() || secretId.trim(),
49− value: secretValue.trim(),
50− purpose: `Auth header: ${props.headerName}`,
51− },
52− });
53− refreshSecrets();
54− props.onCreated(secretId.trim());
55− } catch (e) {
56− setError(e instanceof Error ? e.message : "Failed to save secret");
57− setSaving(false);
58− }
59− };
60−
61− return (
62− <div className="rounded-lg border border-primary/20 bg-primary/[0.02] p-3 space-y-2.5">
63− <p className="text-[11px] font-semibold text-primary tracking-wide uppercase">New secret</p>
64− <div className="grid grid-cols-2 gap-2">
65− <div className="space-y-1">
66− <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">ID</Label>
67− <Input
68− value={secretId}
69− onChange={(e) => setSecretId((e.target as HTMLInputElement).value)}
70− placeholder="my-api-token"
71− className="h-8 text-xs font-mono"
72− />
73− </div>
74− <div className="space-y-1">
75− <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Label</Label>
76− <Input
77− value={secretName}
78− onChange={(e) => setSecretName((e.target as HTMLInputElement).value)}
79− placeholder="API Token"
80− className="h-8 text-xs"
81− />
82− </div>
83− </div>
84− <div className="space-y-1">
85− <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Value</Label>
86− <Input
87− type="password"
88− value={secretValue}
89− onChange={(e) => setSecretValue((e.target as HTMLInputElement).value)}
90− placeholder="paste your token or key…"
91− className="h-8 text-xs font-mono"
92− />
93− </div>
94− {error && <p className="text-[11px] text-destructive">{error}</p>}
95− <div className="flex gap-1.5 pt-0.5">
96− <Button variant="outline" size="xs" onClick={props.onCancel}>
97− Cancel
98− </Button>
99− <Button
100− size="xs"
101− onClick={handleSave}
102− disabled={!secretId.trim() || !secretValue.trim() || saving}
103− >
104− {saving ? "Saving…" : "Create & use"}
105− </Button>
106− </div>
107− </div>
108− );
109−}
110−
111−// ---------------------------------------------------------------------------
112−// Header value preview — shows what the header will look like
113−// ---------------------------------------------------------------------------
114−
115−type ResolveState =
116− | { status: "hidden" }
117− | { status: "loading" }
118− | { status: "revealed"; value: string }
119− | { status: "error" };
120−
121−function HeaderValuePreview(props: {
122− headerName: string;
123− secretId: string;
124− prefix?: string;
125−}) {
126− const { headerName, secretId, prefix } = props;
127− const scopeId = useScope();
128− const [state, setState] = useState<ResolveState>({ status: "hidden" });
129− const doResolve = useAtomSet(resolveSecret, { mode: "promise" });
130−
131− const handleToggle = async () => {
132− if (state.status === "revealed") {
133− setState({ status: "hidden" });
134− return;
135− }
136− setState({ status: "loading" });
137− try {
138− const result = await doResolve({
139− path: {
140− scopeId,
141− secretId: SecretId.make(secretId),
142− },
143− });
144− setState({ status: "revealed", value: result.value });
145− } catch {
146− setState({ status: "error" });
147− }
148− };
149−
150− const displayValue =
151− state.status === "revealed" ? state.value
152− : state.status === "error" ? "failed to resolve"
153− : "•".repeat(12);
154− const isLoading = state.status === "loading";
155− const isRevealed = state.status === "revealed";
156−
157− return (
158− <div className="flex items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-1.5 font-mono text-xs">
159− <span className="text-muted-foreground shrink-0">{headerName}:</span>
160− <span className="text-foreground truncate">
161− {prefix && <span className="text-muted-foreground">{prefix}</span>}
162− {displayValue}
163− </span>
164− <Button
165− variant="ghost"
166− size="icon-xs"
167− className="ml-auto shrink-0"
168− onClick={handleToggle}
169− disabled={isLoading}
170− >
171− {isLoading ? (
172− <Spinner className="size-3" />
173− ) : isRevealed ? (
174− <svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
175− <path d="M2 2l12 12" />
176− <path d="M6.5 6.5a2 2 0 0 0 3 3" />
177− <path d="M3.5 5.5C2.3 6.7 1.5 8 1.5 8s2.5 4.5 6.5 4.5c1 0 1.9-.3 2.7-.7" />
178− <path d="M10.7 10.7c2-1.4 3.3-3.2 3.8-3.7 0 0-2.5-5-6.5-5-.7 0-1.4.1-2 .4" />
179− </svg>
180− ) : (
181− <svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
182− <path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8s-2.5 4.5-6.5 4.5S1.5 8 1.5 8z" />
183− <circle cx="8" cy="8" r="2" />
184− </svg>
185− )}
186− </Button>
187− </div>
188− );
189−}
190−
191−// ---------------------------------------------------------------------------
192−// Header presets
193−// ---------------------------------------------------------------------------
194−
195−const HEADER_PRESETS = [
196− { key: "bearer", label: "Bearer Token", name: "Authorization", prefix: "Bearer " },
197− { key: "basic", label: "Basic Auth", name: "Authorization", prefix: "Basic " },
198− { key: "api-key", label: "API Key", name: "X-API-Key" },
199− { key: "auth-token", label: "Auth Token", name: "X-Auth-Token" },
200− { key: "access-token", label: "Access Token", name: "X-Access-Token" },
201− { key: "cookie", label: "Cookie", name: "Cookie" },
202− { key: "custom", label: "Custom", name: "" },
203−] as const;
204−
205−// ---------------------------------------------------------------------------
206−// Custom header row — pick a preset, then pick a secret
207−// ---------------------------------------------------------------------------
208−
209−function CustomHeaderRow(props: {
210− name: string;
211− prefix?: string;
212− presetKey?: string;
213− secretId: string | null;
214− onChange: (update: { name: string; prefix?: string; presetKey?: string }) => void;
215− onSelectSecret: (secretId: string) => void;
216− onRemove: () => void;
217− existingSecrets: readonly SecretPickerSecret[];
218−}) {
219− const [creating, setCreating] = useState(false);
220− const { name, prefix, presetKey, secretId, onChange, onSelectSecret, onRemove, existingSecrets } = props;
221−
222− const isCustom = presetKey === "custom";
223− const suggestedId = name.toLowerCase().replace(/[^a-z0-9]+/g, "-") || "custom-header";
224−
225− if (creating) {
226− return (
227− <InlineCreateSecret
228− headerName={name || "Custom Header"}
229− suggestedId={suggestedId}
230− onCreated={(id) => {
231− onSelectSecret(id);
232− setCreating(false);
233− }}
234− onCancel={() => setCreating(false)}
235− />
236− );
237− }
238−
239− return (
240− <div className="rounded-lg border border-border bg-card p-3 space-y-2.5">
241− <div className="flex items-center justify-between">
242− <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Header</Label>
243− <Button variant="ghost" size="xs" className="text-muted-foreground hover:text-destructive" onClick={onRemove}>
244− Remove
245− </Button>
246− </div>
247−
248− {/* Preset chips */}
249− <div className="flex flex-wrap gap-1">
250− {HEADER_PRESETS.map((p) => (
251− <button
252− key={p.key}
253− onClick={() =>
254− onChange({
255− name: p.name,
256− prefix: (p as { prefix?: string }).prefix,
257− presetKey: p.key,
258− })
259− }
260− className={`rounded-md border px-2 py-1 text-xs font-medium transition-colors ${
261− presetKey === p.key
262− ? "border-primary/50 bg-primary/10 text-primary"
263− : "border-border bg-background text-muted-foreground hover:text-foreground hover:bg-accent/50"
264− }`}
265− >
266− {p.label}
267− </button>
268− ))}
269− </div>
270−
271− {/* Name + prefix fields — always visible once a preset is picked */}
272− {presetKey !== undefined && (
273− <div className="grid grid-cols-2 gap-2">
274− <div className="space-y-1">
275− <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Name</Label>
276− <Input
277− value={name}
278− onChange={(e) => onChange({ name: (e.target as HTMLInputElement).value, prefix, presetKey: isCustom ? "custom" : presetKey })}
279− placeholder="Authorization"
280− className="h-8 text-xs font-mono"
281− />
282− </div>
283− <div className="space-y-1">
284− <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Prefix <span className="normal-case tracking-normal font-normal text-muted-foreground/60">(opt.)</span></Label>
285− <Input
286− value={prefix ?? ""}
287− onChange={(e) => onChange({ name, prefix: (e.target as HTMLInputElement).value || undefined, presetKey: isCustom ? "custom" : presetKey })}
288− placeholder="Bearer "
289− className="h-8 text-xs font-mono"
290− />
291− </div>
292− </div>
293− )}
294−
295− {/* Secret picker */}
296− {presetKey !== undefined && name.trim() && (
297− <div className="flex items-center gap-1.5">
298− <div className="flex-1 min-w-0">
299− <SecretPicker
300− value={secretId}
301− onSelect={onSelectSecret}
302− secrets={existingSecrets}
303− />
304− </div>
305− <Button variant="outline" size="sm" className="shrink-0" onClick={() => setCreating(true)}>
306− + New
307− </Button>
308− </div>
309− )}
310−
311− {/* Preview */}
312− {secretId && name.trim() && (
313− <HeaderValuePreview
314− headerName={name.trim()}
315− secretId={secretId}
316− prefix={prefix}
317− />
318− )}
319− </div>
320− );
321−}
322−
32319 // ---------------------------------------------------------------------------
32420 // Helpers
32521 // ---------------------------------------------------------------------------
@@ -334,13 +30,11 @@ function prefixForHeader(preset: HeaderPreset, headerName: string): string | und · 8 unmodified lines
33430 }
33531
33632 function matchPresetKey(name: string, prefix?: string): string {
337− if (name === "Authorization" && prefix === "Bearer ") return "bearer";
338− if (name === "Authorization" && prefix === "Basic ") return "basic";
339− if (name === "X-API-Key") return "api-key";
340− if (name === "X-Auth-Token") return "auth-token";
341− if (name === "X-Access-Token") return "access-token";
342− if (name === "Cookie") return "cookie";
343− return "custom";
33+ const preset =
34+ defaultHeaderAuthPresets.find((entry) => entry.name === name && entry.prefix === prefix)
35+ ?? defaultHeaderAuthPresets.find((entry) => entry.name === name && entry.prefix === undefined);
36+
37+ return preset?.key ?? "custom";
34438 }
34539
34640 function presetEntriesFromHeaderPreset(preset: HeaderPreset) {
@@ -385,7 +79,7 @@ export default function AddOpenApiSource(props: { · 38 unmodified lines
38579 const scopeId = useScope();
38680 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" });
38781 const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" });
388− const secrets = useAtomValue(secretsAtom(scopeId));
82+ const secretList = useSecretPickerSecrets();
38983 const autoAnalyzed = useRef(false);
39084
39185 useEffect(() => {
@@ -395,17 +89,6 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines
39589 }
39690 }, []); // eslint-disable-line react-hooks/exhaustive-deps
39791
398− const secretList: readonly SecretPickerSecret[] = Result.match(secrets, {
399− onInitial: () => [] as SecretPickerSecret[],
400− onFailure: () => [] as SecretPickerSecret[],
401− onSuccess: ({ value }) =>
402− value.map((s) => ({
403− id: s.id,
404− name: s.name,
405− provider: s.provider ? String(s.provider) : undefined,
406− })),
407− });
408−
40992 // ---- Derived state ----
41093
41194 const presets = preview?.headerPresets ?? [];
@@ -695,7 +378,7 @@ export default function AddOpenApiSource(props: { · 283 unmodified lines
695378 {presetIndex !== -1 && customHeaders.length > 0 && (
696379 <div className="space-y-2">
697380 {customHeaders.map((ch, i) => (
698− <CustomHeaderRow
381+ <SecretHeaderAuthRow
699382 key={i}
700383 name={ch.name}
701384 prefix={ch.prefix}
packages/react/src/plugins/secret-header-auth.tsx+350−0
@@ -0,0 +1,350 @@
1+import { useState } from "react";
2+import { useAtomRefresh, useAtomSet } from "@effect-atom/atom-react";
3+
4+import { secretsAtom, setSecret, resolveSecret } from "../api/atoms";
5+import { useScope } from "../api/scope-context";
6+import { Button } from "../components/button";
7+import { Input } from "../components/input";
8+import { Label } from "../components/label";
9+import { Spinner } from "../components/spinner";
10+import { SecretPicker, type SecretPickerSecret } from "./secret-picker";
11+import { SecretId } from "@executor/sdk";
12+
13+export interface HeaderAuthPreset {
14+ readonly key: string;
15+ readonly label: string;
16+ readonly name: string;
17+ readonly prefix?: string;
18+}
19+
20+export const defaultHeaderAuthPresets: readonly HeaderAuthPreset[] = [
21+ { key: "bearer", label: "Bearer Token", name: "Authorization", prefix: "Bearer " },
22+ { key: "basic", label: "Basic Auth", name: "Authorization", prefix: "Basic " },
23+ { key: "api-key", label: "API Key", name: "X-API-Key" },
24+ { key: "auth-token", label: "Auth Token", name: "X-Auth-Token" },
25+ { key: "access-token", label: "Access Token", name: "X-Access-Token" },
26+ { key: "cookie", label: "Cookie", name: "Cookie" },
27+ { key: "custom", label: "Custom", name: "" },
28+];
29+
30+function SecretVisibilityIcon(props: { revealed: boolean }) {
31+ return props.revealed ? (
32+ <svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
33+ <path d="M2 2l12 12" />
34+ <path d="M6.5 6.5a2 2 0 0 0 3 3" />
35+ <path d="M3.5 5.5C2.3 6.7 1.5 8 1.5 8s2.5 4.5 6.5 4.5c1 0 1.9-.3 2.7-.7" />
36+ <path d="M10.7 10.7c2-1.4 3.3-3.2 3.8-3.7 0 0-2.5-5-6.5-5-.7 0-1.4.1-2 .4" />
37+ </svg>
38+ ) : (
39+ <svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
40+ <path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8s-2.5 4.5-6.5 4.5S1.5 8 1.5 8z" />
41+ <circle cx="8" cy="8" r="2" />
42+ </svg>
43+ );
44+}
45+
46+function InlineCreateSecret(props: {
47+ headerName: string;
48+ suggestedId: string;
49+ onCreated: (secretId: string) => void;
50+ onCancel: () => void;
51+}) {
52+ const [secretId, setSecretId] = useState(props.suggestedId);
53+ const [secretName, setSecretName] = useState(props.headerName);
54+ const [secretValue, setSecretValue] = useState("");
55+ const [secretRevealed, setSecretRevealed] = useState(false);
56+ const [saving, setSaving] = useState(false);
57+ const [error, setError] = useState<string | null>(null);
58+ const scopeId = useScope();
59+ const doSet = useAtomSet(setSecret, { mode: "promise" });
60+ const refreshSecrets = useAtomRefresh(secretsAtom(scopeId));
61+
62+ const handleSave = async () => {
63+ if (!secretId.trim() || !secretValue.trim()) return;
64+ setSaving(true);
65+ setError(null);
66+ try {
67+ await doSet({
68+ path: { scopeId },
69+ payload: {
70+ id: SecretId.make(secretId.trim()),
71+ name: secretName.trim() || secretId.trim(),
72+ value: secretValue.trim(),
73+ purpose: `Auth header: ${props.headerName}`,
74+ },
75+ });
76+ refreshSecrets();
77+ props.onCreated(secretId.trim());
78+ } catch (e) {
79+ setError(e instanceof Error ? e.message : "Failed to save secret");
80+ setSaving(false);
81+ }
82+ };
83+
84+ return (
85+ <div className="rounded-lg border border-primary/20 bg-primary/[0.02] p-3 space-y-2.5">
86+ <p className="text-[11px] font-semibold text-primary tracking-wide uppercase">New secret</p>
87+ <div className="grid grid-cols-2 gap-2">
88+ <div className="space-y-1">
89+ <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">ID</Label>
90+ <Input
91+ value={secretId}
92+ onChange={(e) => setSecretId((e.target as HTMLInputElement).value)}
93+ placeholder="my-api-token"
94+ className="h-8 text-xs font-mono"
95+ />
96+ </div>
97+ <div className="space-y-1">
98+ <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Label</Label>
99+ <Input
100+ value={secretName}
101+ onChange={(e) => setSecretName((e.target as HTMLInputElement).value)}
102+ placeholder="API Token"
103+ className="h-8 text-xs"
104+ />
105+ </div>
106+ </div>
107+ <div className="space-y-1">
108+ <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Value</Label>
109+ <div className="relative">
110+ <Input
111+ type={secretRevealed ? "text" : "password"}
112+ value={secretValue}
113+ onChange={(e) => setSecretValue((e.target as HTMLInputElement).value)}
114+ placeholder="paste your token or key…"
115+ className="h-8 pr-8 text-xs font-mono"
116+ />
117+ <Button
118+ type="button"
119+ variant="ghost"
120+ size="icon-xs"
121+ className="absolute right-1 top-1/2 size-6 -translate-y-1/2 text-muted-foreground hover:text-foreground"
122+ onClick={() => setSecretRevealed((revealed) => !revealed)}
123+ aria-label={secretRevealed ? "Hide secret value" : "Reveal secret value"}
124+ >
125+ <SecretVisibilityIcon revealed={secretRevealed} />
126+ </Button>
127+ </div>
128+ </div>
129+ {error && <p className="text-[11px] text-destructive">{error}</p>}
130+ <div className="flex gap-1.5 pt-0.5">
131+ <Button variant="outline" size="xs" onClick={props.onCancel}>
132+ Cancel
133+ </Button>
134+ <Button
135+ size="xs"
136+ onClick={handleSave}
137+ disabled={!secretId.trim() || !secretValue.trim() || saving}
138+ >
139+ {saving ? "Saving…" : "Create & use"}
140+ </Button>
141+ </div>
142+ </div>
143+ );
144+}
145+
146+type ResolveState =
147+ | { status: "hidden" }
148+ | { status: "loading" }
149+ | { status: "revealed"; value: string }
150+ | { status: "error" };
151+
152+function HeaderValuePreview(props: {
153+ headerName: string;
154+ secretId: string;
155+ prefix?: string;
156+}) {
157+ const { headerName, secretId, prefix } = props;
158+ const scopeId = useScope();
159+ const [state, setState] = useState<ResolveState>({ status: "hidden" });
160+ const doResolve = useAtomSet(resolveSecret, { mode: "promise" });
161+
162+ const handleToggle = async () => {
163+ if (state.status === "revealed") {
164+ setState({ status: "hidden" });
165+ return;
166+ }
167+ setState({ status: "loading" });
168+ try {
169+ const result = await doResolve({
170+ path: {
171+ scopeId,
172+ secretId: SecretId.make(secretId),
173+ },
174+ });
175+ setState({ status: "revealed", value: result.value });
176+ } catch {
177+ setState({ status: "error" });
178+ }
179+ };
180+
181+ const displayValue =
182+ state.status === "revealed" ? state.value
183+ : state.status === "error" ? "failed to resolve"
184+ : "•".repeat(12);
185+ const isLoading = state.status === "loading";
186+ const isRevealed = state.status === "revealed";
187+
188+ return (
189+ <div className="flex items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-1.5 font-mono text-xs">
190+ <span className="text-muted-foreground shrink-0">{headerName}:</span>
191+ <span className="text-foreground truncate">
192+ {prefix && <span className="text-muted-foreground">{prefix}</span>}
193+ {displayValue}
194+ </span>
195+ <Button
196+ variant="ghost"
197+ size="icon-xs"
198+ className="ml-auto shrink-0"
199+ onClick={handleToggle}
200+ disabled={isLoading}
201+ >
202+ {isLoading ? (
203+ <Spinner className="size-3" />
204+ ) : (
205+ <SecretVisibilityIcon revealed={isRevealed} />
206+ )}
207+ </Button>
208+ </div>
209+ );
210+}
211+
212+export function SecretHeaderAuthRow(props: {
213+ name: string;
214+ prefix?: string;
215+ presetKey?: string;
216+ secretId: string | null;
217+ onChange: (update: { name: string; prefix?: string; presetKey?: string }) => void;
218+ onSelectSecret: (secretId: string) => void;
219+ existingSecrets: readonly SecretPickerSecret[];
220+ presets?: readonly HeaderAuthPreset[];
221+ onRemove?: () => void;
222+ removeLabel?: string;
223+ label?: string;
224+}) {
225+ const [creating, setCreating] = useState(false);
226+ const {
227+ name,
228+ prefix,
229+ presetKey,
230+ secretId,
231+ onChange,
232+ onSelectSecret,
233+ existingSecrets,
234+ presets = defaultHeaderAuthPresets,
235+ onRemove,
236+ removeLabel = "Remove",
237+ label = "Header",
238+ } = props;
239+
240+ const isCustom = presetKey === "custom";
241+ const suggestedId = name.toLowerCase().replace(/[^a-z0-9]+/g, "-") || "custom-header";
242+
243+ if (creating) {
244+ return (
245+ <InlineCreateSecret
246+ headerName={name || "Custom Header"}
247+ suggestedId={suggestedId}
248+ onCreated={(id) => {
249+ onSelectSecret(id);
250+ setCreating(false);
251+ }}
252+ onCancel={() => setCreating(false)}
253+ />
254+ );
255+ }
256+
257+ return (
258+ <div className="rounded-lg border border-border bg-card p-3 space-y-2.5">
259+ <div className="flex items-center justify-between">
260+ <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</Label>
261+ {onRemove && (
262+ <Button variant="ghost" size="xs" className="text-muted-foreground hover:text-destructive" onClick={onRemove}>
263+ {removeLabel}
264+ </Button>
265+ )}
266+ </div>
267+
268+ <div className="flex flex-wrap gap-1">
269+ {presets.map((preset) => (
270+ <button
271+ key={preset.key}
272+ type="button"
273+ onClick={() =>
274+ onChange({
275+ name: preset.name,
276+ prefix: preset.prefix,
277+ presetKey: preset.key,
278+ })
279+ }
280+ className={`rounded-md border px-2 py-1 text-xs font-medium transition-colors ${
281+ presetKey === preset.key
282+ ? "border-primary/50 bg-primary/10 text-primary"
283+ : "border-border bg-background text-muted-foreground hover:text-foreground hover:bg-accent/50"
284+ }`}
285+ >
286+ {preset.label}
287+ </button>
288+ ))}
289+ </div>
290+
291+ {presetKey !== undefined && (
292+ <div className="grid grid-cols-2 gap-2">
293+ <div className="space-y-1">
294+ <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Name</Label>
295+ <Input
296+ value={name}
297+ onChange={(e) =>
298+ onChange({
299+ name: (e.target as HTMLInputElement).value,
300+ prefix,
301+ presetKey: isCustom ? "custom" : presetKey,
302+ })
303+ }
304+ placeholder="Authorization"
305+ className="h-8 text-xs font-mono"
306+ />
307+ </div>
308+ <div className="space-y-1">
309+ <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Prefix <span className="normal-case tracking-normal font-normal text-muted-foreground/60">(opt.)</span></Label>
310+ <Input
311+ value={prefix ?? ""}
312+ onChange={(e) =>
313+ onChange({
314+ name,
315+ prefix: (e.target as HTMLInputElement).value || undefined,
316+ presetKey: isCustom ? "custom" : presetKey,
317+ })
318+ }
319+ placeholder="Bearer "
320+ className="h-8 text-xs font-mono"
321+ />
322+ </div>
323+ </div>
324+ )}
325+
326+ {presetKey !== undefined && name.trim() && (
327+ <div className="flex items-center gap-1.5">
328+ <div className="flex-1 min-w-0">
329+ <SecretPicker
330+ value={secretId}
331+ onSelect={onSelectSecret}
332+ secrets={existingSecrets}
333+ />
334+ </div>
335+ <Button variant="outline" size="sm" className="shrink-0" onClick={() => setCreating(true)}>
336+ + New
337+ </Button>
338+ </div>
339+ )}
340+
341+ {secretId && name.trim() && (
342+ <HeaderValuePreview
343+ headerName={name.trim()}
344+ secretId={secretId}
345+ prefix={prefix}
346+ />
347+ )}
348+ </div>
349+ );
350+}
packages/react/src/plugins/use-secret-picker-secrets.tsx+21−0
@@ -0,0 +1,21 @@
1+import { useAtomValue, Result } from "@effect-atom/atom-react";
2+
3+import { secretsAtom } from "../api/atoms";
4+import { useScope } from "../api/scope-context";
5+import type { SecretPickerSecret } from "./secret-picker";
6+
7+export function useSecretPickerSecrets(): readonly SecretPickerSecret[] {
8+ const scopeId = useScope();
9+ const secrets = useAtomValue(secretsAtom(scopeId));
10+
11+ return Result.match(secrets, {
12+ onInitial: () => [] as SecretPickerSecret[],
13+ onFailure: () => [] as SecretPickerSecret[],
14+ onSuccess: ({ value }) =>
15+ value.map((secret) => ({
16+ id: secret.id,
17+ name: secret.name,
18+ provider: secret.provider ? String(secret.provider) : undefined,
19+ })),
20+ });
21+}