diff --git a/.changeset/fix-provider-login-dialog.md b/.changeset/fix-provider-login-dialog.md new file mode 100644 index 0000000000..e8fee4fa59 --- /dev/null +++ b/.changeset/fix-provider-login-dialog.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Show a persistent sign-in dialog during provider logins, with the paste field and status always visible. +category: fix +dev: New `ProviderLoginDialog` replaces the vanishing pre-flight confirm plus card-inline paste field for `requiresManualCode` OAuth flows. It is rendered as a SIBLING of the onboarding FloatingWindow (a portal moves the DOM node but not the React tree, so events bubbled to the window's raise-to-front handler and lifted it above the dialog), claims `nextFloatingZ()` once on open, and stops pointer propagation — now ratcheted for every portaled `.modal-overlay` in FloatingWindow.test.tsx. Spacing uses the shared `.modal-header`/`.modal-actions` primitives with `var(--modal-padding)` on every row; the paste field sinks to `var(--bg)` because `.form-input` and `.modal` both resolve to `var(--surface)`; the paste region is pinned outside the scroll area so Submit cannot scroll out of reach. Dialog anatomy rules documented in docs/dashboard-guide.md. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c30090343e..3f16595c58 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -2038,6 +2038,31 @@ The dashboard's CSS is split into a global stylesheet (`packages/dashboard/app/s **Rule:** New CSS for a component goes in `app/components/ComponentName.css`, NOT `styles.css`. Only design tokens, primitives (`.btn`, `.card`, `.modal`, `.form-input`), and cross-component `@media` overrides belong in the global file. +### Dialog anatomy: spacing and stacking + +Two rules that a new dialog gets wrong the same way every time. Both were paid for by the Set Up AI +paste-back login dialog (2026-08-18): it shipped with insets that matched nothing else in the app, +and it sank behind the modal that opened it. + +**Spacing comes from the primitives, not from your component.** A dialog panel is +` )} - {(authActionInProgress === provider.id || showRemoteLoginInProgress) && loginInstructions[provider.id] && ( + {/* + FNXC:ProviderAuth 2026-08-18-03:05: + Same rule as the paste field below: the dialog already shows these instructions, and rendering + them here too printed the same paragraph twice — once in the dialog and once in the card + visible around its edges. + */} + {(authActionInProgress === provider.id || showRemoteLoginInProgress) && loginInstructions[provider.id] && loginDialogProvider !== provider.id && ( )} - {(authActionInProgress === provider.id || showRemoteLoginInProgress) && manualCodeConfigs[provider.id] && ( + {/* + FNXC:ProviderAuth 2026-08-18-03:05: + The card keeps its own paste field only for flows the dialog is NOT showing (e.g. a remote + login adopted from another surface). Rendering both would put two inputs for the same code on + screen, one of them hidden behind the dialog. + */} + {(authActionInProgress === provider.id || showRemoteLoginInProgress) && manualCodeConfigs[provider.id] && loginDialogProvider !== provider.id && ( setManualCodeInputs((prev) => ({ ...prev, [provider.id]: value }))} @@ -2449,6 +2514,7 @@ export function ModelOnboardingModal({ }; return ( + <> )} + + + {/* + FNXC:ProviderAuth 2026-08-18-04:20: + RENDERED OUTSIDE THE FLOATINGWINDOW SUBTREE, DELIBERATELY. A portal relocates the DOM node but + not the React tree, and FloatingWindow raises itself to a fresh `nextFloatingZ()` on every + pointerdown/focus that reaches it — so while this lived inside the window's children, every + click in the dialog lifted the window above it and the following click landed on the window + instead. As a sibling, dialog events never reach the window's raise handler. Keep it here. + */} + {loginDialogProvider && (() => { + const dialogProvider = authProviders.find((entry) => entry.id === loginDialogProvider); + const manualCode = manualCodeConfigs[loginDialogProvider]; + const failure = loginErrors[loginDialogProvider]; + const phase: ProviderLoginPhase = dialogProvider?.authenticated + ? "succeeded" + : failure + ? "failed" + : manualCodeSubmitInProgress === loginDialogProvider + ? "submitting" + : "waiting"; + return ( + setManualCodeInputs((prev) => ({ ...prev, [loginDialogProvider]: value }))} + onSubmitCode={() => void handleSubmitManualCode(loginDialogProvider)} + onOpenAuthUrl={() => { + const url = loginAuthUrls[loginDialogProvider]; + if (url) { + openExternalUrl(url); + } + }} + onCancel={() => { + const closing = loginDialogProvider; + setLoginDialogProvider(null); + // A still-running flow must be cancelled server-side, or its slot blocks the retry with a 409. + if (authActionInProgress === closing) { + void handleCancelLogin(closing); + } + }} + /> + ); + })()} + ); } diff --git a/packages/dashboard/app/components/ProviderLoginDialog.css b/packages/dashboard/app/components/ProviderLoginDialog.css new file mode 100644 index 0000000000..d3bd299867 --- /dev/null +++ b/packages/dashboard/app/components/ProviderLoginDialog.css @@ -0,0 +1,203 @@ +/* +FNXC:ProviderAuth 2026-08-18-04:20: +Styling for the persistent paste-back login dialog. + +SPACING COMES FROM THE SHARED PRIMITIVES. The header and action rows use `.modal-header` / +`.modal-actions`, which already carry `var(--modal-padding)`; nothing here re-pads them. The body is +the only region without a primitive, so it defines ONE inset from the same token and every child +inherits it — the first version padded the header, the steps, the instructions, the paste form and +the error line separately, which is how they drifted out of alignment with each other and with the +rest of the app's dialogs. +*/ +.provider-login-dialog { + width: min(calc(var(--space-xl) * 22), calc(100vw - var(--space-2xl))); + display: flex; + flex-direction: column; + /* + The dialog grows with the provider's instructions text, and `.modal`'s 80vh cap alone let the action + row fall off the bottom of a short window — the Cancel control was unreachable. Bound the panel and + scroll the body instead, keeping the header and actions pinned inside the viewport. + */ + max-height: min(80vh, calc(100dvh - var(--space-2xl))); + overflow: hidden; +} + +.provider-login-dialog__body { + display: flex; + flex-direction: column; + gap: var(--space-md); + padding: var(--modal-padding); + padding-top: 0; + overflow-y: auto; + overscroll-behavior: contain; + min-height: 0; +} + +.provider-login-dialog__steps { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.provider-login-dialog__step { + display: flex; + gap: var(--space-sm); + align-items: flex-start; + color: var(--text-muted); +} + +.provider-login-dialog__step-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 2px; + color: var(--border); +} + +/* The active step is the one the operator has to act on, so it carries the accent. */ +.provider-login-dialog__step--active .provider-login-dialog__step-icon { + color: var(--color-primary, var(--in-progress)); +} + +.provider-login-dialog__step--done .provider-login-dialog__step-icon { + color: var(--done, var(--color-success)); +} + +.provider-login-dialog__step--active .provider-login-dialog__step-body strong, +.provider-login-dialog__step--done .provider-login-dialog__step-body strong { + color: var(--text); +} + +.provider-login-dialog__step-body { + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-width: 0; +} + +.provider-login-dialog__step-body strong { + font-weight: 600; + line-height: 1.3; +} + +.provider-login-dialog__step-body small { + color: var(--text-muted); + line-height: 1.45; +} + +.provider-login-dialog__reopen { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: var(--space-xs); + margin-top: var(--space-xs); +} + +.provider-login-dialog__spinner { + animation: provider-login-dialog-spin var(--duration-slow, 1s) linear infinite; +} + +@keyframes provider-login-dialog-spin { + to { + transform: rotate(360deg); + } +} + +/* Nested surfaces span the body inset rather than adding one of their own. */ +.provider-login-dialog__body > .oauth-manual-code, +.provider-login-dialog__body > .login-instructions, +.provider-login-dialog__error { + margin: 0; +} + +/* +FNXC:ProviderAuth 2026-08-18-05:05: +The pinned paste region sits between the scrolling body and the action row, so the field and its +Submit stay on screen no matter how long the provider's instructions are or how short the viewport +is. It carries the same inset as the body and a top divider to separate it from the scroll area. +*/ +.provider-login-dialog__paste { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: var(--space-sm); + /* + `var(--modal-padding)` — the SAME inset as .modal-header, the body, and .modal-actions. Using + --space-lg here instead left the paste box and its Submit button hanging closer to the panel edge + than every other row, which is what read as them being flush against it. + */ + padding: var(--modal-padding); + border-top: 1px solid var(--border); + background: var(--surface); +} + +.provider-login-dialog__paste .oauth-manual-code { + margin-top: 0; +} + +/* +FNXC:ProviderAuth 2026-08-18-05:05: +THE PASTE FIELD MUST READ AS A FIELD. `.form-input` fills with `var(--surface)` and outlines with +`var(--border)` — and a `.modal` panel is ALSO `var(--surface)`, so inside a dialog the input's fill +matches its background exactly and the only thing separating them is a near-black hairline +(measured #0c0c0e on #0c0c0e with a #27272a border). It read as having no border at all, which +matters more here than elsewhere because pasting into this box is the whole point of the dialog. +Sink the field against the panel and strengthen its edge, both from existing tokens. +*/ +.provider-login-dialog .oauth-manual-code__input { + background: var(--bg); + border-color: var(--border-strong); +} + +/* +FNXC:ProviderAuth 2026-08-18-05:05: +`btn-sm` (4px 10px, ~25px tall) is a row-density control; as this dialog's primary commit action it +was too small to read as the thing to press. Give it the standard control padding and make it the +accented action, without touching the shared OAuthManualCodeForm used at row density in the cards. +*/ +.provider-login-dialog .oauth-manual-code__actions { + gap: var(--space-sm); + margin-top: var(--space-xs); +} + +.provider-login-dialog .oauth-manual-code__actions .btn { + padding: var(--space-sm) var(--space-lg); + font-size: 13px; +} + +.provider-login-dialog__error { + line-height: 1.45; +} + +/* +FNXC:ProviderAuth 2026-08-18-04:20: +The overlay dims the page. `.modal-overlay` is deliberately transparent for inline modals, but this +dialog is a hard interruption of an OAuth flow — without a scrim it read as floating debris over the +onboarding modal rather than the thing to act on. +*/ +.provider-login-dialog-overlay { + background: color-mix(in srgb, var(--text) 35%, transparent); +} + +@media (max-width: 768px), (max-height: 480px) { + .provider-login-dialog-overlay { + align-items: center; + padding: max(var(--space-md), env(safe-area-inset-top, 0px)) var(--space-sm) + max(var(--space-md), env(safe-area-inset-bottom, 0px)); + } + + .provider-login-dialog { + width: 100%; + max-height: calc(100dvh - var(--space-2xl)); + } +} + +@media (prefers-reduced-motion: reduce) { + .provider-login-dialog__spinner { + animation: none; + } +} diff --git a/packages/dashboard/app/components/ProviderLoginDialog.tsx b/packages/dashboard/app/components/ProviderLoginDialog.tsx new file mode 100644 index 0000000000..af6e17b5e0 --- /dev/null +++ b/packages/dashboard/app/components/ProviderLoginDialog.tsx @@ -0,0 +1,201 @@ +import { useLayoutEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { CheckCircle2, ExternalLink, Loader2, X } from "lucide-react"; +import { OAuthManualCodeForm } from "./OAuthManualCodeForm"; +import { LoginInstructions } from "./LoginInstructions"; +import { nextFloatingZ } from "./floatingWindowStack"; +import "./ProviderLoginDialog.css"; + +/* +FNXC:ProviderAuth 2026-08-18-03:05: +A PASTE-BACK LOGIN MUST BE VISIBLE FOR ITS WHOLE DURATION, IN ONE PLACE. + +Before this dialog the flow scattered itself across three surfaces: a pre-flight confirm that warned +about paste-back and then vanished, a provider card that changed to a small disabled "Waiting for +login…" chip, and the paste field + instructions rendered inline INSIDE that card — below the fold of +a scrolling modal, so the operator finished signing in, returned to the dashboard, and found no +obvious place to put the redirect URL and no indication of what the app was waiting for. + +So the dialog opens when the login starts and STAYS until the flow ends: it names the step the flow +is on, re-offers the sign-in URL (the popup is easy to lose behind the dashboard), always shows the +paste field, and surfaces the terminal outcome inline instead of as a toast that disappears. + +FNXC:ProviderAuth 2026-08-18-04:20: +LAYOUT CONTRACT — this dialog uses the shared `.modal-header` / `.modal-actions` primitives rather +than hand-rolled padding. The first version set its own header/action padding and drifted from every +other dialog in the app (reported as "doesn't have proper spacing"). Those primitives already carry +`var(--modal-padding)`; only the step list, which has no primitive, defines its own inset, and it +reuses the same token. Do not reintroduce bespoke padding on the header or action row here. +*/ + +export type ProviderLoginPhase = "waiting" | "submitting" | "failed" | "succeeded"; + +export interface ProviderLoginDialogProps { + providerName: string; + /** Auth URL the flow opened, re-offered because the popup is easy to lose or dismiss. */ + authUrl?: string; + instructions?: string; + phase: ProviderLoginPhase; + /** Terminal failure reason, shown inline; never a disappearing toast. */ + errorMessage?: string; + manualCode: { prompt: string; placeholder?: string; helpText?: string }; + codeValue: string; + onCodeChange: (value: string) => void; + onSubmitCode: () => void; + onOpenAuthUrl: () => void; + onCancel: () => void; + "data-testid"?: string; +} + +const STEP_STATE = { + done: "provider-login-dialog__step--done", + active: "provider-login-dialog__step--active", + idle: "", +} as const; + +export function ProviderLoginDialog({ + providerName, + authUrl, + instructions, + phase, + errorMessage, + manualCode, + codeValue, + onCodeChange, + onSubmitCode, + onOpenAuthUrl, + onCancel, + "data-testid": testId, +}: ProviderLoginDialogProps) { + /* + FNXC:ProviderAuth 2026-08-18-04:20: + Claim the top of the shared floating stack ONCE on open, the same way ConfirmDialog does. The first + version called `nextFloatingZ()` inline in the parent's JSX, which re-claimed on every render of a + modal that re-renders on a 2s auth poll — a side effect during render, and a number that changed + underneath the host window instead of settling above it. + */ + const [overlayZ, setOverlayZ] = useState(undefined); + useLayoutEffect(() => { + setOverlayZ(nextFloatingZ()); + }, []); + + if (typeof document === "undefined") { + return null; + } + + const signInState = phase === "waiting" ? STEP_STATE.active : STEP_STATE.done; + const exchangeState = + phase === "submitting" ? STEP_STATE.active : phase === "succeeded" ? STEP_STATE.done : STEP_STATE.idle; + + return createPortal( +
but NOT the React tree, so + events raised in here still bubble to whatever rendered it. This dialog is rendered by a + component hosted in a FloatingWindow, and that window raises itself to a fresh `nextFloatingZ()` + on every pointerdown/focus it sees — so each click INSIDE this dialog lifted the window above + it, and the next click landed on the window instead ("it keeps getting covered by the onboarding + dialog, any click goes to the dialog below"). The parent also renders this outside its + FloatingWindow subtree; this guard keeps the contract if that ever changes. + */ + onPointerDown={(event) => event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onFocus={(event) => event.stopPropagation()} + > +
+
+

Signing in to {providerName}

+ +
+ +
+
    +
  1. + + + Approve the sign-in in your browser + + {phase === "waiting" + ? "A tab should have opened. Finish signing in there — this dialog stays put." + : "Authorization received."} + + {authUrl && phase === "waiting" && ( + + )} + +
  2. + +
  3. + + + Hand the authorization back to Fusion + + {phase === "submitting" + ? "Exchanging the authorization code…" + : phase === "succeeded" + ? "Connected." + : "Usually automatic. If your browser lands on an error page, paste that page's full URL below."} + + +
  4. +
+ + {instructions && } +
+ + {/* + FNXC:ProviderAuth 2026-08-18-05:05: + PINNED, NOT SCROLLED. The paste field and its Submit are the dialog's reason to exist, so they + sit outside the scrolling body: with them inside it, a short viewport (or a provider with long + instructions) pushed Submit below the fold, leaving the operator holding a copied URL and no + visible way to hand it over. Only the steps and instructions scroll. + */} + {phase !== "succeeded" && ( +
+ + {phase === "failed" && errorMessage && ( +

+ {errorMessage} +

+ )} +
+ )} + + {phase === "succeeded" && errorMessage && ( +

+ {errorMessage} +

+ )} + +
+ +
+
+
, + document.body, + ); +} diff --git a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx index 74511b1b48..30be4cf664 100644 --- a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx +++ b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx @@ -1,3 +1,5 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { render, screen, fireEvent } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { loadAllAppCss, loadStylesCss } from "../../test/cssFixture"; @@ -240,6 +242,34 @@ describe("FloatingWindow", () => { expect(cssRuleFor(floatingWindowCss, ".floating-window--chat.floating-window--headerless .floating-window__body")).toContain("overflow: hidden;"); }); + /* + FNXC:FloatingWindow 2026-08-18-04:20: + RATCHET: a portaled `.modal-overlay` must swallow its own pointer events. + + `createPortal` relocates the DOM node but NOT the React tree, so events raised inside a portaled + dialog still bubble to whichever component rendered it. Every FloatingWindow raises itself to a + fresh `nextFloatingZ()` on pointerdown/focus, so a dialog portaled from inside a window's subtree + lifts that window ABOVE itself on first click — after which clicks land on the window behind + (reported on the Set Up AI login dialog as "it keeps getting covered … any click goes to the dialog + below"). Two things prevent it: render the dialog as a SIBLING of the window, and stop propagation + at the overlay. This asserts the second for every such component, since the first is per-caller. + */ + it("keeps every portaled modal overlay from leaking pointer events to its host", () => { + const componentsDir = resolve(__dirname, ".."); + const offenders: string[] = []; + let checked = 0; + + for (const file of readdirSync(componentsDir).filter((name) => name.endsWith(".tsx"))) { + const source = readFileSync(resolve(componentsDir, file), "utf8"); + if (!source.includes("createPortal") || !source.includes("modal-overlay")) continue; + checked++; + if (!source.includes("stopPropagation")) offenders.push(file); + } + + expect(checked, "expected portaled overlay components to scan").toBeGreaterThan(0); + expect(offenders, "portaled overlays must stop pointer propagation to their React-tree host").toEqual([]); + }); + /* FNXC:Onboarding 2026-08-17-23:47: A FloatingWindow paints its own bordered surface, so a hosted child that does not fill it leaves diff --git a/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx b/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx index f11b48a920..27e526bd98 100644 --- a/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ModelOnboardingModal.test.tsx @@ -618,6 +618,110 @@ describe("ModelOnboardingModal", () => { }); }); + /* + FNXC:ProviderAuth 2026-08-18-03:05: + A paste-back login must stay visible for its whole duration. Previously the pre-flight confirm + warned about paste-back and vanished, the card shrank to a disabled "Waiting for login…" chip, and + the paste field rendered inline below the fold of a scrolling modal — so an operator returning from + the browser had nowhere obvious to paste and no sign of what was being waited on. + */ + describe("AI Setup step — persistent paste-back login dialog", () => { + async function startManualCodeLogin() { + // requiresManualCode is what routes a provider through the confirm + persistent dialog path. + mockFetchAuthStatus.mockImplementation(() => Promise.resolve({ + providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", requiresManualCode: true }], + })); + mockLoginProvider.mockResolvedValue({ + url: "https://claude.ai/oauth/authorize?state=abc", + instructions: "Complete login in your browser.", + manualCode: { prompt: "Paste the final redirect URL", placeholder: "http://localhost:*/callback?code=…" }, + }); + mockConfirm.mockResolvedValue(true); + + render(); + await waitFor(() => expect(screen.getByTestId("onboarding-provider-card-anthropic")).toBeTruthy()); + + const card = screen.getByTestId("onboarding-provider-card-anthropic"); + const login = [...card.querySelectorAll("button")].find((b) => /^login$/i.test(b.textContent ?? "")); + fireEvent.click(login!); + return card; + } + + it("keeps a dialog with the paste field open once the login starts", async () => { + await startManualCodeLogin(); + + const dialog = await screen.findByTestId("provider-login-dialog-anthropic"); + expect(dialog).toBeTruthy(); + // The paste target is in the dialog, not only inline in the card behind it. + expect(within(dialog).getByTestId("provider-login-dialog-manual-code")).toBeTruthy(); + // And the flow's current step is stated rather than implied by a disabled chip. + expect(dialog.textContent).toMatch(/Approve the sign-in in your browser/); + expect(dialog.textContent).toMatch(/Hand the authorization back to Fusion/); + // A lost sign-in tab is recoverable without restarting the flow. + expect(within(dialog).getByRole("button", { name: /Open the sign-in page again/i })).toBeTruthy(); + }); + + it("shows only one paste field for the flow", async () => { + await startManualCodeLogin(); + await screen.findByTestId("provider-login-dialog-anthropic"); + + expect(screen.queryByTestId("onboarding-manual-code-anthropic")).toBeNull(); + expect(screen.getAllByTestId("provider-login-dialog-manual-code")).toHaveLength(1); + // Instructions likewise appear once — the card's copy is suppressed while the dialog shows them. + expect(screen.queryByTestId("onboarding-login-instructions-anthropic")).toBeNull(); + }); + + /* + FNXC:ProviderAuth 2026-08-18-04:20: + A portal moves the DOM node but NOT the React tree. While the dialog was rendered inside the + host FloatingWindow's children, every pointerdown in the dialog bubbled (through the React tree) + to the window's raise-to-front handler, which claimed a fresh nextFloatingZ() and painted the + window OVER the dialog — so the next click hit the window instead ("any click goes to the dialog + below"). The dialog must therefore be a SIBLING of the window, and must swallow pointer events. + */ + it("does not let its pointer events reach the host floating window", async () => { + await startManualCodeLogin(); + const dialog = await screen.findByTestId("provider-login-dialog-anthropic"); + + const hostWindow = document.querySelector(".floating-window"); + expect(hostWindow, "onboarding still renders inside a FloatingWindow").toBeTruthy(); + // Sibling, not descendant: containment is what allowed React-tree bubbling to the window. + expect(hostWindow!.contains(dialog)).toBe(false); + + const windowPointerDown = vi.fn(); + hostWindow!.addEventListener("pointerdown", windowPointerDown); + fireEvent.pointerDown(within(dialog).getByTestId("provider-login-dialog-manual-code")); + expect(windowPointerDown).not.toHaveBeenCalled(); + }); + + it("uses the shared modal spacing primitives instead of bespoke padding", async () => { + await startManualCodeLogin(); + const dialog = await screen.findByTestId("provider-login-dialog-anthropic"); + + // .modal-header / .modal-actions already carry var(--modal-padding); hand-rolled padding drifts. + expect(dialog.querySelector(".provider-login-dialog > .modal-header")).toBeTruthy(); + expect(dialog.querySelector(".provider-login-dialog > .modal-actions")).toBeTruthy(); + }); + + it("keeps the dialog open on failure so the reason can be read", async () => { + mockConfirm.mockResolvedValue(true); + mockFetchAuthStatus.mockImplementation(() => Promise.resolve({ + providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", requiresManualCode: true }], + })); + mockLoginProvider.mockRejectedValue(new Error("Login initiation timed out")); + + render(); + await waitFor(() => expect(screen.getByTestId("onboarding-provider-card-anthropic")).toBeTruthy()); + const card = screen.getByTestId("onboarding-provider-card-anthropic"); + fireEvent.click([...card.querySelectorAll("button")].find((b) => /^login$/i.test(b.textContent ?? ""))!); + + const dialog = await screen.findByTestId("provider-login-dialog-anthropic"); + await waitFor(() => { + expect(within(dialog).getByTestId("provider-login-dialog-error").textContent).toMatch(/Login initiation timed out/); + }); + }); + }); + describe("AI Setup step", () => { it("shows OAuth providers with Login button", async () => { render();