diff --git a/e2e/selfhost/connection-modal-dcr-card.test.ts b/e2e/selfhost/connection-modal-dcr-card.test.ts new file mode 100644 index 000000000..38c153728 --- /dev/null +++ b/e2e/selfhost/connection-modal-dcr-card.test.ts @@ -0,0 +1,87 @@ +// Selfhost (browser, recorded): the add-connection modal for a transparent-DCR +// OAuth MCP integration must render its body card, not just a floating tab +// strip. A remote MCP integration that declares an oauth2 method is DCR-capable +// (supportsDynamicRegistration), so the modal skips the app picker. When the +// integration also offers the custom-method "+" the method tab strip renders. +// +// The regression this guards: the OAuth tab's TabsContent card (the "No app to +// choose / automatic setup" explainer) was gated out whenever DCR was active, +// leaving the tab strip with no card under it and a detached-looking border. +// The same gate also made that explainer unreachable dead code. The per-step +// screenshots are the artifact. +import { randomBytes } from "node:crypto"; + +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; +import { IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; + +const api = composePluginApi([mcpHttpPlugin()] as const); + +scenario( + "Connections · the add-connection modal for a transparent-DCR OAuth MCP renders its body card under the tab strip", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeApiClient } = yield* Api; + + // A remote MCP integration declaring an oauth2 method: remote + oauth2 is + // transparent-DCR (supportsDynamicRegistration true), so the modal skips + // the BYO-app picker. The server is never dialed here — opening the modal + // only reads the declared template. + const server = yield* serveMcpServer(() => makeGreetingMcpServer()); + const identity = yield* target.newIdentity(); + const client = yield* makeApiClient(api, identity); + const slug = `mcp-dcr-card-${randomBytes(3).toString("hex")}`; + + yield* client.mcp.addServer({ + payload: { + transport: "remote", + name: "DCR OAuth MCP", + endpoint: server.endpoint, + slug, + authenticationTemplate: [{ kind: "oauth2" }], + }, + }); + + // Remove the integration afterward — selfhost identities share one tenant, + // so a leaked integration would bleed into other scenarios. + yield* Effect.gen(function* () { + yield* browser.session(identity, async ({ page, step }) => { + const dialog = page.getByRole("dialog"); + + await step("Open the integration's add-connection modal", async () => { + await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await page.getByRole("button", { name: "Add connection" }).first().click(); + await dialog.waitFor({ state: "visible" }); + }); + + await step("The OAuth tab and the custom-method + are present", async () => { + await dialog.getByRole("tab", { name: "OAuth" }).waitFor(); + await dialog.getByRole("button", { name: "Add authentication method" }).waitFor(); + }); + + await step("The OAuth tab has its body card, not a floating tab strip", async () => { + // The card the fix restores: the DCR explainer that anchors the tab + // strip. Before the fix the TabsContent was gated out for DCR, so + // this copy was unreachable and the tabs floated with no card. + await dialog.getByText("No app to choose").waitFor({ timeout: 15_000 }); + await dialog.getByText(/supports automatic setup/).waitFor({ timeout: 15_000 }); + }); + }); + }).pipe( + Effect.ensuring( + client.mcp + .removeServer({ params: { slug: IntegrationSlug.make(slug) } }) + .pipe(Effect.ignore), + ), + ); + }), + ), +); diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index 08d065e8b..df33b4c06 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -1722,186 +1722,184 @@ function AddAccountModalView(props: AddAccountModalProps) { - {dcrActive ? null : ( - - {method?.placements && !isEnvMethod && singleInput && !singleCredentialAffix - ? (() => { - const shown = method.placements.filter((p) => p.carrier !== "env"); - return shown.length > 0 ? ( -
- {shown.map((placement, i: number) => ( - - ))} -
- ) : null; - })() - : null} - - {!isNoAuth && ( -
- - - {isOAuth && method ? ( - cimdActive ? ( -
-

No app registration

-

- {cimdConnecting - ? `Connecting to ${integrationName}…` - : `${integrationName} supports Client ID Metadata Document OAuth. We'll use this Executor host's public client metadata document and sign you in.`} -

-
- ) : dcrActive ? ( - // Transparent DCR: no picker. We register an app for you and run - // the OAuth flow with a single Connect click. -
-

No app to choose

-

- {dcrConnecting - ? `Connecting to ${integrationName}…` - : `${integrationName} supports automatic setup. We register an app for you and sign you in — no client ID or app to pick.`} -

-
- ) : oauthLoading ? ( -

Loading OAuth apps…

- ) : ( -
- {dcrFallbackMessage ? ( -
-

- Couldn't set up {integrationName} automatically -

-

- {dcrFallbackMessage} -

-

- Register an app below to connect. -

-
- ) : null} - {/* No registered app matched the integration's endpoint: + + {method?.placements && !isEnvMethod && singleInput && !singleCredentialAffix + ? (() => { + const shown = method.placements.filter((p) => p.carrier !== "env"); + return shown.length > 0 ? ( +
+ {shown.map((placement, i: number) => ( + + ))} +
+ ) : null; + })() + : null} + + {!isNoAuth && ( +
+ + + {isOAuth && method ? ( + cimdActive ? ( +
+

No app registration

+

+ {cimdConnecting + ? `Connecting to ${integrationName}…` + : `${integrationName} supports Client ID Metadata Document OAuth. We'll use this Executor host's public client metadata document and sign you in.`} +

+
+ ) : dcrActive ? ( + // Transparent DCR: no picker. We register an app for you and run + // the OAuth flow with a single Connect click. +
+

No app to choose

+

+ {dcrConnecting + ? `Connecting to ${integrationName}…` + : `${integrationName} supports automatic setup. We register an app for you and sign you in — no client ID or app to pick.`} +

+
+ ) : oauthLoading ? ( +

Loading OAuth apps…

+ ) : ( +
+ {dcrFallbackMessage ? ( +
+

+ Couldn't set up {integrationName} automatically +

+

+ {dcrFallbackMessage} +

+

+ Register an app below to connect. +

+
+ ) : null} + {/* No registered app matched the integration's endpoint: empty state + a prominent register CTA, and an opt-in collapsed "use a different registered app" escape hatch. */} - {oauthDisplayRegisterCTA && ( -
-

- No app for {integrationName} yet -

-

- None of your registered apps target this integration's OAuth - endpoint. Register one to connect. -

-
+ {oauthDisplayRegisterCTA && ( +
+

+ No app for {integrationName} yet +

+

+ None of your registered apps target this integration's OAuth + endpoint. Register one to connect. +

+
+ + {oauthOtherApps.length > 0 && !showOtherApps ? ( - {oauthOtherApps.length > 0 && !showOtherApps ? ( - - ) : null} -
- {oauthOtherApps.length > 0 && showOtherApps ? ( - - {oauthOtherApps.map((app: OAuthClientOption) => ( - - ))} - ) : null}
- )} + {oauthOtherApps.length > 0 && showOtherApps ? ( + + {oauthOtherApps.map((app: OAuthClientOption) => ( + + ))} + + ) : null} +
+ )} - {oauthApps.length > 0 && ( - 0 && ( + + {oauthApps.map((app: OAuthClientOption) => ( + + ))} + - - )} -
- ) - ) : ( - { - setCredentialOrigin(next); - if (next === "paste") setOnePasswordItemId(""); - }} - onePasswordItemId={onePasswordItemId} - onOnePasswordItemIdChange={setOnePasswordItemId} - /> - )} - {isOAuth && oauthPopup.error ? ( -

{oauthPopup.error}

- ) : null} -
- )} - - )} + + Register a new app + + + )} +
+ ) + ) : ( + { + setCredentialOrigin(next); + if (next === "paste") setOnePasswordItemId(""); + }} + onePasswordItemId={onePasswordItemId} + onOnePasswordItemIdChange={setOnePasswordItemId} + /> + )} + {isOAuth && oauthPopup.error ? ( +

{oauthPopup.error}

+ ) : null} +
+ )} + )}