Skip to content

Commit b1e73d2

Browse files
authored
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.
1 parent 53bfa03 commit b1e73d2

3 files changed

Lines changed: 193 additions & 20 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 84 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ function PasteCredentialInputs(props: {
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: {
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: {
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: {
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: {
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) {
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) {
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) {
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) {
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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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: {
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: {
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"

0 commit comments

Comments
 (0)