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:
@@ -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.
|
||||||
@@ -1894,4 +1894,4 @@ already in progress continues on its existing session.
|
|||||||
|
|
||||||
### Authentication credential instances
|
### 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.
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ const SURFACE_FILES = [
|
|||||||
const PRESET_NESTED_KEYS = new Set([
|
const PRESET_NESTED_KEYS = new Set([
|
||||||
"validatorProvider",
|
"validatorProvider",
|
||||||
"validatorModelId",
|
"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", () => {
|
describe("SettingsModal moved-key removal sweep", () => {
|
||||||
|
|||||||
@@ -2454,6 +2454,10 @@ export function SettingsModal({
|
|||||||
void copyTextToClipboard(copilotDeviceCode.userCode);
|
void copyTextToClipboard(copilotDeviceCode.userCode);
|
||||||
}, [deviceCodes]);
|
}, [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 handleLogin = useCallback(async (providerId: string, instanceId?: string, label?: string) => {
|
||||||
const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" });
|
const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" });
|
||||||
const provider = authProviders.find((entry) => entry.id === providerId);
|
const provider = authProviders.find((entry) => entry.id === providerId);
|
||||||
@@ -2473,7 +2477,11 @@ export function SettingsModal({
|
|||||||
clearAuthLoginUiState(stateKey);
|
clearAuthLoginUiState(stateKey);
|
||||||
|
|
||||||
try {
|
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)) {
|
if (instructions?.trim() && !(providerId === "github-copilot" && deviceCode)) {
|
||||||
setLoginInstructions((prev) => ({ ...prev, [stateKey]: instructions }));
|
setLoginInstructions((prev) => ({ ...prev, [stateKey]: instructions }));
|
||||||
}
|
}
|
||||||
@@ -2491,9 +2499,8 @@ export function SettingsModal({
|
|||||||
pollIntervalRef.current[stateKey] = setInterval(async () => {
|
pollIntervalRef.current[stateKey] = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const { providers } = await fetchAuthStatus({ provider: providerId, instance: instanceId });
|
const { providers } = await fetchAuthStatus({ provider: providerId, instance: instanceId });
|
||||||
const visibleProviders = filterVisibleOnboardingAndSettingsProviders(providers);
|
const provider = providers.find((candidate) => candidate.id === providerId
|
||||||
setAuthProviders(visibleProviders);
|
&& (candidate.instanceId ?? "default") === (instanceId ?? "default"));
|
||||||
const provider = visibleProviders.find((p) => p.id === providerId);
|
|
||||||
if (provider?.authenticated) {
|
if (provider?.authenticated) {
|
||||||
if (pollIntervalRef.current[stateKey]) {
|
if (pollIntervalRef.current[stateKey]) {
|
||||||
clearInterval(pollIntervalRef.current[stateKey]);
|
clearInterval(pollIntervalRef.current[stateKey]);
|
||||||
@@ -2501,6 +2508,14 @@ export function SettingsModal({
|
|||||||
}
|
}
|
||||||
setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; });
|
setAuthActionInProgress((prev) => { const next = { ...prev }; delete next[stateKey]; return next; });
|
||||||
clearAuthLoginUiState(stateKey);
|
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");
|
addToast(t("settings.auth.loginSuccessful", "Login successful"), "success");
|
||||||
window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId } }));
|
window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId } }));
|
||||||
scrollSettingsToTop();
|
scrollSettingsToTop();
|
||||||
@@ -2544,7 +2559,9 @@ export function SettingsModal({
|
|||||||
|
|
||||||
setManualCodeSubmitInProgress(stateKey);
|
setManualCodeSubmitInProgress(stateKey);
|
||||||
try {
|
try {
|
||||||
const result = await submitProviderManualCode(providerId, code, instanceId);
|
const result = instanceId === undefined
|
||||||
|
? await submitProviderManualCode(providerId, code)
|
||||||
|
: await submitProviderManualCode(providerId, code, instanceId);
|
||||||
if (result.submitted) {
|
if (result.submitted) {
|
||||||
setManualCodeInputs((prev) => {
|
setManualCodeInputs((prev) => {
|
||||||
if (!(stateKey in prev)) {
|
if (!(stateKey in prev)) {
|
||||||
@@ -2570,7 +2587,11 @@ export function SettingsModal({
|
|||||||
setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true }));
|
setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true }));
|
||||||
// Provider status is shared; do not optimistically clear a concurrent instance's login flag.
|
// Provider status is shared; do not optimistically clear a concurrent instance's login flag.
|
||||||
try {
|
try {
|
||||||
await cancelProviderLogin(providerId, instanceId);
|
if (instanceId === undefined) {
|
||||||
|
await cancelProviderLogin(providerId);
|
||||||
|
} else {
|
||||||
|
await cancelProviderLogin(providerId, instanceId);
|
||||||
|
}
|
||||||
clearAuthLoginUiState(stateKey);
|
clearAuthLoginUiState(stateKey);
|
||||||
await loadAuthStatus().catch(() => {});
|
await loadAuthStatus().catch(() => {});
|
||||||
addToast(t("settings.auth.loginCancelled", "Login cancelled"), "success");
|
addToast(t("settings.auth.loginCancelled", "Login cancelled"), "success");
|
||||||
@@ -2590,7 +2611,11 @@ export function SettingsModal({
|
|||||||
const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" });
|
const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" });
|
||||||
setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true }));
|
setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true }));
|
||||||
try {
|
try {
|
||||||
await logoutProvider(providerId, instanceId);
|
if (instanceId === undefined) {
|
||||||
|
await logoutProvider(providerId);
|
||||||
|
} else {
|
||||||
|
await logoutProvider(providerId, instanceId);
|
||||||
|
}
|
||||||
await loadAuthStatus();
|
await loadAuthStatus();
|
||||||
addToast(t("settings.auth.loggedOut", "Logged out"), "success");
|
addToast(t("settings.auth.loggedOut", "Logged out"), "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -2614,7 +2639,9 @@ export function SettingsModal({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
try {
|
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) => {
|
setApiKeyInputs((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[stateKey];
|
delete next[stateKey];
|
||||||
@@ -2677,7 +2704,11 @@ export function SettingsModal({
|
|||||||
const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" });
|
const stateKey = formatProviderInstanceKey({ providerId, instanceId: instanceId ?? "default" });
|
||||||
setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true }));
|
setAuthActionInProgress((prev) => ({ ...prev, [stateKey]: true }));
|
||||||
try {
|
try {
|
||||||
await clearApiKey(providerId, instanceId);
|
if (instanceId === undefined) {
|
||||||
|
await clearApiKey(providerId);
|
||||||
|
} else {
|
||||||
|
await clearApiKey(providerId, instanceId);
|
||||||
|
}
|
||||||
setApiKeyInputs((prev) => {
|
setApiKeyInputs((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[stateKey];
|
delete next[stateKey];
|
||||||
|
|||||||
@@ -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 () => {
|
it("shows incomplete toast when Anthropic Subscription OAuth stops without authentication", async () => {
|
||||||
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||||
const addToast = vi.fn();
|
const addToast = vi.fn();
|
||||||
@@ -1559,6 +1617,26 @@ describe("SettingsModal", () => {
|
|||||||
expect(mockClearApiKey).toHaveBeenCalledWith("anthropic-api-key");
|
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 () => {
|
it("scrolls settings content to top after API key save succeeds", async () => {
|
||||||
mockFetchAuthStatus.mockResolvedValueOnce({
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
providers: [{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" }],
|
providers: [{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" }],
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ vi.mock("../../api", () => ({
|
|||||||
{ id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" },
|
{ id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" },
|
||||||
{ id: "anthropic-api-key", name: "Anthropic API Key", authenticated: false, type: "api_key" },
|
{ 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" })),
|
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 })),
|
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
|
||||||
saveApiKey: vi.fn(() => Promise.resolve({ success: true })),
|
saveApiKey: vi.fn(() => Promise.resolve({ success: true })),
|
||||||
clearApiKey: 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 {
|
function setDocumentHidden(hidden: boolean): void {
|
||||||
Object.defineProperty(document, "hidden", { configurable: true, value: hidden });
|
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 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;
|
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(subscriptionCard).queryByPlaceholderText("Enter API key")).toBeNull();
|
||||||
expect(within(apiKeyCard).getByPlaceholderText("Enter API key")).toBeTruthy();
|
await user.type(within(apiKeyCard).getByPlaceholderText("Enter API key"), "sk-mobile");
|
||||||
expect(within(apiKeyCard).getByRole("button", { name: "Save" })).toBeTruthy();
|
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 () => {
|
it("renders notification provider cards responsively on mobile", async () => {
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ export function AuthenticationSection({ auth, form, setForm }: AuthenticationSec
|
|||||||
return <div className="auth-apikey-section">
|
return <div className="auth-apikey-section">
|
||||||
<div className="auth-apikey-input-row">
|
<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)}/>
|
<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>
|
</div>
|
||||||
{isAuthActionActive(stateKey) && <small className="auth-apikey-progress">{t("settings.auth.savingKey", "Saving…")}</small>}
|
{isAuthActionActive(stateKey) && <small className="auth-apikey-progress">{t("settings.auth.savingKey", "Saving…")}</small>}
|
||||||
{apiKeyErrors[stateKey] && <small className="auth-apikey-error">{apiKeyErrors[stateKey]}</small>}
|
{apiKeyErrors[stateKey] && <small className="auth-apikey-error">{apiKeyErrors[stateKey]}</small>}
|
||||||
@@ -284,11 +284,12 @@ export function AuthenticationSection({ auth, form, setForm }: AuthenticationSec
|
|||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
const renderAuthenticatedOAuthActions = (provider: AuthProvider, selectedInstanceId?: string) => {
|
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>
|
return <div>
|
||||||
{isAuthActionActive(stateKey) ? <button className="btn btn-sm" disabled>{t("settings.auth.loggingOut", "Logging out…")}</button>
|
{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>
|
: 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={() => selectedInstanceId ? handleLogout(provider.id, selectedInstanceId) : handleLogout(provider.id)}>{t("settings.auth.logout", "Logout")}</button>}
|
: <button className="btn btn-sm" onClick={() => instanceId ? handleLogout(provider.id, instanceId) : handleLogout(provider.id)}>{t("settings.auth.logout", "Logout")}</button>}
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
const renderAvailableOAuthActions = (provider: AuthProvider, selectedInstanceId?: string, pendingLabel?: string) => {
|
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 stateKey = formatProviderInstanceKey({ providerId: provider.id, instanceId: instanceId ?? "default" });
|
||||||
const isActive = provider.loginInProgress || isAuthActionActive(stateKey);
|
const isActive = provider.loginInProgress || isAuthActionActive(stateKey);
|
||||||
return <div>
|
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>
|
{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={() => selectedInstanceId ? handleCancelLogin(provider.id, selectedInstanceId) : 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={() => selectedInstanceId ? handleLogin(provider.id, selectedInstanceId, pendingLabel) : handleLogin(provider.id)}>{t("settings.auth.login", "Login")}</button>}
|
: <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}`}>
|
{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>
|
<strong>{t("settings.auth.enterCodeOnGitHub", "Enter this code on GitHub")}</strong>
|
||||||
<div className="auth-device-code-pill">{deviceCodes[stateKey].userCode}</div>
|
<div className="auth-device-code-pill">{deviceCodes[stateKey].userCode}</div>
|
||||||
|
|||||||
@@ -365,6 +365,25 @@ const NOT_SURFACED_ALLOWLIST: Record<string, string> = {
|
|||||||
validatorFallbackProvider: "moved to workflow settings (U4)",
|
validatorFallbackProvider: "moved to workflow settings (U4)",
|
||||||
validatorFallbackModelId: "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
|
// Internal/engine bookkeeping, session state, or reliability telemetry — not
|
||||||
// rendered as a plain user-facing description field anywhere in Settings.
|
// rendered as a plain user-facing description field anywhere in Settings.
|
||||||
globalPause: "engine-managed pause flag, not a plain description field",
|
globalPause: "engine-managed pause flag, not a plain description field",
|
||||||
|
|||||||
Reference in New Issue
Block a user