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
132132 headers={headers}
133133 onHeadersChange={setHeaders}
134134 existingSecrets={secretList}
135+ sourceName={identity.name}
135136 />
136137 </section>
137138
packages/plugins/graphql/src/react/EditGraphqlSource.tsx+1−0
@@ -123,6 +123,7 @@ function EditForm(props: { · 122 unmodified lines
123123 headers={headers}
124124 onHeadersChange={handleHeadersChange}
125125 existingSecrets={secretList}
126+ sourceName={identity.name}
126127 />
127128 </section>
128129
packages/plugins/mcp/src/react/AddMcpSource.tsx+1−0
@@ -758,6 +758,7 @@ export default function AddMcpSource(props: { · 757 unmodified lines
758758 onHeadersChange={setRemoteAuthHeaders}
759759 existingSecrets={secretList}
760760 singleHeader
761+ sourceName={remoteIdentity.name}
761762 />
762763 )}
763764
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+4−29
@@ -1,4 +1,4 @@
1−import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1+import { useCallback, useEffect, useRef, useState } from "react";
22 import { useAtomSet } from "@effect-atom/atom-react";
33 import { Option } from "effect";
44
@@ -39,6 +39,7 @@ import { · 34 unmodified lines
3939 } from "@executor/react/components/native-select";
4040 import { Textarea } from "@executor/react/components/textarea";
4141 import { Checkbox } from "@executor/react/components/checkbox";
42+import { SourceFavicon } from "@executor/react/components/source-favicon";
4243 import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group";
4344 import { Skeleton } from "@executor/react/components/skeleton";
4445 import { IOSSpinner, Spinner } from "@executor/react/components/spinner";
@@ -224,26 +225,6 @@ export default function AddOpenApiSource(props: { · 179 unmodified lines
224225 return out;
225226 };
226227
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−
247228 const allHeaders: Record<string, HeaderValue> = {};
248229 for (const ch of customHeaders) {
249230 if (ch.name.trim() && ch.secretId) {
@@ -542,14 +523,7 @@ export default function AddOpenApiSource(props: { · 292 unmodified lines
542523 <CardStack>
543524 <CardStackContent className="border-t-0">
544525 <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} />}
553527 <CardStackEntryContent>
554528 <CardStackEntryTitle>
555529 {Option.getOrElse(preview.title, () => "API")}
@@ -806,6 +780,7 @@ export default function AddOpenApiSource(props: { · 250 unmodified lines
806780 headers={customHeaders}
807781 onHeadersChange={handleHeadersChange}
808782 existingSecrets={secretList}
783+ sourceName={identity.name}
809784 />
810785 )}
811786
packages/plugins/openapi/src/react/EditOpenApiSource.tsx+1−0
@@ -123,6 +123,7 @@ function EditForm(props: { · 122 unmodified lines
123123 headers={headers}
124124 onHeadersChange={handleHeadersChange}
125125 existingSecrets={secretList}
126+ sourceName={identity.name}
126127 />
127128 </section>
128129
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
359359 );
360360
361361 const rawSecurity = (doc.security ?? []) as Array<Record<string, unknown>>;
362− const authStrategies = rawSecurity.map(
362+ const declaredStrategies = rawSecurity.map(
363363 (entry) => new AuthStrategy({ schemes: Object.keys(entry) }),
364364 );
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] }));
365371
366372 return new SpecPreview({
367373 title: result.title,
packages/react/src/plugins/headers-list.tsx+7−0
@@ -26,6 +26,11 @@ export interface HeadersListProps { · 25 unmodified lines
2626 readonly singleHeader?: boolean;
2727 /** Text shown in the empty state. */
2828 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;
2934 }
3035
3136 export function HeadersList({
@@ -35,6 +40,7 @@ export function HeadersList({ · 3 unmodified lines
3540 presets = defaultHeaderAuthPresets,
3641 singleHeader = false,
3742 emptyLabel = "No headers",
43+ sourceName,
3844 }: HeadersListProps) {
3945 const [picking, setPicking] = useState(false);
4046 const canAddMore = !singleHeader || headers.length === 0;
@@ -100,6 +106,7 @@ export function HeadersList({ · 59 unmodified lines
100106 onSelectSecret={(secretId) => updateHeader(index, { secretId })}
101107 onRemove={singleHeader ? undefined : () => removeHeader(index)}
102108 existingSecrets={existingSecrets}
109+ sourceName={sourceName}
103110 />
104111 ))}
105112 {canAddMore && <AddHeaderRow onClick={() => setPicking(true)} />}
packages/react/src/plugins/secret-header-auth.tsx+56−42
@@ -1,4 +1,4 @@
1−import { useId, useState } from "react";
1+import { useId, useState, type CSSProperties } from "react";
22 import { useAtomSet } from "@effect-atom/atom-react";
33
44 import { setSecret, resolveSecret } from "../api/atoms";
@@ -60,14 +60,22 @@ function SecretVisibilityIcon(props: { revealed: boolean }) { · 55 unmodified lines
6060 );
6161 }
6262
63+function slugifyForSecretId(input: string): string {
64+ return input
65+ .trim()
66+ .toLowerCase()
67+ .replace(/[^a-z0-9]+/g, "-")
68+ .replace(/^-+|-+$/g, "");
69+}
70+
6371 function InlineCreateSecret(props: {
64− headerName: string;
6572 suggestedId: string;
73+ suggestedName: string;
6674 onCreated: (secretId: string) => void;
6775 onCancel: () => void;
6876 }) {
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);
7179 const [secretValue, setSecretValue] = useState("");
7280 const [secretRevealed, setSecretRevealed] = useState(false);
7381 const [saving, setSaving] = useState(false);
@@ -78,6 +86,9 @@ function InlineCreateSecret(props: { · 4 unmodified lines
7886 const secretNameInputId = useId();
7987 const secretValueInputId = useId();
8088
89+ const secretName = nameOverride ?? props.suggestedName;
90+ const secretId = idOverride ?? (slugifyForSecretId(secretName) || "custom-header");
91+
8192 const handleSave = async () => {
8293 if (!secretId.trim() || !secretValue.trim()) return;
8394 setSaving(true);
@@ -87,7 +98,7 @@ function InlineCreateSecret(props: { · 3 unmodified lines
8798 path: { scopeId },
8899 payload: {
89100 id: SecretId.make(secretId.trim()),
90− name: `${secretName.trim() || secretId.trim()} (Auth header: ${props.headerName})`,
101+ name: secretName.trim() || secretId.trim(),
91102 value: secretValue.trim(),
92103 },
93104 reactivityKeys: secretWriteKeys,
@@ -100,40 +111,43 @@ function InlineCreateSecret(props: { · 6 unmodified lines
100111 };
101112
102113 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">
104115 <p className="text-[11px] font-semibold text-primary tracking-wide uppercase">New secret</p>
105116 <FieldGroup className="gap-3">
106117 <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>
107127 <Field>
108128 <FieldLabel htmlFor={secretIdInputId}>ID</FieldLabel>
109129 <Input
110130 id={secretIdInputId}
111131 value={secretId}
112− onChange={(e) => setSecretId((e.target as HTMLInputElement).value)}
132+ onChange={(e) => setIdOverride((e.target as HTMLInputElement).value)}
113133 placeholder="my-api-token"
114134 className="font-mono"
115135 />
116136 </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>
126137 </div>
127138 <Field>
128139 <FieldLabel htmlFor={secretValueInputId}>Value</FieldLabel>
129140 <div className="relative">
130141 <Input
131142 id={secretValueInputId}
132− type={secretRevealed ? "text" : "password"}
143+ type="text"
133144 value={secretValue}
134145 onChange={(e) => setSecretValue((e.target as HTMLInputElement).value)}
135146 placeholder="paste your token or key…"
136147 className="pr-9 font-mono"
148+ style={
149+ secretRevealed ? undefined : ({ WebkitTextSecurity: "disc" } as CSSProperties)
150+ }
137151 />
138152 <Button
139153 type="button"
@@ -293,6 +307,12 @@ export function SecretHeaderAuthRow(props: { · 153 unmodified lines
293307 onRemove?: () => void;
294308 removeLabel?: string;
295309 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;
296316 }) {
297317 const [creating, setCreating] = useState(false);
298318 const nameInputId = useId();
@@ -308,24 +328,25 @@ export function SecretHeaderAuthRow(props: { · 9 unmodified lines
308328 onRemove,
309329 removeLabel = "Remove",
310330 label = "Header",
331+ sourceName,
311332 } = props;
312333
313334 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";
315338
316339 if (creating) {
317340 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+ />
329350 );
330351 }
331352
@@ -382,19 +403,12 @@ export function SecretHeaderAuthRow(props: { · 50 unmodified lines
382403 </Field>
383404 </FieldGroup>
384405
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+ />
398412
399413 {secretId && name.trim() && (
400414 <HeaderValuePreview headerName={name.trim()} secretId={secretId} prefix={prefix} />
packages/react/src/plugins/secret-picker.tsx+24−1
@@ -1,4 +1,5 @@
11 import { useState, type ChangeEvent, type FocusEvent } from "react";
2+import { PlusIcon } from "lucide-react";
23
34 import { Input } from "../components/input";
45 import {
@@ -7,6 +8,7 @@ import { · 2 unmodified lines
78 CommandGroup,
89 CommandItem,
910 CommandList,
11+ CommandSeparator,
1012 } from "../components/command";
1113 import { Popover, PopoverAnchor, PopoverContent } from "../components/popover";
1214
@@ -33,8 +35,10 @@ export function SecretPicker(props: { · 20 unmodified lines
3335 readonly onSelect: (secretId: string) => void;
3436 readonly secrets: readonly SecretPickerSecret[];
3537 readonly placeholder?: string;
38+ /** When provided, renders a "+ New secret" row at the bottom of the dropdown. */
39+ readonly onCreateNew?: () => void;
3640 }) {
37− const { value, onSelect, secrets, placeholder = "Search secrets…" } = props;
41+ const { value, onSelect, secrets, placeholder = "Search secrets…", onCreateNew } = props;
3842 const [open, setOpen] = useState(false);
3943 const [query, setQuery] = useState("");
4044
@@ -122,6 +126,25 @@ export function SecretPicker(props: { · 81 unmodified lines
122126 </CommandGroup>
123127 );
124128 })}
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+ )}
125148 </CommandList>
126149 </Command>
127150 </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