From 83a33be353c3672ddd935d9079610d997136243c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 17 Aug 2026 20:47:28 -0700 Subject: [PATCH] feat(dashboard): persistent sign-in dialog for paste-back provider logins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator report: during a container login there was nowhere obvious to paste the redirect URL and no sign of what the app was waiting on. The flow was split across a pre-flight confirm that warned about paste-back and vanished, a card that shrank to a disabled "Waiting for login…" chip, and the paste field rendered inline in that card below the fold of a scrolling modal. ProviderLoginDialog now opens with the flow and stays until it ends: a two-step progress list, a button to re-open a lost sign-in tab, the paste field, and the terminal outcome inline instead of a toast that disappears while the operator is in another browser tab. Three defects found and fixed while verifying it in a real container: - It sank behind the onboarding modal and clicks landed on the modal instead. createPortal relocates the DOM node but NOT the React tree, so pointer events bubbled to the host FloatingWindow, which raises itself to a fresh nextFloatingZ() on every pointerdown — each click in the dialog lifted the window above it. Fixed by rendering the dialog as a sibling of the window, claiming z once on open (it was calling nextFloatingZ() inline on every render of a modal that re-renders on a 2s poll), and stopping propagation on the overlay. Ratcheted for every portaled .modal-overlay. - Spacing did not match any other dialog: it hand-rolled header/action padding instead of using .modal-header/.modal-actions, and padded each child separately. Every row now shares var(--modal-padding) — verified at a uniform 17px inset across header, steps, paste prompt, field, Submit, and actions. - The paste field was invisible (.form-input fills with var(--surface), and so does .modal — measured #0c0c0e on #0c0c0e), Submit was a 25px row-density btn-sm, and both could scroll out of reach. The field now sinks to var(--bg) with var(--border-strong), Submit takes standard control padding, and the paste region is pinned outside the scroll area. Dialog anatomy rules (spacing primitives, portal/stacking) documented in docs/dashboard-guide.md. Verified: 441 dashboard tests including 4 new dialog tests, eslint, dashboard typecheck, and the rendered dialog measured in a container build. Co-Authored-By: Claude Opus 5 --- .changeset/fix-provider-login-dialog.md | 7 + docs/dashboard-guide.md | 25 +++ .../app/components/ModelOnboardingModal.tsx | 127 ++++++++++- .../app/components/ProviderLoginDialog.css | 203 ++++++++++++++++++ .../app/components/ProviderLoginDialog.tsx | 201 +++++++++++++++++ .../__tests__/FloatingWindow.test.tsx | 30 +++ .../__tests__/ModelOnboardingModal.test.tsx | 104 +++++++++ 7 files changed, 694 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-provider-login-dialog.md create mode 100644 packages/dashboard/app/components/ProviderLoginDialog.css create mode 100644 packages/dashboard/app/components/ProviderLoginDialog.tsx 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();