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(