- New-secret form renders as a full-width CardStack row instead of a padded embed, so the inputs stop being squeezed by wrapping padding. - Suggested secret label and ID derive from the source name (e.g. "Axiom Authorization" / "axiom-authorization") so tokens across sources no longer collide on a shared "authorization" id. - Label and ID are now an override-or-derive pair: ID auto-follows the label until the user edits the ID, at which point they decouple. - Secret value input is type="text" with CSS text-security masking so password-manager extensions stop intercepting paste. - "+ New" is promoted into the SecretPicker dropdown itself, replacing the external button next to the picker. Also: OpenAPI specs that declare security schemes only under components (e.g. Sentry) now surface as Authentication presets by falling back to one strategy per scheme. Favicon uses the resolved base URL's apex domain via tldts instead of the spec URL's host.
Unified · Split
| @@ -132,6 +132,7 @@ export default function AddGraphqlSource(props: { · 131 unmodified lines | |||
| 132 | headers={headers} | 132 | headers={headers} |
| 133 | onHeadersChange={setHeaders} | 133 | onHeadersChange={setHeaders} |
| 134 | existingSecrets={secretList} | 134 | existingSecrets={secretList} |
| 135 | sourceName={identity.name} | ||
| 135 | /> | 136 | /> |
| 136 | </section> | 137 | </section> |
| 137 | 138 | ||
| @@ -123,6 +123,7 @@ function EditForm(props: { · 122 unmodified lines | |||
| 123 | headers={headers} | 123 | headers={headers} |
| 124 | onHeadersChange={handleHeadersChange} | 124 | onHeadersChange={handleHeadersChange} |
| 125 | existingSecrets={secretList} | 125 | existingSecrets={secretList} |
| 126 | sourceName={identity.name} | ||
| 126 | /> | 127 | /> |
| 127 | </section> | 128 | </section> |
| 128 | 129 | ||
| @@ -758,6 +758,7 @@ export default function AddMcpSource(props: { · 757 unmodified lines | |||
| 758 | onHeadersChange={setRemoteAuthHeaders} | 758 | onHeadersChange={setRemoteAuthHeaders} |
| 759 | existingSecrets={secretList} | 759 | existingSecrets={secretList} |
| 760 | singleHeader | 760 | singleHeader |
| 761 | sourceName={remoteIdentity.name} | ||
| 761 | /> | 762 | /> |
| 762 | )} | 763 | )} |
| 763 | 764 | ||
| @@ -1,4 +1,4 @@ | |||
| 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 1 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 2 | import { useAtomSet } 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 | ||
| @@ -39,6 +39,7 @@ import { · 34 unmodified lines | |||
| 39 | } from "@executor/react/components/native-select"; | 39 | } from "@executor/react/components/native-select"; |
| 40 | import { Textarea } from "@executor/react/components/textarea"; | 40 | import { Textarea } from "@executor/react/components/textarea"; |
| 41 | import { Checkbox } from "@executor/react/components/checkbox"; | 41 | import { Checkbox } from "@executor/react/components/checkbox"; |
| 42 | import { SourceFavicon } from "@executor/react/components/source-favicon"; | ||
| 42 | import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group"; | 43 | import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group"; |
| 43 | import { Skeleton } from "@executor/react/components/skeleton"; | 44 | import { Skeleton } from "@executor/react/components/skeleton"; |
| 44 | import { IOSSpinner, Spinner } from "@executor/react/components/spinner"; | 45 | import { IOSSpinner, Spinner } from "@executor/react/components/spinner"; |
| @@ -224,26 +225,6 @@ export default function AddOpenApiSource(props: { · 179 unmodified lines | |||
| 224 | return out; | 225 | return out; |
| 225 | }; | 226 | }; |
| 226 | 227 | ||
| 227 | // Derive a favicon URL from the spec URL (if the user entered one — raw | ||
| 228 | // JSON/YAML content will fail URL parsing and yield null). Uses Google's | ||
| 229 | // favicon service so we don't depend on the domain serving /favicon.ico. | ||
| 230 | const faviconUrl = useMemo(() => { | ||
| 231 | try { | ||
| 232 | const trimmed = specUrl.trim(); | ||
| 233 | if (!trimmed) return null; | ||
| 234 | const u = new URL(trimmed); | ||
| 235 | if (u.protocol !== "http:" && u.protocol !== "https:") return null; | ||
| 236 | return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=64`; | ||
| 237 | } catch { | ||
| 238 | return null; | ||
| 239 | } | ||
| 240 | }, [specUrl]); | ||
| 241 | |||
| 242 | const [faviconFailed, setFaviconFailed] = useState(false); | ||
| 243 | useEffect(() => { | ||
| 244 | setFaviconFailed(false); | ||
| 245 | }, [faviconUrl]); | ||
| 246 | |||
| 247 | const allHeaders: Record<string, HeaderValue> = {}; | 228 | const allHeaders: Record<string, HeaderValue> = {}; |
| 248 | for (const ch of customHeaders) { | 229 | for (const ch of customHeaders) { |
| 249 | if (ch.name.trim() && ch.secretId) { | 230 | if (ch.name.trim() && ch.secretId) { |
| @@ -542,14 +523,7 @@ export default function AddOpenApiSource(props: { · 292 unmodified lines | |||
| 542 | <CardStack> | 523 | <CardStack> |
| 543 | <CardStackContent className="border-t-0"> | 524 | <CardStackContent className="border-t-0"> |
| 544 | <CardStackEntry> | 525 | <CardStackEntry> |
| 545 | {faviconUrl && !faviconFailed && ( | 526 | {resolvedBaseUrl && <SourceFavicon url={resolvedBaseUrl} size={16} />} |
| 546 | <img | ||
| 547 | src={faviconUrl} | ||
| 548 | alt="" | ||
| 549 | className="size-4 shrink-0 object-contain" | ||
| 550 | onError={() => setFaviconFailed(true)} | ||
| 551 | /> | ||
| 552 | )} | ||
| 553 | <CardStackEntryContent> | 527 | <CardStackEntryContent> |
| 554 | <CardStackEntryTitle> | 528 | <CardStackEntryTitle> |
| 555 | {Option.getOrElse(preview.title, () => "API")} | 529 | {Option.getOrElse(preview.title, () => "API")} |
| @@ -806,6 +780,7 @@ export default function AddOpenApiSource(props: { · 250 unmodified lines | |||
| 806 | headers={customHeaders} | 780 | headers={customHeaders} |
| 807 | onHeadersChange={handleHeadersChange} | 781 | onHeadersChange={handleHeadersChange} |
| 808 | existingSecrets={secretList} | 782 | existingSecrets={secretList} |
| 783 | sourceName={identity.name} | ||
| 809 | /> | 784 | /> |
| 810 | )} | 785 | )} |
| 811 | 786 | ||
| @@ -123,6 +123,7 @@ function EditForm(props: { · 122 unmodified lines | |||
| 123 | headers={headers} | 123 | headers={headers} |
| 124 | onHeadersChange={handleHeadersChange} | 124 | onHeadersChange={handleHeadersChange} |
| 125 | existingSecrets={secretList} | 125 | existingSecrets={secretList} |
| 126 | sourceName={identity.name} | ||
| 126 | /> | 127 | /> |
| 127 | </section> | 128 | </section> |
| 128 | 129 | ||
| @@ -359,9 +359,15 @@ export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (input: st · 358 unmodified lines | |||
| 359 | ); | 359 | ); |
| 360 | 360 | ||
| 361 | const rawSecurity = (doc.security ?? []) as Array<Record<string, unknown>>; | 361 | const rawSecurity = (doc.security ?? []) as Array<Record<string, unknown>>; |
| 362 | const authStrategies = rawSecurity.map( | 362 | const declaredStrategies = rawSecurity.map( |
| 363 | (entry) => new AuthStrategy({ schemes: Object.keys(entry) }), | 363 | (entry) => new AuthStrategy({ schemes: Object.keys(entry) }), |
| 364 | ); | 364 | ); |
| 365 | // Fall back to one strategy per scheme when the spec only declares schemes | ||
| 366 | // under components (e.g. Sentry) so the user still sees auth options. | ||
| 367 | const authStrategies = | ||
| 368 | declaredStrategies.length > 0 | ||
| 369 | ? declaredStrategies | ||
| 370 | : securitySchemes.map((scheme) => new AuthStrategy({ schemes: [scheme.name] })); | ||
| 365 | 371 | ||
| 366 | return new SpecPreview({ | 372 | return new SpecPreview({ |
| 367 | title: result.title, | 373 | title: result.title, |
| @@ -26,6 +26,11 @@ export interface HeadersListProps { · 25 unmodified lines | |||
| 26 | readonly singleHeader?: boolean; | 26 | readonly singleHeader?: boolean; |
| 27 | /** Text shown in the empty state. */ | 27 | /** Text shown in the empty state. */ |
| 28 | readonly emptyLabel?: ReactNode; | 28 | readonly emptyLabel?: ReactNode; |
| 29 | /** | ||
| 30 | * Display name of the source that owns these headers (e.g. "Axiom"). Used | ||
| 31 | * to derive unique default secret labels/IDs like `axiom-authorization`. | ||
| 32 | */ | ||
| 33 | readonly sourceName?: string; | ||
| 29 | } | 34 | } |
| 30 | 35 | ||
| 31 | export function HeadersList({ | 36 | export function HeadersList({ |
| @@ -35,6 +40,7 @@ export function HeadersList({ · 3 unmodified lines | |||
| 35 | presets = defaultHeaderAuthPresets, | 40 | presets = defaultHeaderAuthPresets, |
| 36 | singleHeader = false, | 41 | singleHeader = false, |
| 37 | emptyLabel = "No headers", | 42 | emptyLabel = "No headers", |
| 43 | sourceName, | ||
| 38 | }: HeadersListProps) { | 44 | }: HeadersListProps) { |
| 39 | const [picking, setPicking] = useState(false); | 45 | const [picking, setPicking] = useState(false); |
| 40 | const canAddMore = !singleHeader || headers.length === 0; | 46 | const canAddMore = !singleHeader || headers.length === 0; |
| @@ -100,6 +106,7 @@ export function HeadersList({ · 59 unmodified lines | |||
| 100 | onSelectSecret={(secretId) => updateHeader(index, { secretId })} | 106 | onSelectSecret={(secretId) => updateHeader(index, { secretId })} |
| 101 | onRemove={singleHeader ? undefined : () => removeHeader(index)} | 107 | onRemove={singleHeader ? undefined : () => removeHeader(index)} |
| 102 | existingSecrets={existingSecrets} | 108 | existingSecrets={existingSecrets} |
| 109 | sourceName={sourceName} | ||
| 103 | /> | 110 | /> |
| 104 | ))} | 111 | ))} |
| 105 | {canAddMore && <AddHeaderRow onClick={() => setPicking(true)} />} | 112 | {canAddMore && <AddHeaderRow onClick={() => setPicking(true)} />} |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { useId, useState } from "react"; | 1 | import { useId, useState, type CSSProperties } from "react"; |
| 2 | import { useAtomSet } from "@effect-atom/atom-react"; | 2 | import { useAtomSet } from "@effect-atom/atom-react"; |
| 3 | 3 | ||
| 4 | import { setSecret, resolveSecret } from "../api/atoms"; | 4 | import { setSecret, resolveSecret } from "../api/atoms"; |
| @@ -60,14 +60,22 @@ function SecretVisibilityIcon(props: { revealed: boolean }) { · 55 unmodified lines | |||
| 60 | ); | 60 | ); |
| 61 | } | 61 | } |
| 62 | 62 | ||
| 63 | function slugifyForSecretId(input: string): string { | ||
| 64 | return input | ||
| 65 | .trim() | ||
| 66 | .toLowerCase() | ||
| 67 | .replace(/[^a-z0-9]+/g, "-") | ||
| 68 | .replace(/^-+|-+$/g, ""); | ||
| 69 | } | ||
| 70 | |||
| 63 | function InlineCreateSecret(props: { | 71 | function InlineCreateSecret(props: { |
| 64 | headerName: string; | ||
| 65 | suggestedId: string; | 72 | suggestedId: string; |
| 73 | suggestedName: string; | ||
| 66 | onCreated: (secretId: string) => void; | 74 | onCreated: (secretId: string) => void; |
| 67 | onCancel: () => void; | 75 | onCancel: () => void; |
| 68 | }) { | 76 | }) { |
| 69 | const [secretId, setSecretId] = useState(props.suggestedId); | 77 | const [nameOverride, setNameOverride] = useState<string | null>(null); |
| 70 | const [secretName, setSecretName] = useState(props.headerName); | 78 | const [idOverride, setIdOverride] = useState<string | null>(null); |
| 71 | const [secretValue, setSecretValue] = useState(""); | 79 | const [secretValue, setSecretValue] = useState(""); |
| 72 | const [secretRevealed, setSecretRevealed] = useState(false); | 80 | const [secretRevealed, setSecretRevealed] = useState(false); |
| 73 | const [saving, setSaving] = useState(false); | 81 | const [saving, setSaving] = useState(false); |
| @@ -78,6 +86,9 @@ function InlineCreateSecret(props: { · 4 unmodified lines | |||
| 78 | const secretNameInputId = useId(); | 86 | const secretNameInputId = useId(); |
| 79 | const secretValueInputId = useId(); | 87 | const secretValueInputId = useId(); |
| 80 | 88 | ||
| 89 | const secretName = nameOverride ?? props.suggestedName; | ||
| 90 | const secretId = idOverride ?? (slugifyForSecretId(secretName) || "custom-header"); | ||
| 91 | |||
| 81 | const handleSave = async () => { | 92 | const handleSave = async () => { |
| 82 | if (!secretId.trim() || !secretValue.trim()) return; | 93 | if (!secretId.trim() || !secretValue.trim()) return; |
| 83 | setSaving(true); | 94 | setSaving(true); |
| @@ -87,7 +98,7 @@ function InlineCreateSecret(props: { · 3 unmodified lines | |||
| 87 | path: { scopeId }, | 98 | path: { scopeId }, |
| 88 | payload: { | 99 | payload: { |
| 89 | id: SecretId.make(secretId.trim()), | 100 | id: SecretId.make(secretId.trim()), |
| 90 | name: `${secretName.trim() || secretId.trim()} (Auth header: ${props.headerName})`, | 101 | name: secretName.trim() || secretId.trim(), |
| 91 | value: secretValue.trim(), | 102 | value: secretValue.trim(), |
| 92 | }, | 103 | }, |
| 93 | reactivityKeys: secretWriteKeys, | 104 | reactivityKeys: secretWriteKeys, |
| @@ -100,40 +111,43 @@ function InlineCreateSecret(props: { · 6 unmodified lines | |||
| 100 | }; | 111 | }; |
| 101 | 112 | ||
| 102 | return ( | 113 | return ( |
| 103 | <div className="rounded-lg border border-primary/20 bg-primary/[0.02] p-3 space-y-3"> | 114 | <div className="bg-primary/[0.03] px-4 py-3 space-y-3"> |
| 104 | <p className="text-[11px] font-semibold text-primary tracking-wide uppercase">New secret</p> | 115 | <p className="text-[11px] font-semibold text-primary tracking-wide uppercase">New secret</p> |
| 105 | <FieldGroup className="gap-3"> | 116 | <FieldGroup className="gap-3"> |
| 106 | <div className="grid grid-cols-2 gap-3"> | 117 | <div className="grid grid-cols-2 gap-3"> |
| 118 | <Field> | ||
| 119 | <FieldLabel htmlFor={secretNameInputId}>Label</FieldLabel> | ||
| 120 | <Input | ||
| 121 | id={secretNameInputId} | ||
| 122 | value={secretName} | ||
| 123 | onChange={(e) => setNameOverride((e.target as HTMLInputElement).value)} | ||
| 124 | placeholder="API Token" | ||
| 125 | /> | ||
| 126 | </Field> | ||
| 107 | <Field> | 127 | <Field> |
| 108 | <FieldLabel htmlFor={secretIdInputId}>ID</FieldLabel> | 128 | <FieldLabel htmlFor={secretIdInputId}>ID</FieldLabel> |
| 109 | <Input | 129 | <Input |
| 110 | id={secretIdInputId} | 130 | id={secretIdInputId} |
| 111 | value={secretId} | 131 | value={secretId} |
| 112 | onChange={(e) => setSecretId((e.target as HTMLInputElement).value)} | 132 | onChange={(e) => setIdOverride((e.target as HTMLInputElement).value)} |
| 113 | placeholder="my-api-token" | 133 | placeholder="my-api-token" |
| 114 | className="font-mono" | 134 | className="font-mono" |
| 115 | /> | 135 | /> |
| 116 | </Field> | 136 | </Field> |
| 117 | <Field> | ||
| 118 | <FieldLabel htmlFor={secretNameInputId}>Label</FieldLabel> | ||
| 119 | <Input | ||
| 120 | id={secretNameInputId} | ||
| 121 | value={secretName} | ||
| 122 | onChange={(e) => setSecretName((e.target as HTMLInputElement).value)} | ||
| 123 | placeholder="API Token" | ||
| 124 | /> | ||
| 125 | </Field> | ||
| 126 | </div> | 137 | </div> |
| 127 | <Field> | 138 | <Field> |
| 128 | <FieldLabel htmlFor={secretValueInputId}>Value</FieldLabel> | 139 | <FieldLabel htmlFor={secretValueInputId}>Value</FieldLabel> |
| 129 | <div className="relative"> | 140 | <div className="relative"> |
| 130 | <Input | 141 | <Input |
| 131 | id={secretValueInputId} | 142 | id={secretValueInputId} |
| 132 | type={secretRevealed ? "text" : "password"} | 143 | type="text" |
| 133 | value={secretValue} | 144 | value={secretValue} |
| 134 | onChange={(e) => setSecretValue((e.target as HTMLInputElement).value)} | 145 | onChange={(e) => setSecretValue((e.target as HTMLInputElement).value)} |
| 135 | placeholder="paste your token or key…" | 146 | placeholder="paste your token or key…" |
| 136 | className="pr-9 font-mono" | 147 | className="pr-9 font-mono" |
| 148 | style={ | ||
| 149 | secretRevealed ? undefined : ({ WebkitTextSecurity: "disc" } as CSSProperties) | ||
| 150 | } | ||
| 137 | /> | 151 | /> |
| 138 | <Button | 152 | <Button |
| 139 | type="button" | 153 | type="button" |
| @@ -293,6 +307,12 @@ export function SecretHeaderAuthRow(props: { · 153 unmodified lines | |||
| 293 | onRemove?: () => void; | 307 | onRemove?: () => void; |
| 294 | removeLabel?: string; | 308 | removeLabel?: string; |
| 295 | label?: string; | 309 | label?: string; |
| 310 | /** | ||
| 311 | * Display name of the source this header belongs to (e.g. "Axiom"). Used | ||
| 312 | * to prefix the suggested secret label and ID so tokens from different | ||
| 313 | * sources don't collide on ids like `authorization`. | ||
| 314 | */ | ||
| 315 | sourceName?: string; | ||
| 296 | }) { | 316 | }) { |
| 297 | const [creating, setCreating] = useState(false); | 317 | const [creating, setCreating] = useState(false); |
| 298 | const nameInputId = useId(); | 318 | const nameInputId = useId(); |
| @@ -308,24 +328,25 @@ export function SecretHeaderAuthRow(props: { · 9 unmodified lines | |||
| 308 | onRemove, | 328 | onRemove, |
| 309 | removeLabel = "Remove", | 329 | removeLabel = "Remove", |
| 310 | label = "Header", | 330 | label = "Header", |
| 331 | sourceName, | ||
| 311 | } = props; | 332 | } = props; |
| 312 | 333 | ||
| 313 | const isCustom = presetKey === "custom" || presetKey === undefined; | 334 | const isCustom = presetKey === "custom" || presetKey === undefined; |
| 314 | const suggestedId = name.toLowerCase().replace(/[^a-z0-9]+/g, "-") || "custom-header"; | 335 | const headerLabel = name.trim() || "Custom Header"; |
| 336 | const suggestedName = [sourceName?.trim(), headerLabel].filter(Boolean).join(" "); | ||
| 337 | const suggestedId = slugifyForSecretId(suggestedName) || "custom-header"; | ||
| 315 | 338 | ||
| 316 | if (creating) { | 339 | if (creating) { |
| 317 | return ( | 340 | return ( |
| 318 | <div className="px-4 py-3"> | 341 | <InlineCreateSecret |
| 319 | <InlineCreateSecret | 342 | suggestedId={suggestedId} |
| 320 | headerName={name || "Custom Header"} | 343 | suggestedName={suggestedName} |
| 321 | suggestedId={suggestedId} | 344 | onCreated={(id) => { |
| 322 | onCreated={(id) => { | 345 | onSelectSecret(id); |
| 323 | onSelectSecret(id); | 346 | setCreating(false); |
| 324 | setCreating(false); | 347 | }} |
| 325 | }} | 348 | onCancel={() => setCreating(false)} |
| 326 | onCancel={() => setCreating(false)} | 349 | /> |
| 327 | /> | ||
| 328 | </div> | ||
| 329 | ); | 350 | ); |
| 330 | } | 351 | } |
| 331 | 352 | ||
| @@ -382,19 +403,12 @@ export function SecretHeaderAuthRow(props: { · 50 unmodified lines | |||
| 382 | </Field> | 403 | </Field> |
| 383 | </FieldGroup> | 404 | </FieldGroup> |
| 384 | 405 | ||
| 385 | <div className="flex items-center gap-1.5"> | 406 | <SecretPicker |
| 386 | <div className="flex-1 min-w-0"> | 407 | value={secretId} |
| 387 | <SecretPicker value={secretId} onSelect={onSelectSecret} secrets={existingSecrets} /> | 408 | onSelect={onSelectSecret} |
| 388 | </div> | 409 | secrets={existingSecrets} |
| 389 | <Button | 410 | onCreateNew={() => setCreating(true)} |
| 390 | variant="outline" | 411 | /> |
| 391 | size="sm" | ||
| 392 | className="shrink-0" | ||
| 393 | onClick={() => setCreating(true)} | ||
| 394 | > | ||
| 395 | + New | ||
| 396 | </Button> | ||
| 397 | </div> | ||
| 398 | 412 | ||
| 399 | {secretId && name.trim() && ( | 413 | {secretId && name.trim() && ( |
| 400 | <HeaderValuePreview headerName={name.trim()} secretId={secretId} prefix={prefix} /> | 414 | <HeaderValuePreview headerName={name.trim()} secretId={secretId} prefix={prefix} /> |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { useState, type ChangeEvent, type FocusEvent } from "react"; | 1 | import { useState, type ChangeEvent, type FocusEvent } from "react"; |
| 2 | import { PlusIcon } from "lucide-react"; | ||
| 2 | 3 | ||
| 3 | import { Input } from "../components/input"; | 4 | import { Input } from "../components/input"; |
| 4 | import { | 5 | import { |
| @@ -7,6 +8,7 @@ import { · 2 unmodified lines | |||
| 7 | CommandGroup, | 8 | CommandGroup, |
| 8 | CommandItem, | 9 | CommandItem, |
| 9 | CommandList, | 10 | CommandList, |
| 11 | CommandSeparator, | ||
| 10 | } from "../components/command"; | 12 | } from "../components/command"; |
| 11 | import { Popover, PopoverAnchor, PopoverContent } from "../components/popover"; | 13 | import { Popover, PopoverAnchor, PopoverContent } from "../components/popover"; |
| 12 | 14 | ||
| @@ -33,8 +35,10 @@ export function SecretPicker(props: { · 20 unmodified lines | |||
| 33 | readonly onSelect: (secretId: string) => void; | 35 | readonly onSelect: (secretId: string) => void; |
| 34 | readonly secrets: readonly SecretPickerSecret[]; | 36 | readonly secrets: readonly SecretPickerSecret[]; |
| 35 | readonly placeholder?: string; | 37 | readonly placeholder?: string; |
| 38 | /** When provided, renders a "+ New secret" row at the bottom of the dropdown. */ | ||
| 39 | readonly onCreateNew?: () => void; | ||
| 36 | }) { | 40 | }) { |
| 37 | const { value, onSelect, secrets, placeholder = "Search secrets…" } = props; | 41 | const { value, onSelect, secrets, placeholder = "Search secrets…", onCreateNew } = props; |
| 38 | const [open, setOpen] = useState(false); | 42 | const [open, setOpen] = useState(false); |
| 39 | const [query, setQuery] = useState(""); | 43 | const [query, setQuery] = useState(""); |
| 40 | 44 | ||
| @@ -122,6 +126,25 @@ export function SecretPicker(props: { · 81 unmodified lines | |||
| 122 | </CommandGroup> | 126 | </CommandGroup> |
| 123 | ); | 127 | ); |
| 124 | })} | 128 | })} |
| 129 | {onCreateNew && ( | ||
| 130 | <> | ||
| 131 | {secrets.length > 0 && <CommandSeparator />} | ||
| 132 | <CommandGroup> | ||
| 133 | <CommandItem | ||
| 134 | value="__create_new__" | ||
| 135 | onSelect={() => { | ||
| 136 | onCreateNew(); | ||
| 137 | setOpen(false); | ||
| 138 | setQuery(""); | ||
| 139 | }} | ||
| 140 | className="text-muted-foreground data-[selected=true]:text-foreground" | ||
| 141 | > | ||
| 142 | <PlusIcon aria-hidden className="size-3.5" /> | ||
| 143 | <span>New secret</span> | ||
| 144 | </CommandItem> | ||
| 145 | </CommandGroup> | ||
| 146 | </> | ||
| 147 | )} | ||
| 125 | </CommandList> | 148 | </CommandList> |
| 126 | </Command> | 149 | </Command> |
| 127 | </PopoverContent> | 150 | </PopoverContent> |