History

Refine the connect modal's API-key credential UX (#1195)

- Merged credential field: the placement's lead + prefix is a fixed, non-editable addon inside the field, so it reads as the header value being built ("Authorization: Bearer <token>") and the separate preview line is dropped. autoComplete/ignore attrs stop the browser and password managers from offering to GENERATE a password here; the app's own 1Password picker still covers filling an existing secret. - Attached tabs: the auth-method tabs join the panel as one card (full-width rounded-top header, hairline divider) instead of a pill bar floating above a separate card. - Prefix trailing-space warning (amber): a non-empty prefix with no trailing space is sent joined to the value ("Bearer" + token -> "Bearertoken"). Advisory, not blocking.

Rhys Sullivan committed 4 months ago

Commit b1e73d21 parent 53bfa033 files · +193 −20Browse files

Unified · Split

3 files
  1. e2e/selfhost/connect-modal-credential-ux.test.ts +86 −0
  2. packages/react/src/components/add-account-modal.tsx +84 −19
  3. packages/react/src/components/placement-editor.tsx +23 −1
e2e/selfhost/connect-modal-credential-ux.test.ts+86−0
@@ -0,0 +1,86 @@
1// Selfhost-only (browser): the connect modal's API-key credential UX.
2// - the credential field MERGES the placement's lead + prefix as an affix, so
3// it reads as the header value being built ("Authorization: Bearer ▏token");
4// - the "Add authentication method" editor offers placement presets;
5// - a prefix with no trailing space (sent joined to the value) warns, and the
6// warning clears once the space is restored.
7// Video is the artifact.
8import { randomBytes } from "node:crypto";
9
10import { Effect } from "effect";
11import { composePluginApi } from "@executor-js/api/server";
12import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
13import { IntegrationSlug } from "@executor-js/sdk/shared";
14
15import { scenario } from "../src/scenario";
16import { Api, Browser, Target } from "../src/services";
17
18const api = composePluginApi([openApiHttpPlugin()] as const);
19
20const bearerSpec = (): string =>
21 JSON.stringify({
22 openapi: "3.0.3",
23 info: { title: "Bearer Fixture", version: "1.0.0" },
24 servers: [{ url: "https://api.bearerfix.test" }],
25 security: [{ bearerAuth: [] }],
26 components: { securitySchemes: { bearerAuth: { type: "http", scheme: "bearer" } } },
27 paths: {
28 "/ping": { get: { operationId: "ping", responses: { "200": { description: "ok" } } } },
29 },
30 });
31
32scenario(
33 "Connect modal · API key credential UX: merged affix, add-method, prefix warning",
34 {},
35 Effect.scoped(
36 Effect.gen(function* () {
37 const target = yield* Target;
38 const { client: makeApiClient } = yield* Api;
39 const browser = yield* Browser;
40 const identity = yield* target.newIdentity();
41 const apiClient = yield* makeApiClient(api, identity);
42 const slug = `connect_ux_${randomBytes(4).toString("hex")}`;
43
44 yield* Effect.ensuring(
45 Effect.gen(function* () {
46 yield* apiClient.openapi.addSpec({
47 payload: { spec: { kind: "blob", value: bearerSpec() }, slug },
48 });
49
50 yield* browser.session(identity, async ({ page, step }) => {
51 await step("Open the connect modal", async () => {
52 await page.goto(`/integrations/${slug}?addAccount=1`, { waitUntil: "networkidle" });
53 await page.getByRole("heading", { name: /Add connection/ }).waitFor();
54 });
55
56 await step("The credential field merges the placement prefix", async () => {
57 // The placement's lead + prefix renders as a non-editable affix
58 // inside the field, so there is no separate preview line.
59 await page.getByText("Authorization: Bearer").first().waitFor();
60 });
61
62 await step("The add-method editor offers placement presets", async () => {
63 await page.getByRole("button", { name: "Add authentication method" }).click();
64 await page.getByRole("heading", { name: "Add authentication method" }).waitFor();
65 await page.getByRole("button", { name: "Bearer header" }).waitFor();
66 await page.getByRole("button", { name: "API key query" }).waitFor();
67 });
68
69 await step("A prefix with no trailing space warns", async () => {
70 await page.getByPlaceholder("Bearer ").first().fill("Bearer");
71 await page.getByText("Prefix has no trailing space").waitFor();
72 });
73
74 await step("Restoring the trailing space clears the warning", async () => {
75 await page.getByPlaceholder("Bearer ").first().fill("Bearer ");
76 await page.getByText("Prefix has no trailing space").waitFor({ state: "detached" });
77 });
78 });
79 }),
80 apiClient.openapi
81 .removeSpec({ params: { slug: IntegrationSlug.make(slug) } })
82 .pipe(Effect.ignore),
83 );
84 }),
85 ),
86);
packages/react/src/components/add-account-modal.tsx+84−19
@@ -159,6 +159,10 @@ function PasteCredentialInputs(props: { · 158 unmodified lines
159 /** Force the per-input label even for a single input (env vars name their159 /** Force the per-input label even for a single input (env vars name their
160 * field rather than relying on a generic "value / token" placeholder). */160 * field rather than relying on a generic "value / token" placeholder). */
161 readonly showLabels?: boolean;161 readonly showLabels?: boolean;
162 /** Single-input only: the placement's lead + prefix (e.g. "Authorization:
163 * Bearer "), rendered as a non-editable affix INSIDE the field so the input
164 * reads as the header value being built. Replaces the separate preview line. */
165 readonly affix?: string | null;
162 readonly values: Readonly<Record<string, string>>;166 readonly values: Readonly<Record<string, string>>;
163 readonly onChange: (values: Record<string, string>) => void;167 readonly onChange: (values: Record<string, string>) => void;
164}) {168}) {
@@ -178,7 +182,11 @@ function PasteCredentialInputs(props: { · 13 unmodified lines
178 <Input182 <Input
179 id={`credential-input-${input.variable}`}183 id={`credential-input-${input.variable}`}
180 type="password"184 type="password"
181 autoComplete="new-password"185 autoComplete="off"
186 data-1p-ignore
187 data-lpignore="true"
188 data-bwignore
189 data-form-type="other"
182 placeholder={`paste ${input.label}`}190 placeholder={`paste ${input.label}`}
183 value={props.values[input.variable] ?? ""}191 value={props.values[input.variable] ?? ""}
184 onChange={(e: React.ChangeEvent<HTMLInputElement>) =>192 onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
@@ -206,20 +214,55 @@ function PasteCredentialInputs(props: { · 21 unmodified lines
206 {labelled && (214 {labelled && (
207 <Label className="font-mono text-xs text-muted-foreground">{input.label}</Label>215 <Label className="font-mono text-xs text-muted-foreground">{input.label}</Label>
208 )}216 )}
209 <Input217 {props.affix ? (
210 type="password"218 // Merged field: the placement's lead + prefix is a FIXED addon (its
211 autoComplete="new-password"219 // own muted segment, divider, non-selectable), and the user types
212 placeholder={labelled ? "secret value" : "paste the value / token"}220 // only the token after it — the field reads as the header value being
213 value={props.values[input.variable] ?? ""}221 // built. Only the border reacts to focus (no full-field glow). The
214 onChange={(e: React.ChangeEvent<HTMLInputElement>) =>222 // autoComplete/ignore attrs stop the browser and password managers
215 props.onChange({223 // from offering to GENERATE a password here; the app's own 1Password
216 ...props.values,224 // picker covers filling an existing secret.
217 [input.variable]: e.target.value,225 <div className="flex h-9 w-full min-w-0 items-stretch overflow-hidden rounded-md border border-input bg-transparent font-mono text-sm shadow-xs transition-colors focus-within:border-ring dark:bg-input/30">
218 })226 <span className="flex shrink-0 select-none items-center whitespace-pre border-r border-input bg-muted/40 px-3 text-muted-foreground/70">
219 }227 {props.affix}
220 className="font-mono"228 </span>
221 data-ph-block229 {/* oxlint-disable-next-line react/forbid-elements */}
222 />230 <input
231 type="password"
232 autoComplete="off"
233 data-1p-ignore
234 data-lpignore="true"
235 data-bwignore
236 data-form-type="other"
237 placeholder="token"
238 value={props.values[input.variable] ?? ""}
239 onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
240 props.onChange({ ...props.values, [input.variable]: e.target.value })
241 }
242 className="min-w-0 flex-1 bg-transparent px-3 outline-none placeholder:text-muted-foreground/60"
243 data-ph-block
244 />
245 </div>
246 ) : (
247 <Input
248 type="password"
249 autoComplete="off"
250 data-1p-ignore
251 data-lpignore="true"
252 data-bwignore
253 data-form-type="other"
254 placeholder={labelled ? "secret value" : "paste the value / token"}
255 value={props.values[input.variable] ?? ""}
256 onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
257 props.onChange({
258 ...props.values,
259 [input.variable]: e.target.value,
260 })
261 }
262 className="font-mono"
263 data-ph-block
264 />
265 )}
223 </div>266 </div>
224 ))}267 ))}
225 </div>268 </div>
@@ -297,6 +340,8 @@ function CredentialValueFields(props: { · 71 unmodified lines
297 readonly inputs: readonly CredentialInput[];340 readonly inputs: readonly CredentialInput[];
298 readonly singleInput: boolean;341 readonly singleInput: boolean;
299 readonly showLabels?: boolean;342 readonly showLabels?: boolean;
343 /** Single-input only: placement lead + prefix to merge into the field. */
344 readonly affix?: string | null;
300 /** Allow an external provider (1Password) origin. Off for env methods: the345 /** Allow an external provider (1Password) origin. Off for env methods: the
301 * wire's external `from` is keyed under the canonical `token` variable, but346 * wire's external `from` is keyed under the canonical `token` variable, but
302 * an env credential must be keyed under its env var name, so a 1Password347 * an env credential must be keyed under its env var name, so a 1Password
@@ -351,6 +396,7 @@ function CredentialValueFields(props: { · 48 unmodified lines
351 inputs={props.inputs}396 inputs={props.inputs}
352 singleInput={props.singleInput}397 singleInput={props.singleInput}
353 showLabels={props.showLabels}398 showLabels={props.showLabels}
399 affix={props.affix}
354 values={props.values}400 values={props.values}
355 onChange={props.onValuesChange}401 onChange={props.onValuesChange}
356 />402 />
@@ -1002,6 +1048,20 @@ function AddAccountModalView(props: AddAccountModalProps) { · 645 unmodified lines
1002 // registration entirely.1048 // registration entirely.
1003 const isCimd = isOAuth && method?.oauth?.supportsClientIdMetadataDocument === true;1049 const isCimd = isOAuth && method?.oauth?.supportsClientIdMetadataDocument === true;
1004 const cimdActive = isCimd;1050 const cimdActive = isCimd;
1051 // Single-input header/query methods: the placement's lead + prefix (e.g.
1052 // "Authorization: Bearer ") merges INTO the credential field as a non-editable
1053 // affix, so the input reads as the header value being built and the separate
1054 // preview line is dropped. Env and multi-input methods keep the plain field.
1055 const singleCredentialAffix = useMemo<string | null>(() => {
1056 if (!method || !singleInput || isEnvMethod) return null;
1057 const placement = method.placements.find((p) => p.carrier !== "env");
1058 if (!placement) return null;
1059 const lead =
1060 placement.carrier === "header"
1061 ? `${placement.name || "Authorization"}: `
1062 : `?${placement.name || "api_key"}=`;
1063 return `${lead}${placement.prefix ?? ""}`;
1064 }, [method, singleInput, isEnvMethod]);
1005 // DCR-capable: the integration advertises dynamic registration (MCP oauth2),1065 // DCR-capable: the integration advertises dynamic registration (MCP oauth2),
1006 // OR carries a discovery URL we can probe at connect time. When DCR-capable1066 // OR carries a discovery URL we can probe at connect time. When DCR-capable
1007 // and not yet fallen back, we skip the app picker entirely (Option A).1067 // and not yet fallen back, we skip the app picker entirely (Option A).
@@ -1607,9 +1667,9 @@ function AddAccountModalView(props: AddAccountModalProps) { · 599 unmodified lines
1607 <Tabs1667 <Tabs
1608 value={methodId}1668 value={methodId}
1609 onValueChange={selectMethod}1669 onValueChange={selectMethod}
1610 className="w-full min-w-0 max-w-full gap-3"1670 className="w-full min-w-0 max-w-full gap-0"
1611 >1671 >
1612 <TabsList className="flex h-10 w-fit min-w-0 max-w-full justify-start overflow-x-auto overflow-y-hidden p-1 [scrollbar-width:thin]">1672 <TabsList className="flex h-10 w-full min-w-0 max-w-full justify-start overflow-x-auto overflow-y-hidden rounded-b-none rounded-t-md border border-b-0 border-border/60 bg-muted/30 p-1 [scrollbar-width:thin]">
1613 <div className="flex w-max shrink-0 items-stretch gap-1">1673 <div className="flex w-max shrink-0 items-stretch gap-1">
1614 {allMethods.map((m: AuthMethod) => (1674 {allMethods.map((m: AuthMethod) => (
1615 <div1675 <div
@@ -1669,10 +1729,14 @@ function AddAccountModalView(props: AddAccountModalProps) { · 53 unmodified lines
1669 "mt-0 min-w-0 space-y-5",1729 "mt-0 min-w-0 space-y-5",
1670 // No-auth renders no fields, so skip the framed box that1730 // No-auth renders no fields, so skip the framed box that
1671 // would otherwise show up as an empty bordered panel.1731 // would otherwise show up as an empty bordered panel.
1672 isNoAuth ? null : "rounded-md border border-border/60 bg-muted/15 p-4",1732 // Otherwise the panel attaches to the tab header above
1733 // (square top, rounded bottom).
1734 isNoAuth
1735 ? null
1736 : "rounded-b-md rounded-t-none border border-border/60 bg-muted/15 p-4",
1673 )}1737 )}
1674 >1738 >
1675 {method?.placements && !isEnvMethod && singleInput1739 {method?.placements && !isEnvMethod && singleInput && !singleCredentialAffix
1676 ? (() => {1740 ? (() => {
1677 const shown = method.placements.filter((p) => p.carrier !== "env");1741 const shown = method.placements.filter((p) => p.carrier !== "env");
1678 return shown.length > 0 ? (1742 return shown.length > 0 ? (
@@ -1818,6 +1882,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 139 unmodified lines
1818 inputs={credentialInputs}1882 inputs={credentialInputs}
1819 singleInput={singleInput}1883 singleInput={singleInput}
1820 showLabels={isEnvMethod}1884 showLabels={isEnvMethod}
1885 affix={singleCredentialAffix}
1821 allowExternalProvider={!isEnvMethod}1886 allowExternalProvider={!isEnvMethod}
1822 values={values}1887 values={values}
1823 onValuesChange={setValues}1888 onValuesChange={setValues}
packages/react/src/components/placement-editor.tsx+23−1
@@ -1,4 +1,4 @@
1import { PlusIcon, XIcon } from "lucide-react";1import { PlusIcon, TriangleAlertIcon, XIcon } from "lucide-react";
2 2
3import {3import {
4 emptyPlacement,4 emptyPlacement,
@@ -47,6 +47,10 @@ export function PlacementEditor(props: { · 42 unmodified lines
47 const remove = (index: number): void =>47 const remove = (index: number): void =>
48 onChange(placements.filter((_p: Placement, j: number) => j !== index));48 onChange(placements.filter((_p: Placement, j: number) => j !== index));
49 49
50 // A non-empty prefix with no trailing space is sent JOINED to the credential
51 // (e.g. "Bearer" + token -> "Bearertoken"). Almost always a mistake: warn.
52 const barePrefix = placements.some((p) => p.prefix.length > 0 && !p.prefix.endsWith(" "));
53
50 return (54 return (
51 <div className="flex flex-col gap-3">55 <div className="flex flex-col gap-3">
52 <div className="flex flex-wrap gap-1.5">56 <div className="flex flex-wrap gap-1.5">
@@ -147,6 +151,24 @@ export function PlacementEditor(props: { · 94 unmodified lines
147 ) : null}151 ) : null}
148 </div>152 </div>
149 ))}153 ))}
154 {barePrefix ? (
155 <section className="grid grid-cols-[auto_1fr] gap-x-2.5 gap-y-1 rounded-md border border-l-[3px] border-amber-300/70 border-l-amber-500 bg-amber-50 px-3 py-2.5 text-[12px] leading-5 dark:border-amber-500/25 dark:border-l-amber-500/80 dark:bg-amber-500/10">
156 <TriangleAlertIcon
157 className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400"
158 aria-hidden
159 />
160 <div className="min-w-0 space-y-1">
161 <p className="font-medium text-amber-900 dark:text-amber-100">
162 Prefix has no trailing space
163 </p>
164 <p className="text-amber-900/80 dark:text-amber-100/80">
165 It is sent joined to the value (<span className="font-mono">Bearer••••••</span>). Most
166 APIs expect a space, like <span className="font-mono">"Bearer "</span>.
167 </p>
168 </div>
169 </section>
170 ) : null}
171
150 <Button172 <Button
151 type="button"173 type="button"
152 variant="outline"174 variant="outline"

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