diff --git a/.changeset/fn-8711-settings-credential-instance-contract.md b/.changeset/fn-8711-settings-credential-instance-contract.md new file mode 100644 index 0000000000..e05dc8d060 --- /dev/null +++ b/.changeset/fn-8711-settings-credential-instance-contract.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep named Authentication credential actions targeted to the selected account. +category: fix +dev: Settings omits default-instance arguments and preserves explicit credential instance ids for OAuth and API-key actions. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 52a296e2c0..87305739ba 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -1894,4 +1894,4 @@ already in progress continues on its existing session. ### Authentication credential instances -Settings → Authentication can hold multiple named credential accounts for each non-CLI provider. Select **Add another account** to create a client-generated account id, optionally label it, and complete OAuth or save an API key; an abandoned pending account is not stored. The first credential becomes the provider default; later accounts do not change it. Operators can rename, remove, or make an existing account default. Labels are display-only, optional, and need not be unique. CLI-backed provider cards retain their own credential handling and do not support Fusion credential instances. +Settings → Authentication can hold multiple named credential accounts for each non-CLI provider. Select **Add another account** to create a client-generated account id, optionally label it, and complete OAuth or save an API key; an abandoned pending account is not stored. The first credential becomes the provider default; later accounts do not change it. Authentication actions without a selected named account target that provider default, while actions on a named account retain its instance id through login, cancellation, logout, save, and clear. Operators can rename, remove, or make an existing account default. Labels are display-only, optional, and need not be unique. CLI-backed provider cards retain their own credential handling and do not support Fusion credential instances. diff --git a/packages/dashboard/app/__tests__/settings-moved-keys.test.ts b/packages/dashboard/app/__tests__/settings-moved-keys.test.ts index 03f8dc4127..7b93c4bbe9 100644 --- a/packages/dashboard/app/__tests__/settings-moved-keys.test.ts +++ b/packages/dashboard/app/__tests__/settings-moved-keys.test.ts @@ -47,6 +47,8 @@ const SURFACE_FILES = [ const PRESET_NESTED_KEYS = new Set([ "validatorProvider", "validatorModelId", + // FNXC:SettingsCredentialInstance 2026-08-01-17:06: Validator instance selection is valid only inside a model preset; the form-read guard remains active for its workflow-owned top-level setting. + "validatorCredentialInstanceId", ]); describe("SettingsModal moved-key removal sweep", () => { diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 6c05240d40..8d32c0a3d6 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -2454,6 +2454,10 @@ export function SettingsModal({ void copyTextToClipboard(copilotDeviceCode.userCode); }, [deviceCodes]); + /* + FNXC:SettingsCredentialInstance 2026-08-01-17:06: + Settings must omit the optional instance argument for a provider's default credential, while every explicit account id follows its OAuth or API-key action unchanged. This preserves the API's default-provider compatibility and prevents UI-local state from conflating default and named account flows. + */ const handleLogin = useCallback(async (providerId: string, instanceId?: string, label?: string) => { const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" }); const provider = authProviders.find((entry) => entry.id === providerId); @@ -2473,7 +2477,11 @@ export function SettingsModal({ clearAuthLoginUiState(stateKey); try { - const { url, instructions, manualCode, deviceCode } = await loginProvider(providerId, instanceId, label); + const { url, instructions, manualCode, deviceCode } = instanceId === undefined + ? await loginProvider(providerId) + : label === undefined + ? await loginProvider(providerId, instanceId) + : await loginProvider(providerId, instanceId, label); if (instructions?.trim() && !(providerId === "github-copilot" && deviceCode)) { setLoginInstructions((prev) => ({ ...prev, [stateKey]: instructions })); } @@ -2491,9 +2499,8 @@ export function SettingsModal({ pollIntervalRef.current[stateKey] = setInterval(async () => { try { const { providers } = await fetchAuthStatus({ provider: providerId, instance: instanceId }); - const visibleProviders = filterVisibleOnboardingAndSettingsProviders(providers); - setAuthProviders(visibleProviders); - const provider = visibleProviders.find((p) => p.id === providerId); + const provider = providers.find((candidate) => candidate.id === providerId + && (candidate.instanceId ?? "default") === (instanceId ?? "default")); if (provider?.authenticated) { if (pollIntervalRef.current[stateKey]) { clearInterval(pollIntervalRef.current[stateKey]); @@ -2501,6 +2508,14 @@ export function SettingsModal({ } setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; }); clearAuthLoginUiState(stateKey); + /* + FNXC:SettingsCredentialInstance 2026-08-01-17:49: + A targeted OAuth poll reports only the requested account's instance rows. Do not + replace Authentication's full provider envelope with that scoped response: sibling + providers and named accounts must remain visible while the login finishes. Refresh + the unscoped status only after this requested instance is terminal. + */ + await loadAuthStatus().catch(() => {}); addToast(t("settings.auth.loginSuccessful", "Login successful"), "success"); window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId } })); scrollSettingsToTop(); @@ -2544,7 +2559,9 @@ export function SettingsModal({ setManualCodeSubmitInProgress(stateKey); try { - const result = await submitProviderManualCode(providerId, code, instanceId); + const result = instanceId === undefined + ? await submitProviderManualCode(providerId, code) + : await submitProviderManualCode(providerId, code, instanceId); if (result.submitted) { setManualCodeInputs((prev) => { if (!(stateKey in prev)) { @@ -2570,7 +2587,11 @@ export function SettingsModal({ setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true })); // Provider status is shared; do not optimistically clear a concurrent instance's login flag. try { - await cancelProviderLogin(providerId, instanceId); + if (instanceId === undefined) { + await cancelProviderLogin(providerId); + } else { + await cancelProviderLogin(providerId, instanceId); + } clearAuthLoginUiState(stateKey); await loadAuthStatus().catch(() => {}); addToast(t("settings.auth.loginCancelled", "Login cancelled"), "success"); @@ -2590,7 +2611,11 @@ export function SettingsModal({ const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" }); setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true })); try { - await logoutProvider(providerId, instanceId); + if (instanceId === undefined) { + await logoutProvider(providerId); + } else { + await logoutProvider(providerId, instanceId); + } await loadAuthStatus(); addToast(t("settings.auth.loggedOut", "Logged out"), "success"); } catch (err) { @@ -2614,7 +2639,9 @@ export function SettingsModal({ return next; }); try { - const saveResult = await saveApiKey(providerId, key, instanceId, label); + const saveResult = instanceId === undefined + ? await saveApiKey(providerId, key) + : await saveApiKey(providerId, key, instanceId, label); setApiKeyInputs((prev) => { const next = { ...prev }; delete next[stateKey]; @@ -2677,7 +2704,11 @@ export function SettingsModal({ const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" }); setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true })); try { - await clearApiKey(providerId, instanceId); + if (instanceId === undefined) { + await clearApiKey(providerId); + } else { + await clearApiKey(providerId, instanceId); + } setApiKeyInputs((prev) => { const next = { ...prev }; delete next[stateKey]; diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx index b8638be225..546ee8dcb7 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx @@ -1254,6 +1254,64 @@ describe("SettingsModal", () => { } }); + it("preserves sibling providers and named accounts while polling a named OAuth login", async () => { + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + mockFetchAuthStatus + .mockResolvedValueOnce({ + providers: [ + { + id: "anthropic-subscription", + name: "Anthropic Subscription", + authenticated: false, + type: "oauth", + instanceId: "work", + instances: [ + { instanceId: "work", label: "Work", isDefault: true, authenticated: false, type: "oauth" }, + { instanceId: "personal", label: "Personal", isDefault: false, authenticated: false, type: "oauth" }, + ], + }, + { id: "github", name: "GitHub", authenticated: false, type: "oauth" }, + ], + }) + .mockResolvedValueOnce({ + providers: [ + { + id: "anthropic-subscription", + name: "Anthropic Subscription", + authenticated: false, + type: "oauth", + instanceId: "work", + loginInProgress: true, + instances: [{ instanceId: "work", label: "Work", isDefault: true, authenticated: false, type: "oauth" }], + }, + ], + }); + mockLoginProvider.mockResolvedValueOnce({ url: "https://claude.ai/oauth/authorize" }); + + renderModal(); + await waitForSettingsModalReady(); + await settingsModalUser.click(screen.getByRole("button", { name: "Authentication" })); + vi.useFakeTimers(); + + try { + const instances = screen.getByTestId("auth-instances-anthropic-subscription"); + fireEvent.click(within(instances).getAllByRole("button", { name: "Login" })[0]); + + await act(async () => { + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(2000); + }); + + expect(mockLoginProvider).toHaveBeenCalledWith("anthropic-subscription", "work"); + expect(mockFetchAuthStatus).toHaveBeenLastCalledWith({ provider: "anthropic-subscription", instance: "work" }); + expect(openSpy).toHaveBeenCalledWith("https://claude.ai/oauth/authorize", "_blank"); + expect(screen.getByText("Personal")).toBeInTheDocument(); + expect(screen.getByTestId("auth-provider-icon-github")).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + it("shows incomplete toast when Anthropic Subscription OAuth stops without authentication", async () => { const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); const addToast = vi.fn(); @@ -1559,6 +1617,26 @@ describe("SettingsModal", () => { expect(mockClearApiKey).toHaveBeenCalledWith("anthropic-api-key"); }); + it("routes named credential instances through their matching auth actions", async () => { + mockFetchAuthStatus.mockResolvedValue({ + providers: [ + { id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: true, type: "oauth", instanceId: "work" }, + { id: "anthropic-api-key", name: "Anthropic API Key", authenticated: true, type: "api_key", instanceId: "billing", keyHint: "sk-•••••work" }, + ], + }); + + render(); + await settingsModalUser.click(await screen.findByRole("button", { name: "Authentication" })); + + const subscriptionCard = screen.getByTestId("auth-provider-icon-anthropic-subscription").closest(".auth-provider-card") as HTMLElement; + const apiKeyCard = screen.getByTestId("auth-provider-icon-anthropic-api-key").closest(".auth-provider-card") as HTMLElement; + await settingsModalUser.click(within(subscriptionCard).getByRole("button", { name: "Logout" })); + await settingsModalUser.click(within(apiKeyCard).getByRole("button", { name: "Clear" })); + + expect(mockLogoutProvider).toHaveBeenCalledWith("anthropic-subscription", "work"); + expect(mockClearApiKey).toHaveBeenCalledWith("anthropic-api-key", "billing"); + }); + it("scrolls settings content to top after API key save succeeds", async () => { mockFetchAuthStatus.mockResolvedValueOnce({ providers: [{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" }], diff --git a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx index 2fbc2ac8b0..bd9a502abc 100644 --- a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx @@ -54,7 +54,11 @@ vi.mock("../../api", () => ({ { id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" }, { id: "anthropic-api-key", name: "Anthropic API Key", authenticated: false, type: "api_key" }, ] })), + // FNXC:SettingsCredentialInstance 2026-08-01-17:06: Mobile Authentication uses the same default-or-named instance key as desktop so responsive rendering cannot collapse provider action state. + formatProviderInstanceKey: ({ providerId, instanceId }: { providerId: string; instanceId: string }) => instanceId === "default" ? providerId : `${providerId}[${instanceId}]`, loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })), + submitProviderManualCode: vi.fn(() => Promise.resolve({ success: true, submitted: true })), + cancelProviderLogin: vi.fn(() => Promise.resolve({ success: true, cancelled: true })), logoutProvider: vi.fn(() => Promise.resolve({ success: true })), saveApiKey: vi.fn(() => Promise.resolve({ success: true })), clearApiKey: vi.fn(() => Promise.resolve({ success: true })), @@ -155,7 +159,7 @@ vi.mock("../../hooks/useMemoryBackendStatus", () => ({ })), })); -import { fetchDashboardHealth, fetchSettings, updateSettings } from "../../api"; +import { fetchDashboardHealth, fetchSettings, loginProvider, saveApiKey, updateSettings } from "../../api"; function setDocumentHidden(hidden: boolean): void { Object.defineProperty(document, "hidden", { configurable: true, value: hidden }); @@ -724,10 +728,12 @@ describe("SettingsModal mobile adaptations", () => { const subscriptionCard = (await findByTestId("auth-provider-icon-anthropic-subscription")).closest(".auth-provider-card") as HTMLElement; const apiKeyCard = (await findByTestId("auth-provider-icon-anthropic-api-key")).closest(".auth-provider-card") as HTMLElement; - expect(within(subscriptionCard).getByRole("button", { name: "Login" })).toBeTruthy(); + await user.click(within(subscriptionCard).getByRole("button", { name: "Login" })); + expect(loginProvider).toHaveBeenCalledWith("anthropic-subscription"); expect(within(subscriptionCard).queryByPlaceholderText("Enter API key")).toBeNull(); - expect(within(apiKeyCard).getByPlaceholderText("Enter API key")).toBeTruthy(); - expect(within(apiKeyCard).getByRole("button", { name: "Save" })).toBeTruthy(); + await user.type(within(apiKeyCard).getByPlaceholderText("Enter API key"), "sk-mobile"); + await user.click(within(apiKeyCard).getByRole("button", { name: "Save" })); + expect(saveApiKey).toHaveBeenCalledWith("anthropic-api-key", "sk-mobile"); }); it("renders notification provider cards responsively on mobile", async () => { diff --git a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx index 0b05d64a5b..d569204f1b 100644 --- a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx +++ b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx @@ -276,7 +276,7 @@ export function AuthenticationSection({ auth, form, setForm }: AuthenticationSec return
setApiKeyInputs((prev) => ({ ...prev, [stateKey]: e.target.value }))} disabled={isAuthActionActive(stateKey)}/> - {provider.keyHint && !isPending && !apiKeyInputs[stateKey] ? : } + {provider.keyHint && !isPending && !apiKeyInputs[stateKey] ? : }
{isAuthActionActive(stateKey) && {t("settings.auth.savingKey", "Saving…")}} {apiKeyErrors[stateKey] && {apiKeyErrors[stateKey]}} @@ -284,11 +284,12 @@ export function AuthenticationSection({ auth, form, setForm }: AuthenticationSec
; }; const renderAuthenticatedOAuthActions = (provider: AuthProvider, selectedInstanceId?: string) => { - const stateKey = formatProviderInstanceKey({ providerId: provider.id, instanceId: selectedInstanceId ?? provider.instanceId ?? "default" }); + const instanceId = selectedInstanceId ?? provider.instanceId; + const stateKey = formatProviderInstanceKey({ providerId: provider.id, instanceId: instanceId ?? "default" }); return
{isAuthActionActive(stateKey) ? - : provider.loginInProgress ?
- : } + : provider.loginInProgress ?
+ : }
; }; const renderAvailableOAuthActions = (provider: AuthProvider, selectedInstanceId?: string, pendingLabel?: string) => { @@ -296,9 +297,9 @@ export function AuthenticationSection({ auth, form, setForm }: AuthenticationSec const stateKey = formatProviderInstanceKey({ providerId: provider.id, instanceId: instanceId ?? "default" }); const isActive = provider.loginInProgress || isAuthActionActive(stateKey); return
- {isAuthActionActive(stateKey) ?
- : provider.loginInProgress ?
- : } + {isAuthActionActive(stateKey) ?
+ : provider.loginInProgress ?
+ : } {provider.id === "github-copilot" && deviceCodes[stateKey] && isActive &&
{t("settings.auth.enterCodeOnGitHub", "Enter this code on GitHub")}
{deviceCodes[stateKey].userCode}
diff --git a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx index 2359367ec9..8d766957b8 100644 --- a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx +++ b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx @@ -365,6 +365,25 @@ const NOT_SURFACED_ALLOWLIST: Record = { validatorFallbackProvider: "moved to workflow settings (U4)", validatorFallbackModelId: "moved to workflow settings (U4)", + /* + FNXC:SettingsCredentialInstance 2026-08-01-17:06: + Credential-instance selectors are inline companions to their provider/model pickers. They inherit the provider default when unset, so each has no standalone Settings description while this narrow inventory keeps the default-description census complete. + */ + defaultCredentialInstanceId: "inline companion for the global default model picker; unset inherits the provider default", + fallbackCredentialInstanceId: "inline companion for the global fallback model picker; unset inherits the provider default", + executionGlobalCredentialInstanceId: "inline companion for the global execution model picker; unset inherits the provider default", + planningGlobalCredentialInstanceId: "inline companion for the global planning model picker; unset inherits the provider default", + validatorGlobalCredentialInstanceId: "inline companion for the global validator model picker; unset inherits the provider default", + titleSummarizerGlobalCredentialInstanceId: "inline companion for the global title-summarizer model picker; unset inherits the provider default", + mergerGlobalCredentialInstanceId: "inline companion for the global merger model picker; unset inherits the provider default", + importTranslateGlobalCredentialInstanceId: "inline companion for the global import-translate model picker; unset inherits the provider default", + defaultCredentialInstanceIdOverride: "inline companion for the project default model picker; unset inherits the provider default", + titleSummarizerCredentialInstanceId: "inline companion for the project title-summarizer model picker; unset inherits the provider default", + titleSummarizerFallbackCredentialInstanceId: "inline companion for the project title-summarizer fallback model picker; unset inherits the provider default", + importTranslateCredentialInstanceId: "inline companion for the project import-translate model picker; unset inherits the provider default", + mergerCredentialInstanceId: "inline companion for the project merger model picker; unset inherits the provider default", + mergerFallbackCredentialInstanceId: "inline companion for the project merger fallback model picker; unset inherits the provider default", + // Internal/engine bookkeeping, session state, or reliability telemetry — not // rendered as a plain user-facing description field anywhere in Settings. globalPause: "engine-managed pause flag, not a plain description field",