feat(dashboard): persistent sign-in dialog for paste-back provider logins

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 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-17 20:47:28 -07:00
parent bb11e493f7
commit 83a33be353
7 changed files with 694 additions and 3 deletions

View File

@@ -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.

View File

@@ -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. **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
`<div class="modal your-dialog">` with a `.modal-header` and a `.modal-actions` row; both already
carry `var(--modal-padding)`. Give the middle region ONE inset from the same token and let its
children sit flush inside it. Do not hand-roll header/action padding for a new dialog, and do not
pad each child (steps, form, error line) separately — that is exactly how the login dialog's rows
drifted out of alignment with each other and with every other dialog. Variant-specific overrides of
the primitives (embedded, tablet, phone sheet) are legitimate and several exist; a brand-new dialog
inventing its own base spacing is not.
**A portaled dialog must be a sibling of any FloatingWindow it opens over, and must stop pointer
propagation.** `createPortal` moves the DOM node to `<body>` but NOT the React tree, so events raised
inside the dialog still bubble to whatever 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 the first click — after which every click lands on the
window behind. Render such a dialog outside the `<FloatingWindow>` element (a sibling in the same
fragment), claim `nextFloatingZ()` once on open like `ConfirmDialog` does, and `stopPropagation()`
on the overlay's pointer/mouse/focus handlers. The last part is enforced for every portaled
`.modal-overlay` by a ratchet in `packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx`.
### Browser-safe core imports ### Browser-safe core imports
Dashboard browser code may value-import only browser-safe `@fusion/core` leaf modules. Vite aliases the package root to `packages/core/src/types.ts`; do not bypass that boundary with a relative `core/src` import unless the leaf is listed in [`scripts/lib/dashboard-browser-safe-core-modules.json`](../scripts/lib/dashboard-browser-safe-core-modules.json). In particular, use `near-duplicate-canonical.ts`, not `near-duplicate.ts`, because the latter reaches Node-only duplicate detection dependencies. Dashboard browser code may value-import only browser-safe `@fusion/core` leaf modules. Vite aliases the package root to `packages/core/src/types.ts`; do not bypass that boundary with a relative `core/src` import unless the leaf is listed in [`scripts/lib/dashboard-browser-safe-core-modules.json`](../scripts/lib/dashboard-browser-safe-core-modules.json). In particular, use `near-duplicate-canonical.ts`, not `near-duplicate.ts`, because the latter reaches Node-only duplicate detection dependencies.

View File

@@ -30,6 +30,7 @@ import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
import { CursorCliProviderCard } from "./CursorCliProviderCard"; import { CursorCliProviderCard } from "./CursorCliProviderCard";
import { LlamaCppProviderCard } from "./LlamaCppProviderCard"; import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
import { LoginInstructions } from "./LoginInstructions"; import { LoginInstructions } from "./LoginInstructions";
import { ProviderLoginDialog, type ProviderLoginPhase } from "./ProviderLoginDialog";
import { OAuthManualCodeForm } from "./OAuthManualCodeForm"; import { OAuthManualCodeForm } from "./OAuthManualCodeForm";
import { OnboardingDisclosure } from "./OnboardingDisclosure"; import { OnboardingDisclosure } from "./OnboardingDisclosure";
import { CustomProviderForm } from "./CustomProviderForm"; import { CustomProviderForm } from "./CustomProviderForm";
@@ -812,6 +813,17 @@ export function ModelOnboardingModal({
const [deviceCodes, setDeviceCodes] = useState<Record<string, OAuthDeviceCodeInfo>>({}); const [deviceCodes, setDeviceCodes] = useState<Record<string, OAuthDeviceCodeInfo>>({});
const [manualCodeInputs, setManualCodeInputs] = useState<Record<string, string>>({}); const [manualCodeInputs, setManualCodeInputs] = useState<Record<string, string>>({});
const [manualCodeSubmitInProgress, setManualCodeSubmitInProgress] = useState<string | null>(null); const [manualCodeSubmitInProgress, setManualCodeSubmitInProgress] = useState<string | null>(null);
/* FNXC:ProviderAuth 2026-08-18-03:05: auth URL per in-flight login, so the dialog can re-open a lost sign-in tab. */
const [loginAuthUrls, setLoginAuthUrls] = useState<Record<string, string>>({});
/*
FNXC:ProviderAuth 2026-08-18-03:05:
Which provider's paste-back login dialog is open, and its terminal error if it failed. Visibility
is explicit state rather than derived from the in-flight flags, because the dialog must OUTLIVE the
flow on failure: the operator needs to read why it failed, and a toast is gone before they are back
from the browser tab they were signing in on.
*/
const [loginDialogProvider, setLoginDialogProvider] = useState<string | null>(null);
const [loginErrors, setLoginErrors] = useState<Record<string, string>>({});
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]); const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState<string>(""); const [selectedModel, setSelectedModel] = useState<string>("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -1378,6 +1390,21 @@ export function ModelOnboardingModal({
if (!shouldContinue) { if (!shouldContinue) {
return; return;
} }
/*
FNXC:ProviderAuth 2026-08-18-03:05:
Hand the operator straight from the warning into the persistent dialog, so the paste field and
the flow's current step are on screen from the moment the browser tab opens — not buried in the
provider card below the fold of a scrolling modal.
*/
setLoginErrors((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
setLoginDialogProvider(providerId);
} }
// Clear any previous terminal outcome before starting a new login attempt // Clear any previous terminal outcome before starting a new login attempt
@@ -1396,6 +1423,20 @@ export function ModelOnboardingModal({
delete next[providerId]; delete next[providerId];
lastAutoCopiedDeviceCodesRef.current = next; lastAutoCopiedDeviceCodesRef.current = next;
} }
/*
FNXC:ProviderAuth 2026-08-18-03:05:
The dialog's own state dies with the flow it belongs to. Clearing the auth URL is what
dismisses the dialog, so it must be cleared everywhere this teardown runs (success, cancel,
timeout, failure) or a finished login would leave a stuck modal over the dashboard.
*/
setLoginAuthUrls((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
setLoginInstructions((prev) => { setLoginInstructions((prev) => {
if (!(providerId in prev)) { if (!(providerId in prev)) {
return prev; return prev;
@@ -1448,6 +1489,13 @@ export function ModelOnboardingModal({
if (deviceCode && providerId === "github-copilot") { if (deviceCode && providerId === "github-copilot") {
setDeviceCodes((prev) => ({ ...prev, [providerId]: deviceCode })); setDeviceCodes((prev) => ({ ...prev, [providerId]: deviceCode }));
} }
/*
FNXC:ProviderAuth 2026-08-18-03:05:
Retain the auth URL so ProviderLoginDialog can re-open it. The sign-in tab is easy to lose
behind the dashboard or dismiss by accident, and without the URL the only recovery was to
cancel and restart the whole flow.
*/
setLoginAuthUrls((prev) => ({ ...prev, [providerId]: appendTokenQuery(deviceCode?.verificationUri ?? url) }));
if (providerId !== "github-copilot" || !deviceCode) { if (providerId !== "github-copilot" || !deviceCode) {
openExternalUrl(appendTokenQuery(deviceCode?.verificationUri ?? url)); openExternalUrl(appendTokenQuery(deviceCode?.verificationUri ?? url));
} }
@@ -1467,6 +1515,7 @@ export function ModelOnboardingModal({
provider.id === providerId ? { ...provider, loginInProgress: false } : provider, provider.id === providerId ? { ...provider, loginInProgress: false } : provider,
)); ));
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "timeout" })); setLoginOutcomes((prev) => ({ ...prev, [providerId]: "timeout" }));
setLoginErrors((prev) => ({ ...prev, [providerId]: t("setup.loginTimedOut", "Login timed out. Please try again.") }));
clearAuthLoginUiState(); clearAuthLoginUiState();
addToast(t("setup.loginTimedOut", "Login timed out. Please try again."), "warning"); addToast(t("setup.loginTimedOut", "Login timed out. Please try again."), "warning");
return; return;
@@ -1486,6 +1535,7 @@ export function ModelOnboardingModal({
} }
setAuthActionInProgress(null); setAuthActionInProgress(null);
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "success" })); setLoginOutcomes((prev) => ({ ...prev, [providerId]: "success" }));
setLoginDialogProvider((current) => (current === providerId ? null : current));
clearAuthLoginUiState(); clearAuthLoginUiState();
if (providerId === "github") { if (providerId === "github") {
setGitHubSkippedState(false); setGitHubSkippedState(false);
@@ -1502,6 +1552,7 @@ export function ModelOnboardingModal({
} }
setAuthActionInProgress(null); setAuthActionInProgress(null);
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "failed" })); setLoginOutcomes((prev) => ({ ...prev, [providerId]: "failed" }));
setLoginErrors((prev) => ({ ...prev, [providerId]: t("setup.loginDidNotComplete", "Login did not complete. Please try again.") }));
clearAuthLoginUiState(); clearAuthLoginUiState();
addToast(t("setup.loginDidNotComplete", "Login did not complete. Please try again."), "error"); addToast(t("setup.loginDidNotComplete", "Login did not complete. Please try again."), "error");
} }
@@ -1520,7 +1571,9 @@ export function ModelOnboardingModal({
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "pending" })); setLoginOutcomes((prev) => ({ ...prev, [providerId]: "pending" }));
void loadAuthStatus(); void loadAuthStatus();
} else { } else {
addToast(err instanceof Error ? err.message : t("setup.loginFailed", "Login failed"), "error"); const failureText = err instanceof Error ? err.message : t("setup.loginFailed", "Login failed");
addToast(failureText, "error");
setLoginErrors((prev) => ({ ...prev, [providerId]: failureText }));
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "failed" })); setLoginOutcomes((prev) => ({ ...prev, [providerId]: "failed" }));
} }
setAuthActionInProgress(null); setAuthActionInProgress(null);
@@ -2415,13 +2468,25 @@ export function ModelOnboardingModal({
</div> </div>
</div> </div>
)} )}
{(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 && (
<LoginInstructions <LoginInstructions
instructions={loginInstructions[provider.id]} instructions={loginInstructions[provider.id]}
data-testid={`onboarding-login-instructions-${provider.id}`} data-testid={`onboarding-login-instructions-${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 && (
<OAuthManualCodeForm <OAuthManualCodeForm
value={manualCodeInputs[provider.id] ?? ""} value={manualCodeInputs[provider.id] ?? ""}
onChange={(value) => setManualCodeInputs((prev) => ({ ...prev, [provider.id]: value }))} onChange={(value) => setManualCodeInputs((prev) => ({ ...prev, [provider.id]: value }))}
@@ -2449,6 +2514,7 @@ export function ModelOnboardingModal({
}; };
return ( return (
<>
<FloatingWindow <FloatingWindow
windowKey="model-onboarding" windowKey="model-onboarding"
title={t("setup.titleAiSetup", "Set Up AI")} title={t("setup.titleAiSetup", "Set Up AI")}
@@ -3611,6 +3677,61 @@ export function ModelOnboardingModal({
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
)} )}
</FloatingWindow> </FloatingWindow>
{/*
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 (
<ProviderLoginDialog
data-testid={`provider-login-dialog-${loginDialogProvider}`}
providerName={dialogProvider?.name ?? loginDialogProvider}
authUrl={loginAuthUrls[loginDialogProvider]}
instructions={loginInstructions[loginDialogProvider]}
phase={phase}
errorMessage={failure}
manualCode={{
prompt: manualCode?.prompt ?? t("setup.pasteRedirectUrl", "Paste the final redirect URL or authorization code"),
placeholder: manualCode?.placeholder,
helpText: manualCode?.helpText,
}}
codeValue={manualCodeInputs[loginDialogProvider] ?? ""}
onCodeChange={(value) => 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);
}
}}
/>
);
})()}
</>
); );
} }

View File

@@ -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;
}
}

View File

@@ -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<number | undefined>(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(
<div
className="modal-overlay open provider-login-dialog-overlay"
style={overlayZ === undefined ? undefined : { zIndex: overlayZ }}
data-testid={testId}
/*
FNXC:ProviderAuth 2026-08-18-04:20:
STOP REACT-TREE PROPAGATION. A portal moves the DOM node to <body> 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()}
>
<div className="modal provider-login-dialog" role="dialog" aria-modal="true" aria-label={`Signing in to ${providerName}`}>
<div className="modal-header">
<h3>Signing in to {providerName}</h3>
<button className="modal-close" onClick={onCancel} aria-label="Cancel login" title="Cancel login">
<X size={18} />
</button>
</div>
<div className="provider-login-dialog__body">
<ol className="provider-login-dialog__steps">
<li className={`provider-login-dialog__step ${signInState}`}>
<span className="provider-login-dialog__step-icon" aria-hidden="true">
{phase === "waiting" ? <Loader2 size={16} className="provider-login-dialog__spinner" /> : <CheckCircle2 size={16} />}
</span>
<span className="provider-login-dialog__step-body">
<strong>Approve the sign-in in your browser</strong>
<small>
{phase === "waiting"
? "A tab should have opened. Finish signing in there — this dialog stays put."
: "Authorization received."}
</small>
{authUrl && phase === "waiting" && (
<button className="btn btn-sm provider-login-dialog__reopen" onClick={onOpenAuthUrl}>
<ExternalLink size={14} /> Open the sign-in page again
</button>
)}
</span>
</li>
<li className={`provider-login-dialog__step ${exchangeState}`}>
<span className="provider-login-dialog__step-icon" aria-hidden="true">
{phase === "submitting" ? <Loader2 size={16} className="provider-login-dialog__spinner" /> : <CheckCircle2 size={16} />}
</span>
<span className="provider-login-dialog__step-body">
<strong>Hand the authorization back to Fusion</strong>
<small>
{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."}
</small>
</span>
</li>
</ol>
{instructions && <LoginInstructions instructions={instructions} data-testid="provider-login-dialog-instructions" />}
</div>
{/*
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" && (
<div className="provider-login-dialog__paste">
<OAuthManualCodeForm
value={codeValue}
onChange={onCodeChange}
onSubmit={onSubmitCode}
prompt={manualCode.prompt}
placeholder={manualCode.placeholder}
helpText={manualCode.helpText}
disabled={phase === "submitting"}
submitLabel={phase === "submitting" ? "Submitting…" : "Submit code"}
data-testid="provider-login-dialog-manual-code"
/>
{phase === "failed" && errorMessage && (
<p className="field-error provider-login-dialog__error" data-testid="provider-login-dialog-error">
{errorMessage}
</p>
)}
</div>
)}
{phase === "succeeded" && errorMessage && (
<p className="field-error provider-login-dialog__error" data-testid="provider-login-dialog-error">
{errorMessage}
</p>
)}
<div className="modal-actions">
<button className="btn btn-sm" onClick={onCancel}>
{phase === "succeeded" ? "Close" : "Cancel login"}
</button>
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -1,3 +1,5 @@
import { readdirSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loadAllAppCss, loadStylesCss } from "../../test/cssFixture"; 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;"); 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: FNXC:Onboarding 2026-08-17-23:47:
A FloatingWindow paints its own bordered surface, so a hosted child that does not fill it leaves A FloatingWindow paints its own bordered surface, so a hosted child that does not fill it leaves

View File

@@ -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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
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(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
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", () => { describe("AI Setup step", () => {
it("shows OAuth providers with Login button", async () => { it("shows OAuth providers with Login button", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />); render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);