FN-8711: preserve credential instances in settings actions

Preserve selected credential-instance identity across dashboard authentication actions.

- Route named-account OAuth and API-key actions with their instance IDs.
- Retain sibling credential accounts during targeted OAuth polling.
- Document the default-versus-named credential contract and extend desktop/mobile coverage.

Files changed:
 ...n-8711-settings-credential-instance-contract.md |  7 ++
 docs/settings-reference.md                         |  2 +-
 .../app/__tests__/settings-moved-keys.test.ts      |  2 +
 .../dashboard/app/components/SettingsModal.tsx     | 49 +++++++++++---
 .../__tests__/SettingsModal.models-auth.test.tsx   | 78 ++++++++++++++++++++++
 .../components/__tests__/settings-mobile.test.tsx  | 14 ++--
 .../settings/sections/AuthenticationSection.tsx    | 15 +++--
 .../settings-default-descriptions.test.tsx         | 19 ++++++
 8 files changed, 165 insertions(+), 21 deletions(-)

Fusion-Task-Id: FN-8711

Fusion-Task-Lineage: f43eaa27-4d36-4a3d-acf3-caba54e31857

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-01 12:33:35 -07:00
parent 6e98e16e70
commit 7dc6c4b4ec
8 changed files with 165 additions and 21 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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", () => {

View File

@@ -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];

View File

@@ -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(<SettingsModal onClose={noop} addToast={vi.fn()} />);
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" }],

View File

@@ -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 () => {

View File

@@ -276,7 +276,7 @@ export function AuthenticationSection({ auth, form, setForm }: AuthenticationSec
return <div className="auth-apikey-section">
<div className="auth-apikey-input-row">
<input type="password" className="auth-apikey-input" placeholder={t("settings.authentication.enterAPIKey", "Enter API key")} value={apiKeyInputs[stateKey] ?? ""} onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [stateKey]: e.target.value }))} disabled={isAuthActionActive(stateKey)}/>
{provider.keyHint && !isPending && !apiKeyInputs[stateKey] ? <button className="btn btn-sm" onClick={() => selectedInstanceId ? handleClearApiKey(provider.id, selectedInstanceId) : handleClearApiKey(provider.id)} disabled={isAuthActionActive(stateKey)}>{t("settings.auth.clearKey", "Clear")}</button> : <button className="btn btn-primary btn-sm" onClick={() => selectedInstanceId ? handleSaveApiKey(provider.id, selectedInstanceId, pendingLabel || undefined) : handleSaveApiKey(provider.id)} disabled={isAuthActionActive(stateKey)}>{t("settings.actions.save", "Save")}</button>}
{provider.keyHint && !isPending && !apiKeyInputs[stateKey] ? <button className="btn btn-sm" onClick={() => instanceId ? handleClearApiKey(provider.id, instanceId) : handleClearApiKey(provider.id)} disabled={isAuthActionActive(stateKey)}>{t("settings.auth.clearKey", "Clear")}</button> : <button className="btn btn-primary btn-sm" onClick={() => instanceId ? handleSaveApiKey(provider.id, instanceId, pendingLabel || undefined) : handleSaveApiKey(provider.id)} disabled={isAuthActionActive(stateKey)}>{t("settings.actions.save", "Save")}</button>}
</div>
{isAuthActionActive(stateKey) && <small className="auth-apikey-progress">{t("settings.auth.savingKey", "Saving…")}</small>}
{apiKeyErrors[stateKey] && <small className="auth-apikey-error">{apiKeyErrors[stateKey]}</small>}
@@ -284,11 +284,12 @@ export function AuthenticationSection({ auth, form, setForm }: AuthenticationSec
</div>;
};
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 <div>
{isAuthActionActive(stateKey) ? <button className="btn btn-sm" disabled>{t("settings.auth.loggingOut", "Logging out…")}</button>
: provider.loginInProgress ? <div className="auth-provider-actions-row"><button className="btn btn-sm" disabled>{t("settings.auth.waitingForLogin", "Waiting for login…")}</button><button className="btn btn-sm" onClick={() => selectedInstanceId ? handleCancelLogin(provider.id, selectedInstanceId) : handleCancelLogin(provider.id)}>{t("settings.actions.cancel", "Cancel")}</button></div>
: <button className="btn btn-sm" onClick={() => selectedInstanceId ? handleLogout(provider.id, selectedInstanceId) : handleLogout(provider.id)}>{t("settings.auth.logout", "Logout")}</button>}
: provider.loginInProgress ? <div className="auth-provider-actions-row"><button className="btn btn-sm" disabled>{t("settings.auth.waitingForLogin", "Waiting for login…")}</button><button className="btn btn-sm" onClick={() => instanceId ? handleCancelLogin(provider.id, instanceId) : handleCancelLogin(provider.id)}>{t("settings.actions.cancel", "Cancel")}</button></div>
: <button className="btn btn-sm" onClick={() => instanceId ? handleLogout(provider.id, instanceId) : handleLogout(provider.id)}>{t("settings.auth.logout", "Logout")}</button>}
</div>;
};
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 <div>
{isAuthActionActive(stateKey) ? <div className="auth-provider-actions-row"><button className="btn btn-sm" disabled>{t("settings.auth.waitingForLogin", "Waiting for login…")}</button><button className="btn btn-sm" onClick={() => handleCancelLogin(provider.id, selectedInstanceId)}>{t("settings.actions.cancel", "Cancel")}</button></div>
: provider.loginInProgress ? <div className="auth-provider-actions-row"><button className="btn btn-sm" disabled>{t("settings.auth.waitingForLogin", "Waiting for login…")}</button><button className="btn btn-sm" onClick={() => selectedInstanceId ? handleCancelLogin(provider.id, selectedInstanceId) : handleCancelLogin(provider.id)}>{t("settings.actions.cancel", "Cancel")}</button></div>
: <button className="btn btn-primary btn-sm" onClick={() => selectedInstanceId ? handleLogin(provider.id, selectedInstanceId, pendingLabel) : handleLogin(provider.id)}>{t("settings.auth.login", "Login")}</button>}
{isAuthActionActive(stateKey) ? <div className="auth-provider-actions-row"><button className="btn btn-sm" disabled>{t("settings.auth.waitingForLogin", "Waiting for login…")}</button><button className="btn btn-sm" onClick={() => instanceId ? handleCancelLogin(provider.id, instanceId) : handleCancelLogin(provider.id)}>{t("settings.actions.cancel", "Cancel")}</button></div>
: provider.loginInProgress ? <div className="auth-provider-actions-row"><button className="btn btn-sm" disabled>{t("settings.auth.waitingForLogin", "Waiting for login…")}</button><button className="btn btn-sm" onClick={() => instanceId ? handleCancelLogin(provider.id, instanceId) : handleCancelLogin(provider.id)}>{t("settings.actions.cancel", "Cancel")}</button></div>
: <button className="btn btn-primary btn-sm" onClick={() => instanceId ? handleLogin(provider.id, instanceId, pendingLabel) : handleLogin(provider.id)}>{t("settings.auth.login", "Login")}</button>}
{provider.id === "github-copilot" && deviceCodes[stateKey] && isActive && <div className="auth-device-code-panel" data-testid={`auth-device-code-${stateKey}`}>
<strong>{t("settings.auth.enterCodeOnGitHub", "Enter this code on GitHub")}</strong>
<div className="auth-device-code-pill">{deviceCodes[stateKey].userCode}</div>

View File

@@ -365,6 +365,25 @@ const NOT_SURFACED_ALLOWLIST: Record<string, string> = {
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",