* refactor(react): extract secret-backed header auth UI * feat(mcp): support manual headers for remote sources
Unified · Split
| @@ -1,10 +1,9 @@ | |||
| 1 | import { useState } from "react"; | 1 | import { useState } from "react"; |
| 2 | import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react"; | 2 | import { useAtomSet } from "@effect-atom/atom-react"; |
| 3 | 3 | ||
| 4 | import { secretsAtom, setSecret } from "@executor/react/api/atoms"; | ||
| 5 | import { useScope } from "@executor/react/api/scope-context"; | 4 | import { useScope } from "@executor/react/api/scope-context"; |
| 6 | import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker"; | 5 | import { SecretHeaderAuthRow } from "@executor/react/plugins/secret-header-auth"; |
| 7 | import { SecretId } from "@executor/sdk"; | 6 | import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; |
| 8 | import { Button } from "@executor/react/components/button"; | 7 | import { Button } from "@executor/react/components/button"; |
| 9 | import { Input } from "@executor/react/components/input"; | 8 | import { Input } from "@executor/react/components/input"; |
| 10 | import { Label } from "@executor/react/components/label"; | 9 | import { Label } from "@executor/react/components/label"; |
| @@ -12,144 +11,19 @@ import { Spinner } from "@executor/react/components/spinner"; · 1 unmodified line | |||
| 12 | import { addGraphqlSource } from "./atoms"; | 11 | import { addGraphqlSource } from "./atoms"; |
| 13 | import type { HeaderValue } from "../sdk/types"; | 12 | import type { HeaderValue } from "../sdk/types"; |
| 14 | 13 | ||
| 15 | // --------------------------------------------------------------------------- | 14 | type HeaderEntry = { |
| 16 | // Inline secret creation | 15 | name: string; |
| 17 | // --------------------------------------------------------------------------- | 16 | prefix?: string; |
| 17 | presetKey?: string; | ||
| 18 | secretId: string | null; | ||
| 19 | }; | ||
| 18 | 20 | ||
| 19 | function InlineCreateSecret(props: { | 21 | const 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 | |||
| 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 | // --------------------------------------------------------------------------- | ||
| 153 | 27 | ||
| 154 | export default function AddGraphqlSource(props: { | 28 | export 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 | <AuthHeaderRow | 141 | 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 */} |
| @@ -6,7 +6,10 @@ import { Button } from "@executor/react/components/button"; · 5 unmodified lines | |||
| 6 | import { Input } from "@executor/react/components/input"; | 6 | import { Input } from "@executor/react/components/input"; |
| 7 | import { Label } from "@executor/react/components/label"; | 7 | import { Label } from "@executor/react/components/label"; |
| 8 | import { Badge } from "@executor/react/components/badge"; | 8 | import { Badge } from "@executor/react/components/badge"; |
| 9 | import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group"; | ||
| 9 | import { Spinner } from "@executor/react/components/spinner"; | 10 | 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"; | ||
| 10 | import { probeMcpEndpoint, addMcpSource, startMcpOAuth } from "./atoms"; | 13 | import { probeMcpEndpoint, addMcpSource, startMcpOAuth } from "./atoms"; |
| 11 | import { mcpPresets, type McpPreset } from "../sdk/presets"; | 14 | import { 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 | ||
| 46 | type RemoteAuthMode = "none" | "header" | "oauth2"; | ||
| 47 | |||
| 48 | type PlainHeader = { | ||
| 49 | name: string; | ||
| 50 | value: string; | ||
| 51 | }; | ||
| 52 | |||
| 43 | type State = | 53 | type 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: tokens | 359 | ...(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 OAuth | 545 | > |
| 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'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 | <Button | 650 | </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 */} |
| @@ -1,11 +1,10 @@ | |||
| 1 | import { useState, useEffect, useRef } from "react"; | 1 | 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"; |
| 3 | import { Option } from "effect"; | 3 | import { Option } from "effect"; |
| 4 | 4 | ||
| 5 | import { secretsAtom, setSecret, resolveSecret } from "@executor/react/api/atoms"; | ||
| 6 | import { useScope } from "@executor/react/api/scope-context"; | 5 | import { useScope } from "@executor/react/api/scope-context"; |
| 7 | import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker"; | 6 | import { SecretHeaderAuthRow, defaultHeaderAuthPresets } from "@executor/react/plugins/secret-header-auth"; |
| 8 | import { SecretId } from "@executor/sdk"; | 7 | import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; |
| 9 | import { Button } from "@executor/react/components/button"; | 8 | import { Button } from "@executor/react/components/button"; |
| 10 | import { Input } from "@executor/react/components/input"; | 9 | import { Input } from "@executor/react/components/input"; |
| 11 | import { Label } from "@executor/react/components/label"; | 10 | import { Label } from "@executor/react/components/label"; |
| @@ -17,309 +16,6 @@ import { previewOpenApiSpec, addOpenApiSpec } from "./atoms"; · 5 unmodified lines | |||
| 17 | import type { SpecPreview, HeaderPreset } from "../sdk/preview"; | 16 | import type { SpecPreview, HeaderPreset } from "../sdk/preview"; |
| 18 | import type { HeaderValue } from "../sdk/types"; | 17 | import type { HeaderValue } from "../sdk/types"; |
| 19 | 18 | ||
| 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 | |||
| 323 | // --------------------------------------------------------------------------- | 19 | // --------------------------------------------------------------------------- |
| 324 | // Helpers | 20 | // Helpers |
| 325 | // --------------------------------------------------------------------------- | 21 | // --------------------------------------------------------------------------- |
| @@ -334,13 +30,11 @@ function prefixForHeader(preset: HeaderPreset, headerName: string): string | und · 8 unmodified lines | |||
| 334 | } | 30 | } |
| 335 | 31 | ||
| 336 | function matchPresetKey(name: string, prefix?: string): string { | 32 | function 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 | ||
| 346 | function presetEntriesFromHeaderPreset(preset: HeaderPreset) { | 40 | function 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-deps | 90 | }, []); // 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 | <CustomHeaderRow | 381 | <SecretHeaderAuthRow |
| 699 | key={i} | 382 | key={i} |
| 700 | name={ch.name} | 383 | name={ch.name} |
| 701 | prefix={ch.prefix} | 384 | prefix={ch.prefix} |
| @@ -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 | } | ||
| @@ -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 | } | ||