History

Merge pull request #283 from RhysSullivan/rs/secret-input-ux

Polish secret creation UX in header-auth flow

Rhys Sullivan committed 7 months ago

Commit 96dc18a2 parents 24059d0 b5003fd9 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