Polish secret creation UX in header-auth flow
Unified · Split
| @@ -132,6 +132,7 @@ export default function AddGraphqlSource(props: { · 131 unmodified lines | ||
| 132 | 132 | headers={headers} |
| 133 | 133 | onHeadersChange={setHeaders} |
| 134 | 134 | existingSecrets={secretList} |
| 135 | + sourceName={identity.name} | |
| 135 | 136 | /> |
| 136 | 137 | </section> |
| 137 | 138 | |
| @@ -123,6 +123,7 @@ function EditForm(props: { · 122 unmodified lines | ||
| 123 | 123 | headers={headers} |
| 124 | 124 | onHeadersChange={handleHeadersChange} |
| 125 | 125 | existingSecrets={secretList} |
| 126 | + sourceName={identity.name} | |
| 126 | 127 | /> |
| 127 | 128 | </section> |
| 128 | 129 | |
| @@ -758,6 +758,7 @@ export default function AddMcpSource(props: { · 757 unmodified lines | ||
| 758 | 758 | onHeadersChange={setRemoteAuthHeaders} |
| 759 | 759 | existingSecrets={secretList} |
| 760 | 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 | 2 | import { useAtomSet } from "@effect-atom/atom-react"; |
| 3 | 3 | import { Option } from "effect"; |
| 4 | 4 | |
| @@ -39,6 +39,7 @@ import { · 34 unmodified lines | ||
| 39 | 39 | } from "@executor/react/components/native-select"; |
| 40 | 40 | import { Textarea } from "@executor/react/components/textarea"; |
| 41 | 41 | import { Checkbox } from "@executor/react/components/checkbox"; |
| 42 | +import { SourceFavicon } from "@executor/react/components/source-favicon"; | |
| 42 | 43 | import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group"; |
| 43 | 44 | import { Skeleton } from "@executor/react/components/skeleton"; |
| 44 | 45 | import { IOSSpinner, Spinner } from "@executor/react/components/spinner"; |
| @@ -224,26 +225,6 @@ export default function AddOpenApiSource(props: { · 179 unmodified lines | ||
| 224 | 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 | 228 | const allHeaders: Record<string, HeaderValue> = {}; |
| 248 | 229 | for (const ch of customHeaders) { |
| 249 | 230 | if (ch.name.trim() && ch.secretId) { |
| @@ -542,14 +523,7 @@ export default function AddOpenApiSource(props: { · 292 unmodified lines | ||
| 542 | 523 | <CardStack> |
| 543 | 524 | <CardStackContent className="border-t-0"> |
| 544 | 525 | <CardStackEntry> |
| 545 | − {faviconUrl && !faviconFailed && ( | |
| 546 | − <img | |
| 547 | − src={faviconUrl} | |
| 548 | − alt="" | |
| 549 | − className="size-4 shrink-0 object-contain" | |
| 550 | − onError={() => setFaviconFailed(true)} | |
| 551 | − /> | |
| 552 | − )} | |
| 526 | + {resolvedBaseUrl && <SourceFavicon url={resolvedBaseUrl} size={16} />} | |
| 553 | 527 | <CardStackEntryContent> |
| 554 | 528 | <CardStackEntryTitle> |
| 555 | 529 | {Option.getOrElse(preview.title, () => "API")} |
| @@ -806,6 +780,7 @@ export default function AddOpenApiSource(props: { · 250 unmodified lines | ||
| 806 | 780 | headers={customHeaders} |
| 807 | 781 | onHeadersChange={handleHeadersChange} |
| 808 | 782 | existingSecrets={secretList} |
| 783 | + sourceName={identity.name} | |
| 809 | 784 | /> |
| 810 | 785 | )} |
| 811 | 786 | |
| @@ -123,6 +123,7 @@ function EditForm(props: { · 122 unmodified lines | ||
| 123 | 123 | headers={headers} |
| 124 | 124 | onHeadersChange={handleHeadersChange} |
| 125 | 125 | existingSecrets={secretList} |
| 126 | + sourceName={identity.name} | |
| 126 | 127 | /> |
| 127 | 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 | 361 | const rawSecurity = (doc.security ?? []) as Array<Record<string, unknown>>; |
| 362 | − const authStrategies = rawSecurity.map( | |
| 362 | + const declaredStrategies = rawSecurity.map( | |
| 363 | 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 | 372 | return new SpecPreview({ |
| 367 | 373 | title: result.title, |
| @@ -26,6 +26,11 @@ export interface HeadersListProps { · 25 unmodified lines | ||
| 26 | 26 | readonly singleHeader?: boolean; |
| 27 | 27 | /** Text shown in the empty state. */ |
| 28 | 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 | 36 | export function HeadersList({ |
| @@ -35,6 +40,7 @@ export function HeadersList({ · 3 unmodified lines | ||
| 35 | 40 | presets = defaultHeaderAuthPresets, |
| 36 | 41 | singleHeader = false, |
| 37 | 42 | emptyLabel = "No headers", |
| 43 | + sourceName, | |
| 38 | 44 | }: HeadersListProps) { |
| 39 | 45 | const [picking, setPicking] = useState(false); |
| 40 | 46 | const canAddMore = !singleHeader || headers.length === 0; |
| @@ -100,6 +106,7 @@ export function HeadersList({ · 59 unmodified lines | ||
| 100 | 106 | onSelectSecret={(secretId) => updateHeader(index, { secretId })} |
| 101 | 107 | onRemove={singleHeader ? undefined : () => removeHeader(index)} |
| 102 | 108 | existingSecrets={existingSecrets} |
| 109 | + sourceName={sourceName} | |
| 103 | 110 | /> |
| 104 | 111 | ))} |
| 105 | 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 | 2 | import { useAtomSet } from "@effect-atom/atom-react"; |
| 3 | 3 | |
| 4 | 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 | 71 | function InlineCreateSecret(props: { |
| 64 | − headerName: string; | |
| 65 | 72 | suggestedId: string; |
| 73 | + suggestedName: string; | |
| 66 | 74 | onCreated: (secretId: string) => void; |
| 67 | 75 | onCancel: () => void; |
| 68 | 76 | }) { |
| 69 | − const [secretId, setSecretId] = useState(props.suggestedId); | |
| 70 | − const [secretName, setSecretName] = useState(props.headerName); | |
| 77 | + const [nameOverride, setNameOverride] = useState<string | null>(null); | |
| 78 | + const [idOverride, setIdOverride] = useState<string | null>(null); | |
| 71 | 79 | const [secretValue, setSecretValue] = useState(""); |
| 72 | 80 | const [secretRevealed, setSecretRevealed] = useState(false); |
| 73 | 81 | const [saving, setSaving] = useState(false); |
| @@ -78,6 +86,9 @@ function InlineCreateSecret(props: { · 4 unmodified lines | ||
| 78 | 86 | const secretNameInputId = useId(); |
| 79 | 87 | const secretValueInputId = useId(); |
| 80 | 88 | |
| 89 | + const secretName = nameOverride ?? props.suggestedName; | |
| 90 | + const secretId = idOverride ?? (slugifyForSecretId(secretName) || "custom-header"); | |
| 91 | + | |
| 81 | 92 | const handleSave = async () => { |
| 82 | 93 | if (!secretId.trim() || !secretValue.trim()) return; |
| 83 | 94 | setSaving(true); |
| @@ -87,7 +98,7 @@ function InlineCreateSecret(props: { · 3 unmodified lines | ||
| 87 | 98 | path: { scopeId }, |
| 88 | 99 | payload: { |
| 89 | 100 | id: SecretId.make(secretId.trim()), |
| 90 | − name: `${secretName.trim() || secretId.trim()} (Auth header: ${props.headerName})`, | |
| 101 | + name: secretName.trim() || secretId.trim(), | |
| 91 | 102 | value: secretValue.trim(), |
| 92 | 103 | }, |
| 93 | 104 | reactivityKeys: secretWriteKeys, |
| @@ -100,40 +111,43 @@ function InlineCreateSecret(props: { · 6 unmodified lines | ||
| 100 | 111 | }; |
| 101 | 112 | |
| 102 | 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 | 115 | <p className="text-[11px] font-semibold text-primary tracking-wide uppercase">New secret</p> |
| 105 | 116 | <FieldGroup className="gap-3"> |
| 106 | 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 | 127 | <Field> |
| 108 | 128 | <FieldLabel htmlFor={secretIdInputId}>ID</FieldLabel> |
| 109 | 129 | <Input |
| 110 | 130 | id={secretIdInputId} |
| 111 | 131 | value={secretId} |
| 112 | − onChange={(e) => setSecretId((e.target as HTMLInputElement).value)} | |
| 132 | + onChange={(e) => setIdOverride((e.target as HTMLInputElement).value)} | |
| 113 | 133 | placeholder="my-api-token" |
| 114 | 134 | className="font-mono" |
| 115 | 135 | /> |
| 116 | 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 | 137 | </div> |
| 127 | 138 | <Field> |
| 128 | 139 | <FieldLabel htmlFor={secretValueInputId}>Value</FieldLabel> |
| 129 | 140 | <div className="relative"> |
| 130 | 141 | <Input |
| 131 | 142 | id={secretValueInputId} |
| 132 | − type={secretRevealed ? "text" : "password"} | |
| 143 | + type="text" | |
| 133 | 144 | value={secretValue} |
| 134 | 145 | onChange={(e) => setSecretValue((e.target as HTMLInputElement).value)} |
| 135 | 146 | placeholder="paste your token or key…" |
| 136 | 147 | className="pr-9 font-mono" |
| 148 | + style={ | |
| 149 | + secretRevealed ? undefined : ({ WebkitTextSecurity: "disc" } as CSSProperties) | |
| 150 | + } | |
| 137 | 151 | /> |
| 138 | 152 | <Button |
| 139 | 153 | type="button" |
| @@ -293,6 +307,12 @@ export function SecretHeaderAuthRow(props: { · 153 unmodified lines | ||
| 293 | 307 | onRemove?: () => void; |
| 294 | 308 | removeLabel?: string; |
| 295 | 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 | 317 | const [creating, setCreating] = useState(false); |
| 298 | 318 | const nameInputId = useId(); |
| @@ -308,24 +328,25 @@ export function SecretHeaderAuthRow(props: { · 9 unmodified lines | ||
| 308 | 328 | onRemove, |
| 309 | 329 | removeLabel = "Remove", |
| 310 | 330 | label = "Header", |
| 331 | + sourceName, | |
| 311 | 332 | } = props; |
| 312 | 333 | |
| 313 | 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 | 339 | if (creating) { |
| 317 | 340 | return ( |
| 318 | − <div className="px-4 py-3"> | |
| 319 | − <InlineCreateSecret | |
| 320 | − headerName={name || "Custom Header"} | |
| 321 | − suggestedId={suggestedId} | |
| 322 | − onCreated={(id) => { | |
| 323 | − onSelectSecret(id); | |
| 324 | − setCreating(false); | |
| 325 | − }} | |
| 326 | − onCancel={() => setCreating(false)} | |
| 327 | − /> | |
| 328 | − </div> | |
| 341 | + <InlineCreateSecret | |
| 342 | + suggestedId={suggestedId} | |
| 343 | + suggestedName={suggestedName} | |
| 344 | + onCreated={(id) => { | |
| 345 | + onSelectSecret(id); | |
| 346 | + setCreating(false); | |
| 347 | + }} | |
| 348 | + onCancel={() => setCreating(false)} | |
| 349 | + /> | |
| 329 | 350 | ); |
| 330 | 351 | } |
| 331 | 352 | |
| @@ -382,19 +403,12 @@ export function SecretHeaderAuthRow(props: { · 50 unmodified lines | ||
| 382 | 403 | </Field> |
| 383 | 404 | </FieldGroup> |
| 384 | 405 | |
| 385 | − <div className="flex items-center gap-1.5"> | |
| 386 | − <div className="flex-1 min-w-0"> | |
| 387 | − <SecretPicker value={secretId} onSelect={onSelectSecret} secrets={existingSecrets} /> | |
| 388 | − </div> | |
| 389 | − <Button | |
| 390 | − variant="outline" | |
| 391 | − size="sm" | |
| 392 | − className="shrink-0" | |
| 393 | − onClick={() => setCreating(true)} | |
| 394 | − > | |
| 395 | − + New | |
| 396 | − </Button> | |
| 397 | − </div> | |
| 406 | + <SecretPicker | |
| 407 | + value={secretId} | |
| 408 | + onSelect={onSelectSecret} | |
| 409 | + secrets={existingSecrets} | |
| 410 | + onCreateNew={() => setCreating(true)} | |
| 411 | + /> | |
| 398 | 412 | |
| 399 | 413 | {secretId && name.trim() && ( |
| 400 | 414 | <HeaderValuePreview headerName={name.trim()} secretId={secretId} prefix={prefix} /> |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { useState, type ChangeEvent, type FocusEvent } from "react"; |
| 2 | +import { PlusIcon } from "lucide-react"; | |
| 2 | 3 | |
| 3 | 4 | import { Input } from "../components/input"; |
| 4 | 5 | import { |
| @@ -7,6 +8,7 @@ import { · 2 unmodified lines | ||
| 7 | 8 | CommandGroup, |
| 8 | 9 | CommandItem, |
| 9 | 10 | CommandList, |
| 11 | + CommandSeparator, | |
| 10 | 12 | } from "../components/command"; |
| 11 | 13 | import { Popover, PopoverAnchor, PopoverContent } from "../components/popover"; |
| 12 | 14 | |
| @@ -33,8 +35,10 @@ export function SecretPicker(props: { · 20 unmodified lines | ||
| 33 | 35 | readonly onSelect: (secretId: string) => void; |
| 34 | 36 | readonly secrets: readonly SecretPickerSecret[]; |
| 35 | 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 | 42 | const [open, setOpen] = useState(false); |
| 39 | 43 | const [query, setQuery] = useState(""); |
| 40 | 44 | |
| @@ -122,6 +126,25 @@ export function SecretPicker(props: { · 81 unmodified lines | ||
| 122 | 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 | 148 | </CommandList> |
| 126 | 149 | </Command> |
| 127 | 150 | </PopoverContent> |