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}`}/>} ; }; /*