diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index bc126a9cfb4..ef94a0ba81b 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -315,6 +315,11 @@ const SELECTED_APP_STEP_INDEX: Record, numb key: 1, }; const ZAPIER_STEP_LABELS = ["Access", "Add MCP URL"]; +// Waiting for browser sign-in happens *inside* the flow's last step, so the +// waiting screen reuses the step model of the flow that opened it. It must not +// append a trailing step the flow never lands on: a stepper that grows from two +// dots to three the moment you press Connect reads as a step you missed. +const OAUTH_SIGN_IN_STEP_LABELS = ["Access", "Sign in"]; /** * Which identity a fresh connection should default to (PAP-17835). @@ -1987,6 +1992,9 @@ export function ConnectionSetupFlow({ name: linkName.trim() || endpointHost(linkUrl) || "this server", unverifiedHost: endpointHost(linkUrl), }} + // A pasted endpoint walks the generic three-step wizard, so keep that + // model rather than dropping to the curated two-step one. + steps={{ labels: STEP_LABELS, activeIndex: STEP_INDEX.key }} phase={oauthPhase} error={oauthError} authorizationHost={authorizationHost} @@ -2464,17 +2472,25 @@ function StepHeader({ {step !== "gallery" && ( -
-
+ // A landmark with stable hooks, so the step model can be read without + // guessing at Tailwind classes. The dots are decoration — the label + // line below already says the same thing, so announcing both would + // read every step name twice. +
+ )}
); @@ -2489,6 +2505,7 @@ export function OAuthConnectStateScreen({ recoveryActions, authorizationHost, authorizationUrl, + steps = { labels: OAUTH_SIGN_IN_STEP_LABELS, activeIndex: 1 }, onRetry, onOpenAuthorization, onBack, @@ -2511,6 +2528,12 @@ export function OAuthConnectStateScreen({ authorizationHost?: string | null; /** Already validated by prepareOAuthNavigation; used for a native browser link. */ authorizationUrl?: string | null; + /** + * Step model of the flow that opened this screen, so the stepper keeps the + * shape it had on the previous screen. Defaults to the two-step curated + * sign-in model for hosts that have no wizard of their own. + */ + steps?: { labels: string[]; activeIndex: number }; onOpenAuthorization?: () => void; onRetry: () => void; onBack: () => void; @@ -2549,8 +2572,8 @@ export function OAuthConnectStateScreen({ ('[data-testid="wizard-step-dot"]'), + ); +} + +function stepDotCount(): number { + return stepDots().length; +} + +/** The step names printed under the dots, e.g. "Access · Sign in". */ +function stepLabelsOnScreen(): string[] { + const labelLine = + document.body.querySelector('[data-testid="wizard-step-labels"]')?.textContent ?? ""; + return labelLine.split("·").map((label) => label.trim()).filter(Boolean); +} + /** * Advance past the Access step (PAP-17835), which now sits between picking a * curated app and entering its credential. Picks "Any agent" so Continue is @@ -1837,6 +1855,25 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(connectAppMock).toHaveBeenCalledTimes(1); }); + it("keeps the same steps when Notion sign-in takes over the wizard", async () => { + mockSearch.value = "source=notion"; + listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); + connectAppMock.mockReturnValueOnce(new Promise(() => {})); + + await render(); + + const stepsOnAccess = stepLabelsOnScreen(); + expect(stepsOnAccess).toEqual(["Access", "Sign in"]); + + await passAccessStep(); + await submitCuratedOAuthSetup(); + + expect(container.textContent).toContain("Preparing secure sign-in"); + // Connecting must not grow the stepper a step the flow never lands on. + expect(stepLabelsOnScreen()).toEqual(stepsOnAccess); + expect(stepDotCount()).toBe(stepsOnAccess.length); + }); + it("backs from the sign-in checkpoint to Access without exiting the wizard", async () => { mockSearch.value = "source=notion"; listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] }); @@ -3292,6 +3329,36 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => { expect(container.textContent).toContain("auth.example.test"); }); + // The curated flow has its own regression test for this. The generic flow is + // a separate three-step model with a separate override, so it needs its own — + // otherwise the waiting screen could quietly drop back to the curated + // two-step labels here and nothing would catch it. + it("keeps the generic three-step model when a pasted endpoint hands off to sign-in", async () => { + connectAppMock.mockResolvedValue({ + connectionId: "conn-1", + application: { id: "app-1", name: "mcp.example.test" }, + actions: { readOnly: [], canMakeChanges: [] }, + catalog: [], + suggestedDefaults: {}, + auth: { kind: "oauth", startUrl: "https://auth.example.test/authorize?state=abc" }, + }); + await render(); + await gotoLinkFrame(container, "https://mcp.example.test/mcp"); + + const stepsBeforeHandoff = stepLabelsOnScreen(); + expect(stepsBeforeHandoff).toEqual(["Pick app", "Access", "Add your key"]); + + await act(async () => { + buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + // Signing in must neither grow the stepper nor shrink it to the curated + // two-step set the pasted endpoint never walked. + expect(stepLabelsOnScreen()).toEqual(stepsBeforeHandoff); + expect(stepDotCount()).toBe(stepsBeforeHandoff.length); + }); + it("exchanges a managed connect response in the tenant without opening Cloud confirmation", async () => { const session = "managed_background_session_1234"; const request = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) =>