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