FN-7206: add Anthropic API-key authentication

Add Anthropic API-key authentication alongside the existing OAuth flow.

- Expose Anthropic as a built-in API-key provider without hiding its OAuth controls.
- Render dual-auth Anthropic cards in Settings and model onboarding with key hints, save, and clear actions.
- Cover API status, CLI provider classification, desktop/mobile settings, and onboarding flows with tests and docs.

Files changed:
 .changeset/fn-7206-anthropic-api-key.md            |   7 +
 docs/dashboard-guide.md                            |   2 +
 docs/settings-reference.md                         |   4 +
 .../src/commands/__tests__/provider-auth.test.ts   |  51 ++++---
 packages/cli/src/commands/provider-auth.ts         |  10 +-
 packages/dashboard/app/api/legacy.ts               |   2 +
 .../app/components/ModelOnboardingModal.tsx        | 121 +++++++++++++++-
 .../__tests__/AuthenticationSection.test.tsx       | 141 +++++++++++++++++++
 .../__tests__/SettingsModal.models-auth.test.tsx   |  51 +++++++
 .../__tests__/SettingsModal.test-harness.tsx       |   2 +
 .../components/__tests__/onboarding-flow.test.tsx  |  28 ++++
 .../components/__tests__/settings-mobile.test.tsx  |  16 ++-
 .../settings/sections/AuthenticationSection.tsx    | 153 ++++++++++-----------
 .../dashboard/src/__tests__/routes-auth.test.ts    |  70 ++++++++++
 .../dashboard/src/routes/register-auth-routes.ts   |  17 ++-
 15 files changed, 569 insertions(+), 106 deletions(-)

Fusion-Task-Id: FN-7206

Fusion-Task-Lineage: 3559b7ea-47c6-4f8c-9aa1-5318f47ce07e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-28 17:03:11 -07:00
parent bc38f0c842
commit 013d50fe8b
15 changed files with 570 additions and 107 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add Anthropic API-key authentication under Authentication.
category: feature
dev: Adds Anthropic built-in API-key provider auth and surfaces Anthropic dual OAuth/API-key cards in onboarding and Settings.

View File

@@ -562,6 +562,8 @@ For Claude/Anthropic OAuth credentials, the same `/auth/status` poll also attemp
If the OAuth credential has no refresh token, the refresh request fails, or the provider is not Anthropic, the provider stays expired and the banner remains visible. Re-authenticate with manual re-login from **Settings → Authentication** or Model Onboarding.
Anthropic also supports a raw `ANTHROPIC_API_KEY` from the same provider card in **Settings → Authentication** and Model Onboarding, so operators can use the API-key row without removing the OAuth sign-in path. The dashboard only displays masked key hints after a key is saved.
## Smart Pull
Smart Pull is a one-shot pull workflow that keeps local work safe while advancing your checked-out integration branch.

View File

@@ -708,6 +708,10 @@ Fusion automatically refreshes Claude/Anthropic OAuth credentials before reporti
Manual re-login is still required when no refresh token is stored, the refresh request fails, or the expired OAuth credential belongs to a non-Anthropic provider. In those cases the credential remains expired, `oauth-token-expired` notifications/startup warnings may fire subject to their 12-hour provider throttle, and users should re-authenticate from **Settings → Authentication** or Model Onboarding.
### Anthropic API-key authentication
Anthropic can be connected with a raw API key from both Model Onboarding and **Settings → Authentication**. Anthropic appears as a dual-auth provider: the same Anthropic card keeps the OAuth Login/Logout controls and also shows an API-key row for `ANTHROPIC_API_KEY`, with only masked key hints returned by `/api/auth/status`.
### Authentication troubleshooting (mobile OAuth fallback)
#### `/api/auth/login` response shape for device-code providers

View File

@@ -122,15 +122,18 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(providerIds).toContain("opencode-go");
});
it("filters opencode-go from API key providers when OAuth provider id collides", () => {
it("keeps built-in API key providers when OAuth provider ids collide", () => {
const fusionAuth = makeAuthStorage();
fusionAuth.getOAuthProviders = vi.fn(() => [{ id: "opencode-go", name: "Opencode Go OAuth" }]);
fusionAuth.getOAuthProviders = vi.fn(() => [
{ id: "anthropic", name: "Anthropic OAuth" },
{ id: "opencode-go", name: "Opencode Go OAuth" },
]);
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
const providerIds = wrapped.getApiKeyProviders().map((provider) => provider.id);
expect(providerIds).not.toContain("opencode-go");
expect(providerIds).toEqual(expect.arrayContaining(["anthropic", "opencode-go"]));
});
it("reads legacy auth JSON without creating missing files", async () => {
@@ -186,7 +189,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(oauthIds).toContain("github-copilot");
});
it("does not duplicate anthropic in getApiKeyProviders when OAuth-backed", () => {
it("includes anthropic in getApiKeyProviders when OAuth-backed", () => {
const fusionAuth = makeAuthStorage();
fusionAuth.getOAuthProviders = vi.fn(() => [
{ id: "anthropic", name: "Anthropic" },
@@ -196,11 +199,28 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
const apiKeyProviders = wrapped.getApiKeyProviders();
const anthropic = apiKeyProviders.find((p) => p.id === "anthropic");
expect(anthropic).toBeUndefined();
expect(apiKeyProviders).toContainEqual({ id: "anthropic", name: "Anthropic" });
});
it("stores anthropic credentials as api_key type", () => {
it("keeps only explicit built-ins when a model-registry-derived provider is also OAuth-backed", () => {
const fusionAuth = makeAuthStorage();
fusionAuth.getOAuthProviders = vi.fn(() => [
{ id: "openai", name: "OpenAI OAuth" },
{ id: "github-copilot", name: "GitHub Copilot" },
]);
const modelRegistry = { getAll: vi.fn(() => [
{ provider: "openai", id: "openai/gpt-4o" },
{ provider: "github-copilot", id: "github-copilot/gpt-4o" },
]) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
const apiKeyProviders = wrapped.getApiKeyProviders();
expect(apiKeyProviders.some((p) => p.id === "openai")).toBe(false);
expect(apiKeyProviders.some((p) => p.id === "github-copilot")).toBe(false);
});
it("round-trips anthropic API key credentials", async () => {
const fusionAuth = makeAuthStorage();
fusionAuth.getOAuthProviders = vi.fn(() => [
{ id: "anthropic", name: "Anthropic" },
@@ -214,20 +234,15 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
type: "api_key",
key: "sk-ant-api03-test-key",
});
});
it("detects anthropic as authenticated via hasApiKey after storing API key", () => {
const fusionAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "sk-ant-api03-test" },
});
fusionAuth.getOAuthProviders = vi.fn(() => [
{ id: "anthropic", name: "Anthropic" },
]);
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
expect(wrapped.hasApiKey("anthropic")).toBe(true);
expect(await wrapped.getApiKey("anthropic")).toBe("sk-ant-api03-test-key");
expect(wrapped.get("anthropic")).toEqual({ type: "api_key", key: "sk-ant-api03-test-key" });
wrapped.clearApiKey("anthropic");
expect(fusionAuth.remove).toHaveBeenCalledWith("anthropic");
expect(wrapped.hasApiKey("anthropic")).toBe(false);
expect(await wrapped.getApiKey("anthropic")).toBeUndefined();
});
});

View File

@@ -42,6 +42,7 @@ interface ReadFallbackAuthStorage {
type StoredCredential = StoredAuthCredential;
const BUILT_IN_API_KEY_PROVIDERS: Array<{ id: string; name: string }> = [
{ id: "anthropic", name: "Anthropic" },
{ id: "brave", name: "Brave Search" },
{ id: "kimi-coding", name: "Kimi" },
{ id: "minimax", name: "Minimax" },
@@ -97,9 +98,12 @@ export function wrapAuthStorageWithApiKeyProviders(
const providers = new Map<string, string>();
for (const provider of BUILT_IN_API_KEY_PROVIDERS) {
if (!oauthProviderIds.has(provider.id)) {
providers.set(provider.id, provider.name);
}
/*
FNXC:ProviderAuth 2026-06-28-15:53:
Anthropic supports raw API-key credentials next to its OAuth-capable provider surface, so built-in API-key providers must remain visible even when their id also appears in the OAuth provider list.
Keep OAuth-id exclusion only for registry-derived providers to avoid accidentally reclassifying unrelated OAuth providers while preserving explicit API-key targets.
*/
providers.set(provider.id, provider.name);
}
for (const model of modelRegistry.getAll()) {

View File

@@ -1730,6 +1730,8 @@ export interface AuthProvider {
* one-click Enable/Disable + Test button rather than login/key inputs.
*/
type?: "oauth" | "api_key" | "cli";
/** Provider accepts a raw API key in addition to its primary auth method, e.g. an OAuth provider that also accepts ANTHROPIC_API_KEY. */
supportsApiKey?: boolean;
/** Masked hint of the stored API key (first 3 + bullets + last 4 chars) */
keyHint?: string;
}

View File

@@ -91,7 +91,16 @@ interface ProviderInfo {
/** Provider metadata with plain-language descriptions for the onboarding UI */
function getProviderInfoMap(t: (key: string, defaultValue: string) => string): Record<string, ProviderInfo> {
return {
anthropic: { description: t("setup.providerDesc.anthropic", "Claude models — strong at reasoning, analysis, and code") },
anthropic: {
description: t("setup.providerDesc.anthropic", "Claude models — strong at reasoning, analysis, and code"),
apiKeyInfo: {
fieldLabel: t("setup.apiKeyLabel.anthropic", "Anthropic API Key"),
setupInstructions: t("setup.apiKeySetup.anthropic", "Create an API key from your Anthropic Console under API keys."),
dashboardUrl: "https://console.anthropic.com/settings/keys",
inputPlaceholder: "sk-ant-...",
usageDescription: t("setup.apiKeyUsage.anthropic", "Used for Claude models in task execution and planning"),
},
},
openai: {
description: t("setup.providerDesc.openai", "GPT models — versatile for a wide range of tasks"),
apiKeyInfo: {
@@ -1802,7 +1811,8 @@ export function ModelOnboardingModal({
const showShellConnectionSetup = shellState.host !== "web" && !shellState.activeProfileId;
const orderedAiProviders = [...aiProviders].sort(compareOnboardingProviders);
const hasOauthProviders = orderedAiProviders.some((provider) => !provider.type || provider.type === "oauth");
const hasApiKeyProviders = orderedAiProviders.some((provider) => provider.type === "api_key");
const providerSupportsApiKey = (provider: AuthProvider) => provider.type === "api_key" || provider.supportsApiKey === true;
const hasApiKeyProviders = orderedAiProviders.some((provider) => providerSupportsApiKey(provider));
const connectedAiProviders = aiProviders.filter((provider) => provider.authenticated);
const hasAiProvider = connectedAiProviders.length > 0;
const hasProjectSelected = Boolean(projectId);
@@ -2005,10 +2015,17 @@ export function ModelOnboardingModal({
);
}
if (provider.type === "api_key") {
if (providerSupportsApiKey(provider)) {
const providerInfo = getProviderInfo(provider.id, t);
const apiKeyInfo = getApiKeyInfo(provider, t);
const isDualAuthProvider = provider.type !== "api_key" && provider.supportsApiKey === true;
const hasStoredApiKey = Boolean(provider.keyHint);
/*
FNXC:ProviderAuth 2026-06-28-16:14:
Onboarding should offer Anthropic's raw API-key path next to the existing OAuth login path, while OpenAI continues using its existing standalone API-key card.
Use `supportsApiKey` for dual providers so OAuth-only authentication does not hide the API-key input.
*/
return (
<div
key={provider.id}
@@ -2035,6 +2052,55 @@ export function ModelOnboardingModal({
<span className="auth-key-hint">{t("setup.apiKeyHint", "Key: {{keyHint}}", { keyHint: provider.keyHint })}</span>
)}
</div>
{isDualAuthProvider && (
<div className="onboarding-provider-card__actions">
{authActionInProgress === provider.id ? (
provider.authenticated ? (
<button className="btn btn-sm" disabled>
{t("setup.loggingOut", "Logging out…")}
</button>
) : (
<>
<button className="btn btn-sm" disabled>
{t("setup.waitingForLogin", "Waiting for login…")}
</button>
<button
className="btn btn-sm"
onClick={() => void handleCancelLogin(provider.id)}
>
{t("setup.cancelLogin", "Cancel")}
</button>
</>
)
) : showRemoteLoginInProgress ? (
<>
<button className="btn btn-sm" disabled>
{t("setup.waitingForLogin", "Waiting for login…")}
</button>
<button
className="btn btn-sm"
onClick={() => void handleCancelLogin(provider.id)}
>
{t("setup.cancelLogin", "Cancel")}
</button>
</>
) : provider.authenticated ? (
<button
className="btn btn-sm"
onClick={() => handleLogout(provider.id)}
>
{t("setup.logout", "Logout")}
</button>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleLogin(provider.id)}
>
{t("setup.login", "Login")}
</button>
)}
</div>
)}
<div className="onboarding-provider-card__actions onboarding-provider-card__actions--api-key">
<ApiKeyEntryForm
provider={provider}
@@ -2043,12 +2109,57 @@ export function ModelOnboardingModal({
isSaving={authActionInProgress === provider.id}
error={apiKeyErrors[provider.id]}
success={apiKeySuccess[provider.id]}
isConnected={provider.authenticated}
isConnected={provider.type === "api_key" ? provider.authenticated : hasStoredApiKey}
onInputChange={handleApiKeyInputChange}
onSave={handleSaveApiKey}
onClear={handleClearApiKey}
/>
</div>
{isDualAuthProvider && (authActionInProgress === provider.id || showRemoteLoginInProgress) && provider.id === "github-copilot" && deviceCodes[provider.id] && (
<div className="auth-device-code-panel" data-testid={`onboarding-device-code-${provider.id}`}>
<strong>{t("setup.enterCodeOnGitHub", "Enter this code on GitHub")}</strong>
<div className="auth-device-code-pill">{deviceCodes[provider.id].userCode}</div>
<div className="auth-provider-actions-row">
<button
className="btn btn-sm"
onClick={() => {
void (async () => {
const copied = await copyTextToClipboard(deviceCodes[provider.id].userCode);
if (copied) {
addToast(t("setup.copiedCodeToClipboard", "Copied code to clipboard"), "success");
return;
}
addToast(t("setup.failedToCopyCode", "Failed to copy code — copy it manually from the box above"), "error");
})();
}}
>
{t("setup.copyCode", "Copy code")}
</button>
<button className="btn btn-sm" onClick={() => window.open(appendTokenQuery(deviceCodes[provider.id].verificationUri), "_blank")}>
{t("setup.openGitHub", "Open GitHub")}
</button>
</div>
</div>
)}
{isDualAuthProvider && (authActionInProgress === provider.id || showRemoteLoginInProgress) && loginInstructions[provider.id] && (
<LoginInstructions
instructions={loginInstructions[provider.id]}
data-testid={`onboarding-login-instructions-${provider.id}`}
/>
)}
{isDualAuthProvider && (authActionInProgress === provider.id || showRemoteLoginInProgress) && manualCodeConfigs[provider.id] && (
<OAuthManualCodeForm
value={manualCodeInputs[provider.id] ?? ""}
onChange={(value) => setManualCodeInputs((prev) => ({ ...prev, [provider.id]: value }))}
onSubmit={() => void handleSubmitManualCode(provider.id)}
prompt={manualCodeConfigs[provider.id].prompt}
placeholder={manualCodeConfigs[provider.id].placeholder}
helpText={manualCodeConfigs[provider.id].helpText}
disabled={manualCodeSubmitInProgress === provider.id}
submitLabel={manualCodeSubmitInProgress === provider.id ? t("setup.submittingCode", "Submitting…") : t("setup.submitCode", "Submit code")}
data-testid={`onboarding-manual-code-${provider.id}`}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { useState } from "react";
import { AuthenticationSection, type AuthenticationSectionData } from "../settings/sections/AuthenticationSection";
import type { AuthProvider } from "../../api";
vi.mock("../ProviderIcon", () => ({
ProviderIcon: ({ provider }: { provider: string }) => <span data-testid={`mock-icon-${provider}`}>{provider}</span>,
}));
vi.mock("../PluginSlot", () => ({
PluginSlot: ({ slotId }: { slotId: string }) => <div data-testid={`plugin-slot-${slotId}`} />,
}));
vi.mock("../LoginInstructions", () => ({
LoginInstructions: ({ instructions }: { instructions: string }) => <div>{instructions}</div>,
}));
vi.mock("../LoadingSpinner", () => ({
LoadingSpinner: ({ label }: { label: string }) => <div>{label}</div>,
}));
vi.mock("../OAuthManualCodeForm", () => ({
OAuthManualCodeForm: ({ prompt }: { prompt: string }) => <div>{prompt}</div>,
}));
vi.mock("../CustomProvidersSection", () => ({
CustomProvidersSection: () => <div data-testid="custom-providers-section" />,
}));
vi.mock("../ClaudeCliProviderCard", () => ({ ClaudeCliProviderCard: () => <div /> }));
vi.mock("../CursorCliProviderCard", () => ({ CursorCliProviderCard: () => <div /> }));
vi.mock("../LlamaCppProviderCard", () => ({ LlamaCppProviderCard: () => <div /> }));
function renderAuthSection(providers: AuthProvider[], overrides: Partial<AuthenticationSectionData> = {}) {
const handleLogin = vi.fn();
const handleLogout = vi.fn();
const handleSaveApiKey = vi.fn();
const handleClearApiKey = vi.fn();
function Harness() {
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
const [manualCodeInputs, setManualCodeInputs] = useState<Record<string, string>>({});
const auth: AuthenticationSectionData = {
addToast: vi.fn(),
authProviders: providers,
authLoading: false,
authActionInProgress: null,
apiKeyInputs,
setApiKeyInputs,
apiKeyErrors: {},
opencodeApiKeyRefreshStatus: {},
deviceCodes: {},
loginInstructions: {},
manualCodeConfigs: {},
manualCodeInputs,
setManualCodeInputs,
manualCodeSubmitInProgress: null,
loadAuthStatus: vi.fn(),
handleLogin,
handleLogout,
handleCancelLogin: vi.fn(),
handleSaveApiKey,
handleClearApiKey,
handleSubmitManualCode: vi.fn(),
...overrides,
};
return <AuthenticationSection auth={auth} />;
}
render(<Harness />);
return { handleLogin, handleLogout, handleSaveApiKey, handleClearApiKey };
}
describe("AuthenticationSection", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders an unauthenticated dual Anthropic card with OAuth login and API-key save", () => {
const { handleLogin, handleSaveApiKey } = renderAuthSection([
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", supportsApiKey: true },
]);
const card = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement;
expect(within(card).getByRole("button", { name: "Login" })).toBeInTheDocument();
fireEvent.change(within(card).getByPlaceholderText("Enter API key"), { target: { value: "sk-ant-api03-new" } });
const saveButton = within(card).getByRole("button", { name: "Save" });
expect(saveButton).toHaveClass("btn-primary");
fireEvent.click(saveButton);
fireEvent.click(within(card).getByRole("button", { name: "Login" }));
expect(handleSaveApiKey).toHaveBeenCalledWith("anthropic");
expect(handleLogin).toHaveBeenCalledWith("anthropic");
});
it("renders OAuth-only dual Anthropic as authenticated while keeping the API-key input", () => {
const { handleLogout } = renderAuthSection([
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth", supportsApiKey: true },
]);
const card = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement;
expect(card).toHaveClass("auth-provider-card--authenticated");
expect(within(card).getByRole("button", { name: "Logout" })).toBeInTheDocument();
expect(within(card).getByRole("button", { name: "Save" })).toBeInTheDocument();
expect(within(card).getByPlaceholderText("Enter API key")).toBeInTheDocument();
fireEvent.click(within(card).getByRole("button", { name: "Logout" }));
expect(handleLogout).toHaveBeenCalledWith("anthropic");
});
it("renders API-key-only dual Anthropic with masked key hint and Clear", () => {
const { handleClearApiKey } = renderAuthSection([
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", supportsApiKey: true, keyHint: "sk-•••••1234" },
]);
const card = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement;
expect(card).not.toHaveClass("auth-provider-card--authenticated");
expect(within(card).getByRole("button", { name: "Login" })).toBeInTheDocument();
expect(within(card).queryByRole("button", { name: "Logout" })).not.toBeInTheDocument();
expect(within(card).getByText("Key: sk-•••••1234")).toBeInTheDocument();
expect(within(card).getByRole("button", { name: "Clear" })).toBeInTheDocument();
fireEvent.change(within(card).getByPlaceholderText("Enter API key"), { target: { value: "sk-ant-api03-replacement" } });
expect(within(card).queryByRole("button", { name: "Clear" })).not.toBeInTheDocument();
expect(within(card).getByRole("button", { name: "Save" })).toBeInTheDocument();
fireEvent.change(within(card).getByPlaceholderText("Enter API key"), { target: { value: "" } });
fireEvent.click(within(card).getByRole("button", { name: "Clear" }));
expect(handleClearApiKey).toHaveBeenCalledWith("anthropic");
});
it("renders both OAuth logout and API-key Clear when Anthropic has both credentials", () => {
renderAuthSection([
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth", supportsApiKey: true, keyHint: "sk-•••••dkey" },
]);
const card = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement;
expect(within(card).getByRole("button", { name: "Logout" })).toBeInTheDocument();
expect(within(card).getByRole("button", { name: "Clear" })).toBeInTheDocument();
});
});

View File

@@ -16,6 +16,7 @@ import {
mockLogoutProvider,
mockCancelProviderLogin,
mockSaveApiKey,
mockClearApiKey,
mockSubmitProviderManualCode,
mockFetchModels,
mockFetchWorkflow,
@@ -91,6 +92,7 @@ vi.mock("../../api", async (importOriginal) => {
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
cancelProviderLogin: (...args: unknown[]) => mockCancelProviderLogin(...args),
saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args),
clearApiKey: (...args: unknown[]) => mockClearApiKey(...args),
submitProviderManualCode: (...args: unknown[]) => mockSubmitProviderManualCode(...args),
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
fetchWorkflow: (...args: unknown[]) => mockFetchWorkflow(...args),
@@ -1356,6 +1358,55 @@ describe("SettingsModal", () => {
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("manually"), "error");
});
it("renders dual Anthropic OAuth and API-key controls in Authentication settings", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", supportsApiKey: true }],
});
render(<SettingsModal onClose={noop} addToast={vi.fn()} />);
await settingsModalUser.click(await screen.findByRole("button", { name: "Authentication" }));
const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement;
expect(within(anthropicCard).getByRole("button", { name: "Login" })).toBeInTheDocument();
await settingsModalUser.type(within(anthropicCard).getByPlaceholderText("Enter API key"), "sk-ant-api03-settings");
await settingsModalUser.click(within(anthropicCard).getByRole("button", { name: "Save" }));
expect(mockSaveApiKey).toHaveBeenCalledWith("anthropic", "sk-ant-api03-settings");
});
it("renders Login and Clear for an Anthropic API-key-only card", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", supportsApiKey: true, keyHint: "sk-•••••1234" }],
});
render(<SettingsModal onClose={noop} addToast={vi.fn()} />);
await settingsModalUser.click(await screen.findByRole("button", { name: "Authentication" }));
const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement;
expect(within(anthropicCard).getByRole("button", { name: "Login" })).toBeInTheDocument();
expect(within(anthropicCard).queryByRole("button", { name: "Logout" })).not.toBeInTheDocument();
expect(within(anthropicCard).getByText("Key: sk-•••••1234")).toBeInTheDocument();
await settingsModalUser.click(within(anthropicCard).getByRole("button", { name: "Clear" }));
expect(mockClearApiKey).toHaveBeenCalledWith("anthropic");
});
it("renders Clear beside OAuth controls for stored Anthropic API keys", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth", supportsApiKey: true, keyHint: "sk-•••••dkey" }],
});
render(<SettingsModal onClose={noop} addToast={vi.fn()} />);
await settingsModalUser.click(await screen.findByRole("button", { name: "Authentication" }));
const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement;
expect(within(anthropicCard).getByRole("button", { name: "Logout" })).toBeInTheDocument();
expect(within(anthropicCard).getByText("Key: sk-•••••dkey")).toBeInTheDocument();
await settingsModalUser.click(within(anthropicCard).getByRole("button", { name: "Clear" }));
expect(mockClearApiKey).toHaveBeenCalledWith("anthropic");
});
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

@@ -33,6 +33,7 @@ export const mockLoginProvider = vi.fn();
export const mockLogoutProvider = vi.fn();
export const mockCancelProviderLogin = vi.fn();
export const mockSaveApiKey = vi.fn();
export const mockClearApiKey = vi.fn();
export const mockSubmitProviderManualCode = vi.fn();
export const mockFetchModels = vi.fn();
export const mockFetchWorkflow = vi.fn();
@@ -280,6 +281,7 @@ export function installSettingsModalEnv() {
mockDeleteCustomProvider.mockResolvedValue(undefined);
mockCancelProviderLogin.mockResolvedValue({ success: true, cancelled: true });
mockSaveApiKey.mockResolvedValue(undefined);
mockClearApiKey.mockResolvedValue({ success: true });
mockSubmitProviderManualCode.mockResolvedValue({ success: true, submitted: true });
mockTestNotification.mockResolvedValue({ success: true });
mockFetchBackups.mockResolvedValue({ backups: [], totalSize: 0 });

View File

@@ -503,6 +503,34 @@ describe("onboarding flow integration", () => {
expect(screen.getByTestId("claude-cli-provider-card")).toHaveAttribute("data-authenticated", "false");
});
it("renders dual Anthropic OAuth and API-key controls in onboarding", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", supportsApiKey: true },
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
],
});
renderModal();
await waitFor(() => {
expect(screen.getByTestId("onboarding-apikey-input-anthropic")).toBeInTheDocument();
});
const anthropicCard = screen.getByTestId("onboarding-provider-card-anthropic");
expect(within(anthropicCard).getByRole("button", { name: "Login" })).toBeInTheDocument();
expect(screen.getByTestId("onboarding-apikey-input-openai")).toBeInTheDocument();
fireEvent.change(screen.getByTestId("onboarding-apikey-input-anthropic"), {
target: { value: "sk-ant-api03-flow-test" },
});
fireEvent.click(screen.getByTestId("onboarding-apikey-save-anthropic"));
await waitFor(() => {
expect(mockSaveApiKey).toHaveBeenCalledWith("anthropic", "sk-ant-api03-flow-test");
});
});
it("still progresses to later onboarding steps after interacting with the sectioned provider UI", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [

View File

@@ -47,7 +47,7 @@ vi.mock("../../api", () => ({
fetchSettingsByScope: vi.fn(() => Promise.resolve({ global: { ...defaultSettings }, project: {} })),
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
updateGlobalSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }] })),
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", supportsApiKey: true }] })),
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
saveApiKey: vi.fn(() => Promise.resolve({ success: true })),
@@ -375,6 +375,20 @@ describe("SettingsModal mobile adaptations", () => {
expect(getByText("These settings are shared across all your Fusion projects.")).toBeTruthy();
});
it("renders dual Anthropic Authentication controls on mobile", async () => {
mockSettingsViewport(true);
Object.defineProperty(window, "innerWidth", { configurable: true, value: 375 });
const user = userEvent.setup();
const { findByTestId, getByLabelText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
await user.selectOptions(getByLabelText("Settings Section"), "authentication");
const card = (await findByTestId("auth-provider-icon-anthropic")).closest(".auth-provider-card") as HTMLElement;
expect(within(card).getByRole("button", { name: "Login" })).toBeTruthy();
expect(within(card).getByPlaceholderText("Enter API key")).toBeTruthy();
expect(within(card).getByRole("button", { name: "Save" })).toBeTruthy();
});
it("renders notification provider cards responsively on mobile", async () => {
mockSettingsViewport(true);
Object.defineProperty(window, "innerWidth", { configurable: true, value: 375 });

View File

@@ -79,6 +79,77 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
(claudeCliProvider && !claudeCliProvider.authenticated) ||
(cursorCliProvider && !cursorCliProvider.authenticated) ||
(llamaCppProvider && !llamaCppProvider.authenticated);
const providerSupportsApiKey = (provider: AuthProvider) => provider.type === "api_key" || provider.supportsApiKey === true;
const renderApiKeySection = (provider: AuthProvider) => (<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[provider.id] ?? ""} onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} disabled={authActionInProgress === provider.id}/>
{provider.keyHint && !apiKeyInputs[provider.id] ? (<button className="btn btn-sm" onClick={() => handleClearApiKey(provider.id)} disabled={authActionInProgress === provider.id}>
{t("settings.auth.clearKey", "Clear")}
</button>) : (<button className="btn btn-primary btn-sm" onClick={() => handleSaveApiKey(provider.id)} disabled={authActionInProgress === provider.id}>
{t("settings.actions.save", "Save")}
</button>)}
</div>
{authActionInProgress === provider.id && (<small className="auth-apikey-progress">{t("settings.auth.savingKey", "Saving…")}</small>)}
{apiKeyErrors[provider.id] && (<small className="auth-apikey-error">{apiKeyErrors[provider.id]}</small>)}
{(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && (<small className={opencodeApiKeyRefreshStatus[provider.id].tone === "error" ? "form-error" : "text-muted"}>
{opencodeApiKeyRefreshStatus[provider.id].message}
</small>)}
</div>);
const renderAuthenticatedOAuthActions = (provider: AuthProvider) => (<div>
{authActionInProgress === provider.id ? (<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={() => handleCancelLogin(provider.id)}>
{t("settings.actions.cancel", "Cancel")}
</button>
</div>) : (<button className="btn btn-sm" onClick={() => handleLogout(provider.id)}>
{t("settings.auth.logout", "Logout")}
</button>)}
</div>);
const renderAvailableOAuthActions = (provider: AuthProvider) => (<div>
{authActionInProgress === provider.id ? (<button className="btn btn-sm" disabled>
{t("settings.auth.waitingForLogin", "Waiting for login…")}
</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={() => handleCancelLogin(provider.id)}>
{t("settings.actions.cancel", "Cancel")}
</button>
</div>) : (<button className="btn btn-primary btn-sm" onClick={() => handleLogin(provider.id)}>
{t("settings.auth.login", "Login")}
</button>)}
{provider.id === "github-copilot" && deviceCodes[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (<div className="auth-device-code-panel" data-testid={`auth-device-code-${provider.id}`}>
<strong>{t("settings.auth.enterCodeOnGitHub", "Enter this code on GitHub")}</strong>
<div className="auth-device-code-pill">{deviceCodes[provider.id].userCode}</div>
<div className="auth-provider-actions-row">
<button className="btn btn-sm" onClick={() => {
void (async () => {
const copied = await copyTextToClipboard(deviceCodes[provider.id].userCode);
if (copied) {
addToast(t("settings.auth.copiedCodeToClipboard", "Copied code to clipboard"), "success");
return;
}
addToast(t("settings.auth.failedToCopyCode", "Failed to copy code — copy it manually from the box above"), "error");
})();
}}>
{t("settings.auth.copyCode", "Copy code")}
</button>
<button className="btn btn-sm" onClick={() => window.open(appendTokenQuery(deviceCodes[provider.id].verificationUri), "_blank")}>
{t("settings.auth.openGitHub", "Open GitHub")}
</button>
</div>
</div>)}
{loginInstructions[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (<LoginInstructions instructions={loginInstructions[provider.id]} data-testid={`auth-login-instructions-${provider.id}`}/>)}
{manualCodeConfigs[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (<OAuthManualCodeForm value={manualCodeInputs[provider.id] ?? ""} onChange={(value) => setManualCodeInputs((prev) => ({ ...prev, [provider.id]: value }))} onSubmit={() => void handleSubmitManualCode(provider.id)} prompt={manualCodeConfigs[provider.id].prompt} placeholder={manualCodeConfigs[provider.id].placeholder} helpText={manualCodeConfigs[provider.id].helpText} disabled={manualCodeSubmitInProgress === provider.id} submitLabel={manualCodeSubmitInProgress === provider.id ? "Submitting…" : "Submit code"} data-testid={`auth-manual-code-${provider.id}`}/>)}</div>);
/*
FNXC:ProviderAuth 2026-06-28-16:02:
A provider can be dual-auth: Anthropic keeps its OAuth login controls while also accepting an `ANTHROPIC_API_KEY` stored through the same API-key row as standalone providers.
Render both intentional controls on one card so Settings does not create duplicate provider cards or orphaned action wrappers.
*/
return (<>
<h4 className="settings-section-heading">{t("settings.auth.title", "Authentication")}</h4>
{authLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.auth.loadingStatus", "Loading authentication status…")} /></div>) : authProviders.length === 0 ? (<div className="settings-empty-state settings-muted">
@@ -107,34 +178,8 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
</span>
{provider.authenticated && provider.keyHint && (<span className="auth-key-hint">{t("settings.authentication.key", "Key: ")}{provider.keyHint}</span>)}
</div>
{provider.type === "api_key" ? (<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[provider.id] ?? ""} onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} disabled={authActionInProgress === provider.id}/>
{provider.authenticated && !apiKeyInputs[provider.id] ? (<button className="btn btn-sm" onClick={() => handleClearApiKey(provider.id)} disabled={authActionInProgress === provider.id}>
{t("settings.auth.clearKey", "Clear")}
</button>) : (<button className="btn btn-primary btn-sm" onClick={() => handleSaveApiKey(provider.id)} disabled={authActionInProgress === provider.id}>
{t("settings.actions.save", "Save")}
</button>)}
</div>
{authActionInProgress === provider.id && (<small className="auth-apikey-progress">{t("settings.auth.savingKey", "Saving…")}</small>)}
{apiKeyErrors[provider.id] && (<small className="auth-apikey-error">{apiKeyErrors[provider.id]}</small>)}
{(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && (<small className={opencodeApiKeyRefreshStatus[provider.id].tone === "error" ? "form-error" : "text-muted"}>
{opencodeApiKeyRefreshStatus[provider.id].message}
</small>)}
</div>) : (<div>
{authActionInProgress === provider.id ? (<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={() => handleCancelLogin(provider.id)}>
{t("settings.actions.cancel", "Cancel")}
</button>
</div>) : (<button className="btn btn-sm" onClick={() => handleLogout(provider.id)}>
{t("settings.auth.logout", "Logout")}
</button>)}
</div>)}
{provider.type !== "api_key" && renderAuthenticatedOAuthActions(provider)}
{providerSupportsApiKey(provider) && renderApiKeySection(provider)}
</div>
</div>))}
</div>)}
@@ -154,56 +199,10 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
<span data-testid={`auth-status-${provider.id}`} className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}>
{t("settings.auth.statusNotConnected", "✗ Not connected")}
</span>
{provider.keyHint && (<span className="auth-key-hint">{t("settings.authentication.key", "Key: ")}{provider.keyHint}</span>)}
</div>
{provider.type === "api_key" ? (<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[provider.id] ?? ""} onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} disabled={authActionInProgress === provider.id}/>
<button className="btn btn-primary btn-sm" onClick={() => handleSaveApiKey(provider.id)} disabled={authActionInProgress === provider.id}>
{t("settings.actions.save", "Save")}
</button>
</div>
{authActionInProgress === provider.id && (<small className="auth-apikey-progress">{t("settings.auth.savingKey", "Saving…")}</small>)}
{apiKeyErrors[provider.id] && (<small className="auth-apikey-error">{apiKeyErrors[provider.id]}</small>)}
{(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && (<small className={opencodeApiKeyRefreshStatus[provider.id].tone === "error" ? "form-error" : "text-muted"}>
{opencodeApiKeyRefreshStatus[provider.id].message}
</small>)}
</div>) : (<div>
{authActionInProgress === provider.id ? (<button className="btn btn-sm" disabled>
{t("settings.auth.waitingForLogin", "Waiting for login…")}
</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={() => handleCancelLogin(provider.id)}>
{t("settings.actions.cancel", "Cancel")}
</button>
</div>) : (<button className="btn btn-primary btn-sm" onClick={() => handleLogin(provider.id)}>
{t("settings.auth.login", "Login")}
</button>)}
{provider.id === "github-copilot" && deviceCodes[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (<div className="auth-device-code-panel" data-testid={`auth-device-code-${provider.id}`}>
<strong>{t("settings.auth.enterCodeOnGitHub", "Enter this code on GitHub")}</strong>
<div className="auth-device-code-pill">{deviceCodes[provider.id].userCode}</div>
<div className="auth-provider-actions-row">
<button className="btn btn-sm" onClick={() => {
void (async () => {
const copied = await copyTextToClipboard(deviceCodes[provider.id].userCode);
if (copied) {
addToast(t("settings.auth.copiedCodeToClipboard", "Copied code to clipboard"), "success");
return;
}
addToast(t("settings.auth.failedToCopyCode", "Failed to copy code — copy it manually from the box above"), "error");
})();
}}>
{t("settings.auth.copyCode", "Copy code")}
</button>
<button className="btn btn-sm" onClick={() => window.open(appendTokenQuery(deviceCodes[provider.id].verificationUri), "_blank")}>
{t("settings.auth.openGitHub", "Open GitHub")}
</button>
</div>
</div>)}
{loginInstructions[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (<LoginInstructions instructions={loginInstructions[provider.id]} data-testid={`auth-login-instructions-${provider.id}`}/>)}
{manualCodeConfigs[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (<OAuthManualCodeForm value={manualCodeInputs[provider.id] ?? ""} onChange={(value) => setManualCodeInputs((prev) => ({ ...prev, [provider.id]: value }))} onSubmit={() => void handleSubmitManualCode(provider.id)} prompt={manualCodeConfigs[provider.id].prompt} placeholder={manualCodeConfigs[provider.id].placeholder} helpText={manualCodeConfigs[provider.id].helpText} disabled={manualCodeSubmitInProgress === provider.id} submitLabel={manualCodeSubmitInProgress === provider.id ? "Submitting…" : "Submit code"} data-testid={`auth-manual-code-${provider.id}`}/>)}
</div>)}
{provider.type !== "api_key" && renderAvailableOAuthActions(provider)}
{providerSupportsApiKey(provider) && renderApiKeySection(provider)}
</div>
</div>))}
</div>)}

View File

@@ -848,6 +848,61 @@ describe("GET /auth/status", () => {
expect(openrouter.type).toBe("api_key");
});
it("marks an OAuth provider as supporting API keys when provider ids collide", async () => {
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "anthropic", name: "Anthropic" },
]);
(authStorage.getApiKeyProviders as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "anthropic", name: "Anthropic" },
]);
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockReturnValue(false);
(authStorage.hasApiKey as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => provider === "anthropic");
(authStorage.get as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => (
provider === "anthropic" ? { type: "api_key", key: "sk-ant-api03-abcdef1234" } : undefined
));
const res = await GET(app, "/api/auth/status");
expect(res.status).toBe(200);
const anthropicProviders = res.body.providers.filter((p: any) => p.id === "anthropic");
expect(anthropicProviders).toHaveLength(1);
expect(anthropicProviders[0]).toMatchObject({
id: "anthropic",
name: "Anthropic",
authenticated: true,
type: "oauth",
supportsApiKey: true,
keyHint: "sk-•••••1234",
requiresManualCode: true,
});
});
it("preserves OAuth authentication while surfacing a stored dual-provider API key", async () => {
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "anthropic", name: "Anthropic" },
]);
(authStorage.getApiKeyProviders as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "anthropic", name: "Anthropic" },
]);
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => provider === "anthropic");
(authStorage.hasApiKey as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => provider === "anthropic");
(authStorage.get as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => (
provider === "anthropic" ? { type: "api_key", key: "sk-ant-api03-oauthandkey" } : undefined
));
const res = await GET(app, "/api/auth/status");
expect(res.status).toBe(200);
const anthropicProviders = res.body.providers.filter((p: any) => p.id === "anthropic");
expect(anthropicProviders).toHaveLength(1);
expect(anthropicProviders[0]).toMatchObject({
authenticated: true,
type: "oauth",
supportsApiKey: true,
keyHint: "sk-•••••dkey",
});
});
it("reports research API-key providers with type api_key", async () => {
(authStorage.getApiKeyProviders as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "tavily", name: "Tavily" },
@@ -2105,6 +2160,21 @@ describe("POST /auth/api-key", () => {
expect(authStorage.setApiKey).toHaveBeenCalledWith("tavily", "tavily-secret");
});
it("saves an Anthropic API key when Anthropic is also an OAuth provider", async () => {
(authStorage.getApiKeyProviders as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "anthropic", name: "Anthropic" },
]);
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
provider: "anthropic",
apiKey: " sk-ant-api03-test-key ",
}), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(authStorage.setApiKey).toHaveBeenCalledWith("anthropic", "sk-ant-api03-test-key");
});
it("returns 400 when provider is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({
apiKey: "sk-test",

View File

@@ -272,6 +272,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
type: "oauth" | "api_key" | "cli";
expired?: boolean;
keyHint?: string;
supportsApiKey?: boolean;
loginInProgress?: boolean;
requiresManualCode?: boolean;
}[] = await Promise.all(oauthProviders.map(async (p) => {
@@ -306,8 +307,6 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
if (storage.getApiKeyProviders) {
const apiKeyProviders = storage.getApiKeyProviders();
for (const p of apiKeyProviders) {
// Skip if already listed as an OAuth provider (avoid duplicates)
if (providers.some((existing) => existing.id === p.id)) continue;
let keyHint: string | undefined;
if (storage.get) {
const cred = storage.get(p.id);
@@ -315,6 +314,20 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
keyHint = maskApiKey(cred.key);
}
}
const existing = providers.find((provider) => provider.id === p.id);
if (existing) {
/*
FNXC:ProviderAuth 2026-06-28-15:58:
Anthropic can be authenticated by either OAuth or `ANTHROPIC_API_KEY`, so `/auth/status` must expose one dual-auth card instead of duplicating the provider or hiding the key row.
Mark API-key-only credentials authenticated here because settings groups cards solely by `authenticated`.
*/
existing.supportsApiKey = true;
existing.keyHint = keyHint;
if (!existing.authenticated && storage.hasApiKey) {
existing.authenticated = storage.hasApiKey(p.id);
}
continue;
}
providers.push({
id: p.id,
name: p.name,