History

feat(react): polish secret creation UX in header-auth flow

- 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.

Rhys Sullivan committed 7 months ago

Commit b5003fd1 parent b88c4909 files · +102 −73Browse files

Unified · Split

9 files
  1. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +1 −0
  2. packages/plugins/graphql/src/react/EditGraphqlSource.tsx +1 −0
  3. packages/plugins/mcp/src/react/AddMcpSource.tsx +1 −0
  4. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +4 −29
  5. packages/plugins/openapi/src/react/EditOpenApiSource.tsx +1 −0
  6. packages/plugins/openapi/src/sdk/preview.ts +7 −1
  7. packages/react/src/plugins/headers-list.tsx +7 −0
  8. packages/react/src/plugins/secret-header-auth.tsx +56 −42
  9. packages/react/src/plugins/secret-picker.tsx +24 −1
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+1−0
@@ -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
packages/plugins/graphql/src/react/EditGraphqlSource.tsx+1−0
@@ -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
packages/plugins/mcp/src/react/AddMcpSource.tsx+1−0
@@ -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 singleHeader760 singleHeader
761 sourceName={remoteIdentity.name}
761 />762 />
762 )}763 )}
763 764
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+4−29
@@ -1,4 +1,4 @@
1import { useCallback, useEffect, useMemo, useRef, useState } from "react";1import { useCallback, useEffect, useRef, useState } from "react";
2import { useAtomSet } from "@effect-atom/atom-react";2import { useAtomSet } from "@effect-atom/atom-react";
3import { Option } from "effect";3import { 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";
40import { Textarea } from "@executor/react/components/textarea";40import { Textarea } from "@executor/react/components/textarea";
41import { Checkbox } from "@executor/react/components/checkbox";41import { Checkbox } from "@executor/react/components/checkbox";
42import { SourceFavicon } from "@executor/react/components/source-favicon";
42import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group";43import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group";
43import { Skeleton } from "@executor/react/components/skeleton";44import { Skeleton } from "@executor/react/components/skeleton";
44import { IOSSpinner, Spinner } from "@executor/react/components/spinner";45import { 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
packages/plugins/openapi/src/react/EditOpenApiSource.tsx+1−0
@@ -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
packages/plugins/openapi/src/sdk/preview.ts+7−1
@@ -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,
packages/react/src/plugins/headers-list.tsx+7−0
@@ -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
31export function HeadersList({36export 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)} />}
packages/react/src/plugins/secret-header-auth.tsx+56−42
@@ -1,4 +1,4 @@
1import { useId, useState } from "react";1import { useId, useState, type CSSProperties } from "react";
2import { useAtomSet } from "@effect-atom/atom-react";2import { useAtomSet } from "@effect-atom/atom-react";
3 3
4import { setSecret, resolveSecret } from "../api/atoms";4import { setSecret, resolveSecret } from "../api/atoms";
@@ -60,14 +60,22 @@ function SecretVisibilityIcon(props: { revealed: boolean }) { · 55 unmodified lines
60 );60 );
61}61}
62 62
63function slugifyForSecretId(input: string): string {
64 return input
65 .trim()
66 .toLowerCase()
67 .replace(/[^a-z0-9]+/g, "-")
68 .replace(/^-+|-+$/g, "");
69}
70
63function InlineCreateSecret(props: {71function 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 <Input129 <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 <Input141 <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 <Button152 <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 <InlineCreateSecret342 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 <Button410 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} />
packages/react/src/plugins/secret-picker.tsx+24−1
@@ -1,4 +1,5 @@
1import { useState, type ChangeEvent, type FocusEvent } from "react";1import { useState, type ChangeEvent, type FocusEvent } from "react";
2import { PlusIcon } from "lucide-react";
2 3
3import { Input } from "../components/input";4import { Input } from "../components/input";
4import {5import {
@@ -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";
11import { Popover, PopoverAnchor, PopoverContent } from "../components/popover";13import { 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>

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t