feat(dashboard): use the persistent sign-in dialog in Settings authentication too
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 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/fix-settings-login-dialog.md
Normal file
7
.changeset/fix-settings-login-dialog.md
Normal file
@@ -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.
|
||||
@@ -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<Record<string, OAuthDeviceCodeInfo>>({});
|
||||
const [manualCodeInputs, setManualCodeInputs] = useState<Record<string, string>>({});
|
||||
const [manualCodeSubmitInProgress, setManualCodeSubmitInProgress] = useState<string | null>(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<Record<string, string>>({});
|
||||
const [loginErrors, setLoginErrors] = useState<Record<string, string>>({});
|
||||
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
|
||||
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
|
||||
const [opencodeApiKeyRefreshStatus, setOpencodeApiKeyRefreshStatus] = useState<Record<string, {
|
||||
@@ -2407,6 +2420,14 @@ export function SettingsModal({
|
||||
}, []);
|
||||
|
||||
const clearAuthLoginUiState = useCallback((providerId: string) => {
|
||||
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({
|
||||
</FloatingWindow>
|
||||
);
|
||||
|
||||
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 (
|
||||
<ProviderLoginDialog
|
||||
data-testid={`provider-login-dialog-${stateKey}`}
|
||||
providerName={providerName}
|
||||
authUrl={loginAuthUrls[stateKey]}
|
||||
instructions={loginInstructions[stateKey]}
|
||||
phase={phase}
|
||||
errorMessage={failure}
|
||||
manualCode={{
|
||||
prompt: manualCode?.prompt ?? t("settings.auth.pasteRedirectUrl", "Paste the final redirect URL or authorization code"),
|
||||
placeholder: manualCode?.placeholder,
|
||||
helpText: manualCode?.helpText,
|
||||
}}
|
||||
codeValue={manualCodeInputs[stateKey] ?? ""}
|
||||
onCodeChange={(value) => 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(
|
||||
<>
|
||||
<div
|
||||
className={isEmbedded ? "modal modal-lg settings-modal settings-modal--embedded" : "modal modal-lg settings-modal"}
|
||||
@@ -5216,6 +5314,8 @@ export function SettingsModal({
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AuthenticationSectionData>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,13 @@ export interface AuthenticationSectionData {
|
||||
manualCodeInputs: Record<string, string>;
|
||||
setManualCodeInputs: Dispatch<SetStateAction<Record<string, string>>>;
|
||||
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<void>;
|
||||
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<Record<string, { instanceId: string; label: string }>>({});
|
||||
const isAuthActionActive = (stateKey: string) => typeof authActionInProgress === "string"
|
||||
? authActionInProgress === stateKey
|
||||
@@ -320,8 +327,8 @@ export function AuthenticationSection({ auth, form, setForm }: AuthenticationSec
|
||||
<button className="btn btn-sm" onClick={() => openExternalUrl(appendTokenQuery(deviceCodes[stateKey].verificationUri))}>{t("settings.auth.openGitHub", "Open GitHub")}</button>
|
||||
</div>
|
||||
</div>}
|
||||
{loginInstructions[stateKey] && isActive && <LoginInstructions instructions={loginInstructions[stateKey]} data-testid={`auth-login-instructions-${stateKey}`}/>}
|
||||
{manualCodeConfigs[stateKey] && isActive && <OAuthManualCodeForm value={manualCodeInputs[stateKey] ?? ""} onChange={(value) => 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 && <LoginInstructions instructions={loginInstructions[stateKey]} data-testid={`auth-login-instructions-${stateKey}`}/>}
|
||||
{manualCodeConfigs[stateKey] && isActive && activeLoginDialogKey !== stateKey && <OAuthManualCodeForm value={manualCodeInputs[stateKey] ?? ""} onChange={(value) => 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}`}/>}
|
||||
</div>;
|
||||
};
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user