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:
7
.changeset/fix-provider-login-dialog.md
Normal file
7
.changeset/fix-provider-login-dialog.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
203
packages/dashboard/app/components/ProviderLoginDialog.css
Normal file
203
packages/dashboard/app/components/ProviderLoginDialog.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
201
packages/dashboard/app/components/ProviderLoginDialog.tsx
Normal file
201
packages/dashboard/app/components/ProviderLoginDialog.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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" />);
|
||||||
|
|||||||
Reference in New Issue
Block a user