Polish secret creation UX in header-auth flow
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> |