From 7c1d06237c992c8ecd1e43f280305ae6307899e7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 17 Aug 2026 20:58:15 -0700 Subject: [PATCH] feat(dashboard): use the persistent sign-in dialog in Settings authentication too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings had its own copy of the flow onboarding just replaced: instructions and the paste field rendered inline in the provider row of a scrolling list, with no single place showing what the login was waiting on. Same dialog now serves both, so an operator who learns the flow at first run sees it again when adding a provider later. Settings differs in one way that matters: every flow is keyed by `stateKey` (`providerId`, or `providerId[instance]` for a named credential instance), because one provider can hold several accounts. `loginDialog` therefore carries { stateKey, providerId, instanceId, providerName } and threads instanceId back to handleSubmitManualCode / handleCancelLogin, and the row suppresses its own instructions + paste field ONLY for the key the dialog owns — a sibling account keeps its inline field. (An early draft keyed on `provider:default`, which is not the real format and broke exactly that case; caught by the new tests.) The dialog renders outside renderModalShell: the modal presentation is a FloatingWindow, and a portaled dialog inside a window's React subtree lifts that window above itself on first click. The embedded presentation is unaffected. Verified in a container build against the real Settings UI: dialog opens on Continue to login, the row's inline paste field disappears (0 present), exactly one paste field exists, and the dialog is not a descendant of the window. 756 dashboard tests pass, including 3 new handoff tests; typecheck and eslint clean. Co-Authored-By: Claude Opus 5 --- .changeset/fix-settings-login-dialog.md | 7 ++ .../app/components/SettingsModal.tsx | 102 +++++++++++++++++- .../__tests__/AuthenticationSection.test.tsx | 49 +++++++++ .../sections/AuthenticationSection.tsx | 13 ++- 4 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-settings-login-dialog.md diff --git a/.changeset/fix-settings-login-dialog.md b/.changeset/fix-settings-login-dialog.md new file mode 100644 index 0000000000..2a8d158a81 --- /dev/null +++ b/.changeset/fix-settings-login-dialog.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Settings authentication now uses the same persistent sign-in dialog as first-run onboarding. +category: fix +dev: Wires `ProviderLoginDialog` into SettingsModal/AuthenticationSection for `requiresManualCode` OAuth flows. Settings keys every flow by `stateKey` (`providerId`, or `providerId[instance]` for a named credential instance), so `loginDialog` carries `{ stateKey, providerId, instanceId, providerName }` and the row suppresses its own instructions/paste field only for the key the dialog owns — a sibling account keeps its inline field. Rendered outside `renderModalShell` because the modal presentation is a FloatingWindow and a portaled dialog inside its React subtree lifts the window above itself on first click. diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 437857d3da..f134200116 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -72,6 +72,7 @@ import "./SettingsModal.css"; import { FileBrowser } from "./FileBrowser"; import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser"; import { FloatingWindow } from "./FloatingWindow"; +import { ProviderLoginDialog, type ProviderLoginPhase } from "./ProviderLoginDialog"; import { ProviderIcon } from "./ProviderIcon"; import { generateUniquePresetId } from "../utils/modelPresets"; import { copyTextToClipboard } from "../utils/copyToClipboard"; @@ -1439,6 +1440,18 @@ export function SettingsModal({ const [deviceCodes, setDeviceCodes] = useState>({}); const [manualCodeInputs, setManualCodeInputs] = useState>({}); const [manualCodeSubmitInProgress, setManualCodeSubmitInProgress] = useState(null); + /* + FNXC:ProviderAuth 2026-08-18-06:10: + Settings shares onboarding's persistent paste-back login dialog (ProviderLoginDialog). Everything + here is keyed by `stateKey` (provider + credential instance), not a bare provider id, because + Settings can hold several named accounts for one provider and each runs its own flow. + `loginDialog` carries the identity the dialog's handlers need; `loginAuthUrls` re-opens a lost + sign-in tab; `loginErrors` keeps the terminal reason on screen after the flow dies, since a toast + is gone before the operator is back from the browser tab they were signing in on. + */ + const [loginDialog, setLoginDialog] = useState<{ stateKey: string; providerId: string; instanceId?: string; providerName: string } | null>(null); + const [loginAuthUrls, setLoginAuthUrls] = useState>({}); + const [loginErrors, setLoginErrors] = useState>({}); const [apiKeyInputs, setApiKeyInputs] = useState>({}); const [apiKeyErrors, setApiKeyErrors] = useState>({}); const [opencodeApiKeyRefreshStatus, setOpencodeApiKeyRefreshStatus] = useState { + setLoginAuthUrls((prev) => { + if (!(providerId in prev)) { + return prev; + } + const next = { ...prev }; + delete next[providerId]; + return next; + }); if (providerId in lastAutoCopiedDeviceCodesRef.current) { const next = { ...lastAutoCopiedDeviceCodesRef.current }; delete next[providerId]; @@ -2477,6 +2498,21 @@ export function SettingsModal({ if (!shouldContinue) { return; } + /* + FNXC:ProviderAuth 2026-08-18-06:10: + Hand 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 instead of appearing inline in + a scrolling provider list. + */ + setLoginErrors((prev) => { + if (!(stateKey in prev)) { + return prev; + } + const next = { ...prev }; + delete next[stateKey]; + return next; + }); + setLoginDialog({ stateKey, providerId, instanceId, providerName: provider.name }); } setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true })); @@ -2497,6 +2533,7 @@ export function SettingsModal({ if (deviceCode && providerId === "github-copilot") { setDeviceCodes((prev) => ({ ...prev, [stateKey]: deviceCode })); } + setLoginAuthUrls((prev) => ({ ...prev, [stateKey]: appendTokenQuery(deviceCode?.verificationUri ?? url) })); if (providerId !== "github-copilot" || !deviceCode) { openExternalUrl(appendTokenQuery(deviceCode?.verificationUri ?? url)); } @@ -2513,6 +2550,7 @@ export function SettingsModal({ delete pollIntervalRef.current[stateKey]; } setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; }); + setLoginDialog((current) => (current?.stateKey === stateKey ? null : current)); clearAuthLoginUiState(stateKey); /* FNXC:SettingsCredentialInstance 2026-08-01-17:49: @@ -2534,6 +2572,7 @@ export function SettingsModal({ delete pollIntervalRef.current[stateKey]; } setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; }); + setLoginErrors((prev) => ({ ...prev, [stateKey]: t("settings.auth.loginDidNotComplete", "Login did not complete. Please try again.") })); clearAuthLoginUiState(stateKey); addToast(t("settings.auth.loginDidNotComplete", "Login did not complete. Please try again."), "error"); } @@ -2549,6 +2588,7 @@ export function SettingsModal({ await loadAuthStatus(); } else { addToast(message, "error"); + setLoginErrors((prev) => ({ ...prev, [stateKey]: message })); } setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; }); clearAuthLoginUiState(stateKey); @@ -4404,6 +4444,7 @@ export function SettingsModal({ manualCodeInputs, setManualCodeInputs, manualCodeSubmitInProgress, + activeLoginDialogKey: loginDialog?.stateKey ?? null, loadAuthStatus, handleLogin, handleLogout, @@ -4472,7 +4513,64 @@ export function SettingsModal({ ); - return renderModalShell( + /* + FNXC:ProviderAuth 2026-08-18-06:10: + The login dialog is rendered OUTSIDE renderModalShell, deliberately. In its modal presentation the + shell is a FloatingWindow, and a portaled dialog inside a window's React subtree lifts that window + above itself on first click (a portal moves the DOM node, not the React tree, and the window raises + itself on every pointerdown it sees). As a sibling it is unaffected in both presentations. + */ + const loginDialogElement = loginDialog ? (() => { + const { stateKey, providerId, instanceId, providerName } = loginDialog; + const manualCode = manualCodeConfigs[stateKey]; + const failure = loginErrors[stateKey]; + const authenticated = authProviders.some((entry) => entry.id === providerId + && (entry.instanceId ?? "default") === (instanceId ?? "default") + && entry.authenticated); + const phase: ProviderLoginPhase = authenticated + ? "succeeded" + : failure + ? "failed" + : manualCodeSubmitInProgress === stateKey + ? "submitting" + : "waiting"; + return ( + setManualCodeInputs((prev) => ({ ...prev, [stateKey]: value }))} + onSubmitCode={() => void handleSubmitManualCode(providerId, instanceId)} + onOpenAuthUrl={() => { + const url = loginAuthUrls[stateKey]; + if (url) { + openExternalUrl(url); + } + }} + onCancel={() => { + setLoginDialog(null); + // A still-running flow must be cancelled server-side, or its slot blocks the retry with a 409. + if (authActionInProgress[stateKey]) { + void handleCancelLogin(providerId, instanceId); + } + }} + /> + ); + })() : null; + + return ( + <> + {loginDialogElement} + {renderModalShell( <>
)} + )} + ); } diff --git a/packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx b/packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx index f67aa179e1..4e47abf618 100644 --- a/packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx +++ b/packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx @@ -288,4 +288,53 @@ describe("AuthenticationSection", () => { expect(handleLogin).toHaveBeenCalledWith("anthropic-subscription"); expect(handleSaveApiKey).not.toHaveBeenCalled(); }); + + /* + FNXC:ProviderAuth 2026-08-18-06:10: + Settings shares onboarding's persistent login dialog. While that dialog owns a flow, the provider + row must NOT also render the instructions and paste field — two inputs for the same code, one of + them behind the dialog. Suppression is keyed by `stateKey` (provider + credential instance), so a + second named account for the same provider keeps its own inline field while the first is in the + dialog; that is the case a bare provider-id check would break. + */ + describe("persistent login dialog handoff", () => { + const dialogFlowOverrides = { + loginInstructions: { "anthropic-subscription": "Complete login in your browser." }, + manualCodeConfigs: { "anthropic-subscription": { prompt: "Paste the final redirect URL" } }, + authActionInProgress: { "anthropic-subscription": true }, + } as unknown as Partial; + + const subscriptionProvider = [{ + id: "anthropic-subscription", + name: "Anthropic Subscription", + authenticated: false, + type: "oauth", + } as AuthProvider]; + + it("renders its own paste field when no dialog owns the flow", () => { + renderAuthSection(subscriptionProvider, { ...dialogFlowOverrides, activeLoginDialogKey: null }); + + expect(screen.getByText("Paste the final redirect URL")).toBeInTheDocument(); + expect(screen.getByText("Complete login in your browser.")).toBeInTheDocument(); + }); + + it("yields both to the dialog that owns the flow", () => { + renderAuthSection(subscriptionProvider, { + ...dialogFlowOverrides, + activeLoginDialogKey: "anthropic-subscription", + }); + + expect(screen.queryByText("Paste the final redirect URL")).not.toBeInTheDocument(); + expect(screen.queryByText("Complete login in your browser.")).not.toBeInTheDocument(); + }); + + it("keeps a sibling account's inline field when another instance is in the dialog", () => { + renderAuthSection(subscriptionProvider, { + ...dialogFlowOverrides, + activeLoginDialogKey: "anthropic-subscription[work]", + }); + + expect(screen.getByText("Paste the final redirect URL")).toBeInTheDocument(); + }); + }); }); diff --git a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx index bd5967724b..61ebec8501 100644 --- a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx +++ b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx @@ -42,6 +42,13 @@ export interface AuthenticationSectionData { manualCodeInputs: Record; setManualCodeInputs: Dispatch>>; manualCodeSubmitInProgress: string | null; + /* + FNXC:ProviderAuth 2026-08-18-06:10: + stateKey whose login is showing in the persistent ProviderLoginDialog, or null. That dialog + already renders the instructions and paste field, so the row must not render its own copies — + two inputs for the same code, one of them behind the dialog. + */ + activeLoginDialogKey?: string | null; loadAuthStatus: () => void | Promise; handleLogin: (providerId: string, instanceId?: string, label?: string) => void; handleLogout: (providerId: string, instanceId?: string) => void; @@ -87,7 +94,7 @@ const compareAuthProviderDisplayOrder = (a: AuthProvider, b: AuthProvider) => { }; export function AuthenticationSection({ auth, form, setForm }: AuthenticationSectionProps) { const { t } = useTranslation("app"); - const { projectId, addToast, authProviders, authLoading, authActionInProgress, apiKeyInputs, setApiKeyInputs, apiKeyErrors, opencodeApiKeyRefreshStatus, deviceCodes, loginInstructions, manualCodeConfigs, manualCodeInputs, setManualCodeInputs, manualCodeSubmitInProgress, loadAuthStatus, handleLogin, handleLogout, handleCancelLogin, handleSaveApiKey, handleClearApiKey, handleSubmitManualCode, onReopenOnboarding, } = auth; + const { projectId, addToast, authProviders, authLoading, authActionInProgress, apiKeyInputs, setApiKeyInputs, apiKeyErrors, opencodeApiKeyRefreshStatus, deviceCodes, loginInstructions, manualCodeConfigs, manualCodeInputs, setManualCodeInputs, manualCodeSubmitInProgress, activeLoginDialogKey, loadAuthStatus, handleLogin, handleLogout, handleCancelLogin, handleSaveApiKey, handleClearApiKey, handleSubmitManualCode, onReopenOnboarding, } = auth; const [pendingInstances, setPendingInstances] = useState>({}); const isAuthActionActive = (stateKey: string) => typeof authActionInProgress === "string" ? authActionInProgress === stateKey @@ -320,8 +327,8 @@ export function AuthenticationSection({ auth, form, setForm }: AuthenticationSec
} - {loginInstructions[stateKey] && isActive && } - {manualCodeConfigs[stateKey] && isActive && setManualCodeInputs((prev) => ({ ...prev, [stateKey]: value }))} onSubmit={() => void handleSubmitManualCode(provider.id, instanceId)} prompt={manualCodeConfigs[stateKey].prompt} placeholder={manualCodeConfigs[stateKey].placeholder} helpText={manualCodeConfigs[stateKey].helpText} disabled={manualCodeSubmitInProgress === stateKey} submitLabel={manualCodeSubmitInProgress === stateKey ? "Submitting…" : "Submit code"} data-testid={`auth-manual-code-${stateKey}`}/>} + {loginInstructions[stateKey] && isActive && activeLoginDialogKey !== stateKey && } + {manualCodeConfigs[stateKey] && isActive && activeLoginDialogKey !== stateKey && setManualCodeInputs((prev) => ({ ...prev, [stateKey]: value }))} onSubmit={() => void handleSubmitManualCode(provider.id, instanceId)} prompt={manualCodeConfigs[stateKey].prompt} placeholder={manualCodeConfigs[stateKey].placeholder} helpText={manualCodeConfigs[stateKey].helpText} disabled={manualCodeSubmitInProgress === stateKey} submitLabel={manualCodeSubmitInProgress === stateKey ? "Submitting…" : "Submit code"} data-testid={`auth-manual-code-${stateKey}`}/>} ; }; /*