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.
8+import { randomBytes } from "node:crypto";
9+
10+import { Effect } from "effect";
11+import { composePluginApi } from "@executor-js/api/server";
12+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
13+import { IntegrationSlug } from "@executor-js/sdk/shared";
14+
15+import { scenario } from "../src/scenario";
16+import { Api, Browser, Target } from "../src/services";
17+
18+const api = composePluginApi([openApiHttpPlugin()] as const);
19+
20+const 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+
32+scenario(
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
159159 /** Force the per-input label even for a single input (env vars name their
160160 * field rather than relying on a generic "value / token" placeholder). */
161161 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;
162166 readonly values: Readonly<Record<string, string>>;
163167 readonly onChange: (values: Record<string, string>) => void;
164168 }) {
@@ -178,7 +182,11 @@ function PasteCredentialInputs(props: { · 13 unmodified lines
178182 <Input
179183 id={`credential-input-${input.variable}`}
180184 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"
182190 placeholder={`paste ${input.label}`}
183191 value={props.values[input.variable] ?? ""}
184192 onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
@@ -206,20 +214,55 @@ function PasteCredentialInputs(props: { · 21 unmodified lines
206214 {labelled && (
207215 <Label className="font-mono text-xs text-muted-foreground">{input.label}</Label>
208216 )}
209− <Input
210− type="password"
211− autoComplete="new-password"
212− placeholder={labelled ? "secret value" : "paste the value / token"}
213− value={props.values[input.variable] ?? ""}
214− onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
215− props.onChange({
216− ...props.values,
217− [input.variable]: e.target.value,
218− })
219− }
220− className="font-mono"
221− data-ph-block
222− />
217+ {props.affix ? (
218+ // Merged field: the placement's lead + prefix is a FIXED addon (its
219+ // own muted segment, divider, non-selectable), and the user types
220+ // only the token after it — the field reads as the header value being
221+ // built. Only the border reacts to focus (no full-field glow). The
222+ // autoComplete/ignore attrs stop the browser and password managers
223+ // from offering to GENERATE a password here; the app's own 1Password
224+ // picker covers filling an existing secret.
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">
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">
227+ {props.affix}
228+ </span>
229+ {/* oxlint-disable-next-line react/forbid-elements */}
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+ )}
223266 </div>
224267 ))}
225268 </div>
@@ -297,6 +340,8 @@ function CredentialValueFields(props: { · 71 unmodified lines
297340 readonly inputs: readonly CredentialInput[];
298341 readonly singleInput: boolean;
299342 readonly showLabels?: boolean;
343+ /** Single-input only: placement lead + prefix to merge into the field. */
344+ readonly affix?: string | null;
300345 /** Allow an external provider (1Password) origin. Off for env methods: the
301346 * wire's external `from` is keyed under the canonical `token` variable, but
302347 * an env credential must be keyed under its env var name, so a 1Password
@@ -351,6 +396,7 @@ function CredentialValueFields(props: { · 48 unmodified lines
351396 inputs={props.inputs}
352397 singleInput={props.singleInput}
353398 showLabels={props.showLabels}
399+ affix={props.affix}
354400 values={props.values}
355401 onChange={props.onValuesChange}
356402 />
@@ -1002,6 +1048,20 @@ function AddAccountModalView(props: AddAccountModalProps) { · 645 unmodified lines
10021048 // registration entirely.
10031049 const isCimd = isOAuth && method?.oauth?.supportsClientIdMetadataDocument === true;
10041050 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]);
10051065 // DCR-capable: the integration advertises dynamic registration (MCP oauth2),
10061066 // OR carries a discovery URL we can probe at connect time. When DCR-capable
10071067 // and not yet fallen back, we skip the app picker entirely (Option A).
@@ -1607,9 +1667,9 @@ function AddAccountModalView(props: AddAccountModalProps) { · 599 unmodified lines
16071667 <Tabs
16081668 value={methodId}
16091669 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"
16111671 >
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]">
16131673 <div className="flex w-max shrink-0 items-stretch gap-1">
16141674 {allMethods.map((m: AuthMethod) => (
16151675 <div
@@ -1669,10 +1729,14 @@ function AddAccountModalView(props: AddAccountModalProps) { · 53 unmodified lines
16691729 "mt-0 min-w-0 space-y-5",
16701730 // No-auth renders no fields, so skip the framed box that
16711731 // 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",
16731737 )}
16741738 >
1675− {method?.placements && !isEnvMethod && singleInput
1739+ {method?.placements && !isEnvMethod && singleInput && !singleCredentialAffix
16761740 ? (() => {
16771741 const shown = method.placements.filter((p) => p.carrier !== "env");
16781742 return shown.length > 0 ? (
@@ -1818,6 +1882,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 139 unmodified lines
18181882 inputs={credentialInputs}
18191883 singleInput={singleInput}
18201884 showLabels={isEnvMethod}
1885+ affix={singleCredentialAffix}
18211886 allowExternalProvider={!isEnvMethod}
18221887 values={values}
18231888 onValuesChange={setValues}
packages/react/src/components/placement-editor.tsx+23−1
@@ -1,4 +1,4 @@
1−import { PlusIcon, XIcon } from "lucide-react";
1+import { PlusIcon, TriangleAlertIcon, XIcon } from "lucide-react";
22
33 import {
44 emptyPlacement,
@@ -47,6 +47,10 @@ export function PlacementEditor(props: { · 42 unmodified lines
4747 const remove = (index: number): void =>
4848 onChange(placements.filter((_p: Placement, j: number) => j !== index));
4949
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+
5054 return (
5155 <div className="flex flex-col gap-3">
5256 <div className="flex flex-wrap gap-1.5">
@@ -147,6 +151,24 @@ export function PlacementEditor(props: { · 94 unmodified lines
147151 ) : null}
148152 </div>
149153 ))}
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+
150172 <Button
151173 type="button"
152174 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