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 @@
1import { useState } from "react";1import { useState } from "react";
2import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";2import { useAtomSet } from "@effect-atom/atom-react";
3 3
4import { secretsAtom, setSecret } from "@executor/react/api/atoms";
5import { useScope } from "@executor/react/api/scope-context";4import { useScope } from "@executor/react/api/scope-context";
6import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker";5import { SecretHeaderAuthRow } from "@executor/react/plugins/secret-header-auth";
7import { SecretId } from "@executor/sdk";6import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";
8import { Button } from "@executor/react/components/button";7import { Button } from "@executor/react/components/button";
9import { Input } from "@executor/react/components/input";8import { Input } from "@executor/react/components/input";
10import { Label } from "@executor/react/components/label";9import { Label } from "@executor/react/components/label";
@@ -12,144 +11,19 @@ import { Spinner } from "@executor/react/components/spinner"; · 1 unmodified line
12import { addGraphqlSource } from "./atoms";11import { addGraphqlSource } from "./atoms";
13import type { HeaderValue } from "../sdk/types";12import type { HeaderValue } from "../sdk/types";
14 13
15// ---------------------------------------------------------------------------14type HeaderEntry = {
16// Inline secret creation15 name: string;
17// ---------------------------------------------------------------------------16 prefix?: string;
17 presetKey?: string;
18 secretId: string | null;
19};
18 20
19function InlineCreateSecret(props: {21const initialHeader = (): HeaderEntry => ({
20 headerName: string;22 name: "Authorization",
21 suggestedId: string;23 prefix: "Bearer ",
22 onCreated: (secretId: string) => void;24 presetKey: "bearer",
23 onCancel: () => void;25 secretId: null,
24}) {26});
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
110function 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// ---------------------------------------------------------------------------
153 27
154export default function AddGraphqlSource(props: {28export default function AddGraphqlSource(props: {
155 onComplete: () => void;29 onComplete: () => void;
@@ -158,34 +32,53 @@ export default function AddGraphqlSource(props: { · 2 unmodified lines
158}) {32}) {
159 const [endpoint, setEndpoint] = useState(props.initialUrl ?? "");33 const [endpoint, setEndpoint] = useState(props.initialUrl ?? "");
160 const [namespace, setNamespace] = useState("");34 const [namespace, setNamespace] = useState("");
161 const [authSecretId, setAuthSecretId] = useState<string | null>(null);35 const [headers, setHeaders] = useState<HeaderEntry[]>([initialHeader()]);
162 const [adding, setAdding] = useState(false);36 const [adding, setAdding] = useState(false);
163 const [addError, setAddError] = useState<string | null>(null);37 const [addError, setAddError] = useState<string | null>(null);
164 38
165 const scopeId = useScope();39 const scopeId = useScope();
166 const doAdd = useAtomSet(addGraphqlSource, { mode: "promise" });40 const doAdd = useAtomSet(addGraphqlSource, { mode: "promise" });
167 const secrets = useAtomValue(secretsAtom(scopeId));41 const secretList = useSecretPickerSecrets();
168 42
169 const secretList: readonly SecretPickerSecret[] = Result.match(secrets, {43 const headersValid = headers.every(
170 onInitial: () => [] as SecretPickerSecret[],44 (header) => header.name.trim() && header.secretId,
171 onFailure: () => [] as SecretPickerSecret[],45 );
172 onSuccess: ({ value }) =>46 const canAdd =
173 value.map((s) => ({47 endpoint.trim().length > 0 && (headers.length === 0 || headersValid);
174 id: s.id,48
175 name: s.name,49 const updateHeader = (
176 provider: s.provider ? String(s.provider) : undefined,50 index: number,
177 })),51 update: Partial<{ name: string; prefix?: string; presetKey?: string; secretId: string | null }>,
178 });52 ) => {
53 setHeaders((current) =>
54 current.map((header, i) => (i === index ? { ...header, ...update } : header)),
55 );
56 };
179 57
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 };
181 68
182 const handleAdd = async () => {69 const handleAdd = async () => {
183 setAdding(true);70 setAdding(true);
184 setAddError(null);71 setAddError(null);
185 try {72 try {
186 const headers: Record<string, HeaderValue> = {};73 const headerMap: Record<string, HeaderValue> = {};
187 if (authSecretId) {74 for (const header of headers) {
188 headers["Authorization"] = { secretId: authSecretId, prefix: "Bearer " };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 }
189 }82 }
190 83
191 await doAdd({84 await doAdd({
@@ -193,7 +86,7 @@ export default function AddGraphqlSource(props: { · 1 unmodified line
193 payload: {86 payload: {
194 endpoint: endpoint.trim(),87 endpoint: endpoint.trim(),
195 namespace: namespace.trim() || undefined,88 namespace: namespace.trim() || undefined,
196 ...(Object.keys(headers).length > 0 ? { headers } : {}),89 ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}),
197 },90 },
198 });91 });
199 props.onComplete();92 props.onComplete();
@@ -239,17 +132,43 @@ export default function AddGraphqlSource(props: { · 39 unmodified lines
239 132
240 {/* Authentication */}133 {/* Authentication */}
241 <section className="space-y-2.5">134 <section className="space-y-2.5">
242 <Label>135 <div className="flex items-center justify-between gap-3">
243 Authentication <span className="text-muted-foreground font-normal">(optional)</span>136 <div>
244 </Label>137 <Label>
245 <p className="text-[12px] text-muted-foreground">138 Authentication <span className="text-muted-foreground font-normal">(optional)</span>
246 Select a secret for the Bearer token sent with every request, including introspection.139 </Label>
247 </p>140 <p className="mt-1 text-[12px] text-muted-foreground">
248 <AuthHeaderRow141 Secret-backed headers sent with every request, including introspection.
249 selectedSecretId={authSecretId}142 </p>
250 onSelect={setAuthSecretId}143 </div>
251 existingSecrets={secretList}144 <Button
252 />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 )}
253 </section>172 </section>
254 173
255 {/* Error */}174 {/* Error */}
packages/plugins/mcp/src/react/AddMcpSource.tsx+262−46
@@ -6,7 +6,10 @@ import { Button } from "@executor/react/components/button"; · 5 unmodified lines
6import { Input } from "@executor/react/components/input";6import { Input } from "@executor/react/components/input";
7import { Label } from "@executor/react/components/label";7import { Label } from "@executor/react/components/label";
8import { Badge } from "@executor/react/components/badge";8import { Badge } from "@executor/react/components/badge";
9import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group";
9import { Spinner } from "@executor/react/components/spinner";10import { Spinner } from "@executor/react/components/spinner";
11import { SecretHeaderAuthRow } from "@executor/react/plugins/secret-header-auth";
12import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";
10import { probeMcpEndpoint, addMcpSource, startMcpOAuth } from "./atoms";13import { probeMcpEndpoint, addMcpSource, startMcpOAuth } from "./atoms";
11import { mcpPresets, type McpPreset } from "../sdk/presets";14import { mcpPresets, type McpPreset } from "../sdk/presets";
12 15
@@ -40,6 +43,13 @@ type ProbeResult = { · 27 unmodified lines
40 serverName: string | null;43 serverName: string | null;
41};44};
42 45
46type RemoteAuthMode = "none" | "header" | "oauth2";
47
48type PlainHeader = {
49 name: string;
50 value: string;
51};
52
43type State =53type State =
44 | { step: "url"; url: string }54 | { step: "url"; url: string }
45 | { step: "probing"; url: string }55 | { step: "probing"; url: string }
@@ -230,6 +240,21 @@ export default function AddMcpSource(props: { · 184 unmodified lines
230 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" });240 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" });
231 const doAdd = useAtomSet(addMcpSource, { mode: "promise" });241 const doAdd = useAtomSet(addMcpSource, { mode: "promise" });
232 const doStartOAuth = useAtomSet(startMcpOAuth, { mode: "promise" });242 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[]>([]);
233 258
234 const probe = "probe" in state ? state.probe : null;259 const probe = "probe" in state ? state.probe : null;
235 const tokens = "tokens" in state ? state.tokens : null;260 const tokens = "tokens" in state ? state.tokens : null;
@@ -237,8 +262,16 @@ export default function AddMcpSource(props: { · 1 unmodified line
237 const isProbing = state.step === "probing";262 const isProbing = state.step === "probing";
238 const isAdding = state.step === "adding";263 const isAdding = state.step === "adding";
239 const isOAuthBusy = state.step === "oauth-starting" || state.step === "oauth-waiting";264 const isOAuthBusy = state.step === "oauth-starting" || state.step === "oauth-waiting";
240 const needsOAuth = probe?.requiresOAuth === true && !tokens;265 const canUseNone = probe?.requiresOAuth !== true;
241 const canAdd = probe && !needsOAuth && !isAdding && !isOAuthBusy;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;
242 const error = state.step === "error" ? state.error : null;275 const error = state.step === "error" ? state.error : null;
243 276
244 // ---- Remote actions ----277 // ---- Remote actions ----
@@ -250,6 +283,7 @@ export default function AddMcpSource(props: { · 5 unmodified lines
250 path: { scopeId },283 path: { scopeId },
251 payload: { endpoint: state.url.trim() },284 payload: { endpoint: state.url.trim() },
252 });285 });
286 setRemoteAuthMode(result.requiresOAuth ? "oauth2" : "none");
253 dispatch({ type: "probe-ok", probe: result });287 dispatch({ type: "probe-ok", probe: result });
254 } catch (e) {288 } catch (e) {
255 dispatch({ type: "probe-fail", error: e instanceof Error ? e.message : "Failed to connect" });289 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
316 if (!probe) return;350 if (!probe) return;
317 dispatch({ type: "add-start" });351 dispatch({ type: "add-start" });
318 try {352 try {
319 await doAdd({353 const auth =
320 path: { scopeId },354 remoteAuthMode === "header"
321 payload: {355 ? {
322 transport: "remote" as const,356 kind: "header" as const,
323 name: probe.serverName ?? probe.name,357 headerName: remoteHeaderAuth.name.trim(),
324 endpoint: state.url.trim(),358 secretId: remoteHeaderAuth.secretId!,
325 auth: tokens359 ...(remoteHeaderAuth.prefix ? { prefix: remoteHeaderAuth.prefix } : {}),
360 }
361 : remoteAuthMode === "oauth2" && tokens
326 ? {362 ? {
327 kind: "oauth2" as const,363 kind: "oauth2" as const,
328 accessTokenSecretId: tokens.accessTokenSecretId,364 accessTokenSecretId: tokens.accessTokenSecretId,
@@ -331,14 +367,28 @@ export default function AddMcpSource(props: { · 2 unmodified lines
331 expiresAt: tokens.expiresAt,367 expiresAt: tokens.expiresAt,
332 scope: tokens.scope,368 scope: tokens.scope,
333 }369 }
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 } : {}),
335 },385 },
336 });386 });
337 props.onComplete();387 props.onComplete();
338 } catch (e) {388 } catch (e) {
339 dispatch({ type: "add-fail", error: e instanceof Error ? e.message : "Failed to add source" });389 dispatch({ type: "add-fail", error: e instanceof Error ? e.message : "Failed to add source" });
340 }390 }
341 }, [probe, tokens, state.url, doAdd, props]);391 }, [probe, remoteAuthMode, remoteHeaderAuth, remoteHeaders, tokens, state.url, doAdd, props]);
342 392
343 // ---- Stdio actions ----393 // ---- Stdio actions ----
344 394
@@ -483,51 +533,217 @@ export default function AddMcpSource(props: { · 138 unmodified lines
483 </div>533 </div>
484 )}534 )}
485 535
486 {/* OAuth section */}536 {/* Authentication */}
487 {probe?.requiresOAuth && !tokens && (537 {probe && (
488 <section className="space-y-2.5">538 <section className="space-y-2.5">
489 {state.step === "probed" && (539 <Label>Authentication</Label>
490 <Button onClick={handleOAuth} className="w-full" variant="outline">540
491 <svg viewBox="0 0 16 16" fill="none" className="mr-1.5 size-3.5">541 <RadioGroup
492 <path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1z" stroke="currentColor" strokeWidth="1.2" />542 value={remoteAuthMode}
493 <path d="M8 4v4l2.5 1.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />543 onValueChange={(value) => setRemoteAuthMode(value as RemoteAuthMode)}
494 </svg>544 className="gap-1.5"
495 Sign in with OAuth545 >
496 </Button>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 />
497 )}608 )}
498 609
499 {state.step === "oauth-starting" && (610 {probe.requiresOAuth && remoteAuthMode === "oauth2" && !tokens && (
500 <div className="flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2.5">611 <>
501 <Spinner className="size-3.5" />612 {state.step === "probed" && (
502 <span className="text-xs text-muted-foreground">Starting authorization…</span>613 <Button onClick={handleOAuth} className="w-full" variant="outline">
503 </div>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 </>
504 )}644 )}
505 645
506 {state.step === "oauth-waiting" && (646 {probe.requiresOAuth && remoteAuthMode === "oauth2" && tokens && (
507 <div className="flex items-center gap-2 rounded-lg border border-blue-500/30 bg-blue-500/5 px-3 py-2.5">647 <div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5">
508 <Spinner className="size-3.5 text-blue-500" />648 <svg viewBox="0 0 16 16" fill="none" className="size-3.5 text-emerald-500">
509 <span className="text-xs text-blue-600 dark:text-blue-400">Waiting for authorization in popup…</span>649 <path d="M3 8.5l3 3 7-7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
510 <Button650 </svg>
511 variant="ghost"651 <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">Authenticated</span>
512 size="sm"
513 onClick={handleCancelOAuth}
514 className="ml-auto h-7 px-2 text-xs"
515 >
516 Cancel
517 </Button>
518 </div>652 </div>
519 )}653 )}
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 )}
520 </section>660 </section>
521 )}661 )}
522 662
523 {/* OAuth success */}663 {/* Additional headers */}
524 {tokens && (664 {probe && (
525 <div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5">665 <section className="space-y-2.5">
526 <svg viewBox="0 0 16 16" fill="none" className="size-3.5 text-emerald-500">666 <div className="flex items-center justify-between gap-3">
527 <path d="M3 8.5l3 3 7-7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />667 <div>
528 </svg>668 <Label>Additional headers</Label>
529 <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">Authenticated</span>669 <p className="mt-1 text-[12px] text-muted-foreground">
530 </div>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>
531 )}747 )}
532 748
533 {/* Error */}749 {/* Error */}
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+10−327
@@ -1,11 +1,10 @@
1import { useState, useEffect, useRef } from "react";1import { useState, useEffect, useRef } from "react";
2import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";2import { useAtomSet } from "@effect-atom/atom-react";
3import { Option } from "effect";3import { Option } from "effect";
4 4
5import { secretsAtom, setSecret, resolveSecret } from "@executor/react/api/atoms";
6import { useScope } from "@executor/react/api/scope-context";5import { useScope } from "@executor/react/api/scope-context";
7import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker";6import { SecretHeaderAuthRow, defaultHeaderAuthPresets } from "@executor/react/plugins/secret-header-auth";
8import { SecretId } from "@executor/sdk";7import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";
9import { Button } from "@executor/react/components/button";8import { Button } from "@executor/react/components/button";
10import { Input } from "@executor/react/components/input";9import { Input } from "@executor/react/components/input";
11import { Label } from "@executor/react/components/label";10import { Label } from "@executor/react/components/label";
@@ -17,309 +16,6 @@ import { previewOpenApiSpec, addOpenApiSpec } from "./atoms"; · 5 unmodified lines
17import type { SpecPreview, HeaderPreset } from "../sdk/preview";16import type { SpecPreview, HeaderPreset } from "../sdk/preview";
18import type { HeaderValue } from "../sdk/types";17import type { HeaderValue } from "../sdk/types";
19 18
20// ---------------------------------------------------------------------------
21// Inline secret creation
22// ---------------------------------------------------------------------------
23
24function 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
115type ResolveState =
116 | { status: "hidden" }
117 | { status: "loading" }
118 | { status: "revealed"; value: string }
119 | { status: "error" };
120
121function 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
195const 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
209function 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
323// ---------------------------------------------------------------------------19// ---------------------------------------------------------------------------
324// Helpers20// Helpers
325// ---------------------------------------------------------------------------21// ---------------------------------------------------------------------------
@@ -334,13 +30,11 @@ function prefixForHeader(preset: HeaderPreset, headerName: string): string | und · 8 unmodified lines
334}30}
335 31
336function matchPresetKey(name: string, prefix?: string): string {32function matchPresetKey(name: string, prefix?: string): string {
337 if (name === "Authorization" && prefix === "Bearer ") return "bearer";33 const preset =
338 if (name === "Authorization" && prefix === "Basic ") return "basic";34 defaultHeaderAuthPresets.find((entry) => entry.name === name && entry.prefix === prefix)
339 if (name === "X-API-Key") return "api-key";35 ?? defaultHeaderAuthPresets.find((entry) => entry.name === name && entry.prefix === undefined);
340 if (name === "X-Auth-Token") return "auth-token";36
341 if (name === "X-Access-Token") return "access-token";37 return preset?.key ?? "custom";
342 if (name === "Cookie") return "cookie";
343 return "custom";
344}38}
345 39
346function presetEntriesFromHeaderPreset(preset: HeaderPreset) {40function presetEntriesFromHeaderPreset(preset: HeaderPreset) {
@@ -385,7 +79,7 @@ export default function AddOpenApiSource(props: { · 38 unmodified lines
385 const scopeId = useScope();79 const scopeId = useScope();
386 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" });80 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" });
387 const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" });81 const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" });
388 const secrets = useAtomValue(secretsAtom(scopeId));82 const secretList = useSecretPickerSecrets();
389 const autoAnalyzed = useRef(false);83 const autoAnalyzed = useRef(false);
390 84
391 useEffect(() => {85 useEffect(() => {
@@ -395,17 +89,6 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines
395 }89 }
396 }, []); // eslint-disable-line react-hooks/exhaustive-deps90 }, []); // eslint-disable-line react-hooks/exhaustive-deps
397 91
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
409 // ---- Derived state ----92 // ---- Derived state ----
410 93
411 const presets = preview?.headerPresets ?? [];94 const presets = preview?.headerPresets ?? [];
@@ -695,7 +378,7 @@ export default function AddOpenApiSource(props: { · 283 unmodified lines
695 {presetIndex !== -1 && customHeaders.length > 0 && (378 {presetIndex !== -1 && customHeaders.length > 0 && (
696 <div className="space-y-2">379 <div className="space-y-2">
697 {customHeaders.map((ch, i) => (380 {customHeaders.map((ch, i) => (
698 <CustomHeaderRow381 <SecretHeaderAuthRow
699 key={i}382 key={i}
700 name={ch.name}383 name={ch.name}
701 prefix={ch.prefix}384 prefix={ch.prefix}
packages/react/src/plugins/secret-header-auth.tsx+350−0
@@ -0,0 +1,350 @@
1import { useState } from "react";
2import { useAtomRefresh, useAtomSet } from "@effect-atom/atom-react";
3
4import { secretsAtom, setSecret, resolveSecret } from "../api/atoms";
5import { useScope } from "../api/scope-context";
6import { Button } from "../components/button";
7import { Input } from "../components/input";
8import { Label } from "../components/label";
9import { Spinner } from "../components/spinner";
10import { SecretPicker, type SecretPickerSecret } from "./secret-picker";
11import { SecretId } from "@executor/sdk";
12
13export interface HeaderAuthPreset {
14 readonly key: string;
15 readonly label: string;
16 readonly name: string;
17 readonly prefix?: string;
18}
19
20export 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
30function 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
46function 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
146type ResolveState =
147 | { status: "hidden" }
148 | { status: "loading" }
149 | { status: "revealed"; value: string }
150 | { status: "error" };
151
152function 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
212export 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 @@
1import { useAtomValue, Result } from "@effect-atom/atom-react";
2
3import { secretsAtom } from "../api/atoms";
4import { useScope } from "../api/scope-context";
5import type { SecretPickerSecret } from "./secret-picker";
6
7export 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}