From 013d50fe8be36bf4c49cf75424ea50c65ad57f38 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 28 Jun 2026 17:03:11 -0700 Subject: [PATCH] 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) --- .changeset/fn-7206-anthropic-api-key.md | 7 + docs/dashboard-guide.md | 2 + docs/settings-reference.md | 4 + .../commands/__tests__/provider-auth.test.ts | 55 ++++--- packages/cli/src/commands/provider-auth.ts | 10 +- packages/dashboard/app/api/legacy.ts | 2 + .../app/components/ModelOnboardingModal.tsx | 119 +++++++++++++- .../__tests__/AuthenticationSection.test.tsx | 141 ++++++++++++++++ .../SettingsModal.models-auth.test.tsx | 51 ++++++ .../__tests__/SettingsModal.test-harness.tsx | 2 + .../__tests__/onboarding-flow.test.tsx | 28 ++++ .../__tests__/settings-mobile.test.tsx | 16 +- .../sections/AuthenticationSection.tsx | 153 +++++++++--------- .../src/__tests__/routes-auth.test.ts | 70 ++++++++ .../src/routes/register-auth-routes.ts | 17 +- 15 files changed, 570 insertions(+), 107 deletions(-) create mode 100644 .changeset/fn-7206-anthropic-api-key.md create mode 100644 packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx diff --git a/.changeset/fn-7206-anthropic-api-key.md b/.changeset/fn-7206-anthropic-api-key.md new file mode 100644 index 0000000000..dfc7e33897 --- /dev/null +++ b/.changeset/fn-7206-anthropic-api-key.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 0a1dcbf324..05169272d3 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 4f5a99867a..30b9a94bb6 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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 diff --git a/packages/cli/src/commands/__tests__/provider-auth.test.ts b/packages/cli/src/commands/__tests__/provider-auth.test.ts index 91c50bdaf8..d52c82d5c1 100644 --- a/packages/cli/src/commands/__tests__/provider-auth.test.ts +++ b/packages/cli/src/commands/__tests__/provider-auth.test.ts @@ -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(); }); }); diff --git a/packages/cli/src/commands/provider-auth.ts b/packages/cli/src/commands/provider-auth.ts index a6436d2138..ea0defc62b 100644 --- a/packages/cli/src/commands/provider-auth.ts +++ b/packages/cli/src/commands/provider-auth.ts @@ -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(); 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()) { diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index e5334c8c45..97333bb22b 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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; } diff --git a/packages/dashboard/app/components/ModelOnboardingModal.tsx b/packages/dashboard/app/components/ModelOnboardingModal.tsx index e597ea1bb3..037d6b4893 100644 --- a/packages/dashboard/app/components/ModelOnboardingModal.tsx +++ b/packages/dashboard/app/components/ModelOnboardingModal.tsx @@ -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 { 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 (
{t("setup.apiKeyHint", "Key: {{keyHint}}", { keyHint: provider.keyHint })} )}
+ {isDualAuthProvider && ( +
+ {authActionInProgress === provider.id ? ( + provider.authenticated ? ( + + ) : ( + <> + + + + ) + ) : showRemoteLoginInProgress ? ( + <> + + + + ) : provider.authenticated ? ( + + ) : ( + + )} +
+ )}
+ {isDualAuthProvider && (authActionInProgress === provider.id || showRemoteLoginInProgress) && provider.id === "github-copilot" && deviceCodes[provider.id] && ( +
+ {t("setup.enterCodeOnGitHub", "Enter this code on GitHub")} +
{deviceCodes[provider.id].userCode}
+
+ + +
+
+ )} + {isDualAuthProvider && (authActionInProgress === provider.id || showRemoteLoginInProgress) && loginInstructions[provider.id] && ( + + )} + {isDualAuthProvider && (authActionInProgress === provider.id || showRemoteLoginInProgress) && manualCodeConfigs[provider.id] && ( + 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}`} + /> + )} ); } diff --git a/packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx b/packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx new file mode 100644 index 0000000000..b0d6814bd7 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx @@ -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 }) => {provider}, +})); + +vi.mock("../PluginSlot", () => ({ + PluginSlot: ({ slotId }: { slotId: string }) =>
, +})); + +vi.mock("../LoginInstructions", () => ({ + LoginInstructions: ({ instructions }: { instructions: string }) =>
{instructions}
, +})); + +vi.mock("../LoadingSpinner", () => ({ + LoadingSpinner: ({ label }: { label: string }) =>
{label}
, +})); + +vi.mock("../OAuthManualCodeForm", () => ({ + OAuthManualCodeForm: ({ prompt }: { prompt: string }) =>
{prompt}
, +})); + +vi.mock("../CustomProvidersSection", () => ({ + CustomProvidersSection: () =>
, +})); + +vi.mock("../ClaudeCliProviderCard", () => ({ ClaudeCliProviderCard: () =>
})); +vi.mock("../CursorCliProviderCard", () => ({ CursorCliProviderCard: () =>
})); +vi.mock("../LlamaCppProviderCard", () => ({ LlamaCppProviderCard: () =>
})); + +function renderAuthSection(providers: AuthProvider[], overrides: Partial = {}) { + const handleLogin = vi.fn(); + const handleLogout = vi.fn(); + const handleSaveApiKey = vi.fn(); + const handleClearApiKey = vi.fn(); + + function Harness() { + const [apiKeyInputs, setApiKeyInputs] = useState>({}); + const [manualCodeInputs, setManualCodeInputs] = useState>({}); + 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 ; + } + + render(); + 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(); + }); +}); 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 0e18ef452f..f8a084a5bb 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx @@ -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(); + 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(); + 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(); + 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" }], diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx index 4316e00787..514cdc3f67 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test-harness.tsx @@ -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 }); diff --git a/packages/dashboard/app/components/__tests__/onboarding-flow.test.tsx b/packages/dashboard/app/components/__tests__/onboarding-flow.test.tsx index f72a5c13c5..60c1b324f1 100644 --- a/packages/dashboard/app/components/__tests__/onboarding-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/onboarding-flow.test.tsx @@ -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: [ diff --git a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx index 2eaf204426..d39b45e7cc 100644 --- a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx @@ -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(); + 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 }); diff --git a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx index 60e6d218a9..ca37e87a12 100644 --- a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx +++ b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx @@ -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) => (
+
+ setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} disabled={authActionInProgress === provider.id}/> + {provider.keyHint && !apiKeyInputs[provider.id] ? () : ()} +
+ {authActionInProgress === provider.id && ({t("settings.auth.savingKey", "Saving…")})} + {apiKeyErrors[provider.id] && ({apiKeyErrors[provider.id]})} + {(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && ( + {opencodeApiKeyRefreshStatus[provider.id].message} + )} +
); + const renderAuthenticatedOAuthActions = (provider: AuthProvider) => (
+ {authActionInProgress === provider.id ? () : provider.loginInProgress ? (
+ + +
) : ()} +
); + const renderAvailableOAuthActions = (provider: AuthProvider) => (
+ {authActionInProgress === provider.id ? () : provider.loginInProgress ? (
+ + +
) : ()} + {provider.id === "github-copilot" && deviceCodes[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
+ {t("settings.auth.enterCodeOnGitHub", "Enter this code on GitHub")} +
{deviceCodes[provider.id].userCode}
+
+ + +
+
)} + {loginInstructions[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ()} + {manualCodeConfigs[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ( 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}`}/>)}
); + /* + 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 (<>

{t("settings.auth.title", "Authentication")}

{authLoading ? (
) : authProviders.length === 0 ? (
@@ -107,34 +178,8 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) { {provider.authenticated && provider.keyHint && ({t("settings.authentication.key", "Key: ")}{provider.keyHint})}
- {provider.type === "api_key" ? (
-
- setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} disabled={authActionInProgress === provider.id}/> - {provider.authenticated && !apiKeyInputs[provider.id] ? () : ()} -
- {authActionInProgress === provider.id && ({t("settings.auth.savingKey", "Saving…")})} - {apiKeyErrors[provider.id] && ({apiKeyErrors[provider.id]})} - {(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && ( - {opencodeApiKeyRefreshStatus[provider.id].message} - )} -
) : (
- {authActionInProgress === provider.id ? () : provider.loginInProgress ? (
- - -
) : ()} -
)} + {provider.type !== "api_key" && renderAuthenticatedOAuthActions(provider)} + {providerSupportsApiKey(provider) && renderApiKeySection(provider)}
))}
)} @@ -154,56 +199,10 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) { {t("settings.auth.statusNotConnected", "✗ Not connected")} + {provider.keyHint && ({t("settings.authentication.key", "Key: ")}{provider.keyHint})}
- {provider.type === "api_key" ? (
-
- setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} disabled={authActionInProgress === provider.id}/> - -
- {authActionInProgress === provider.id && ({t("settings.auth.savingKey", "Saving…")})} - {apiKeyErrors[provider.id] && ({apiKeyErrors[provider.id]})} - {(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && ( - {opencodeApiKeyRefreshStatus[provider.id].message} - )} -
) : (
- {authActionInProgress === provider.id ? () : provider.loginInProgress ? (
- - -
) : ()} - {provider.id === "github-copilot" && deviceCodes[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
- {t("settings.auth.enterCodeOnGitHub", "Enter this code on GitHub")} -
{deviceCodes[provider.id].userCode}
-
- - -
-
)} - {loginInstructions[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ()} - {manualCodeConfigs[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ( 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}`}/>)} -
)} + {provider.type !== "api_key" && renderAvailableOAuthActions(provider)} + {providerSupportsApiKey(provider) && renderApiKeySection(provider)}
))} )} diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index 93026c790f..76d3f4ab71 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -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).mockReturnValue([ + { id: "anthropic", name: "Anthropic" }, + ]); + (authStorage.getApiKeyProviders as ReturnType).mockReturnValue([ + { id: "anthropic", name: "Anthropic" }, + ]); + (authStorage.hasAuth as ReturnType).mockReturnValue(false); + (authStorage.hasApiKey as ReturnType).mockImplementation((provider: string) => provider === "anthropic"); + (authStorage.get as ReturnType).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).mockReturnValue([ + { id: "anthropic", name: "Anthropic" }, + ]); + (authStorage.getApiKeyProviders as ReturnType).mockReturnValue([ + { id: "anthropic", name: "Anthropic" }, + ]); + (authStorage.hasAuth as ReturnType).mockImplementation((provider: string) => provider === "anthropic"); + (authStorage.hasApiKey as ReturnType).mockImplementation((provider: string) => provider === "anthropic"); + (authStorage.get as ReturnType).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).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).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", diff --git a/packages/dashboard/src/routes/register-auth-routes.ts b/packages/dashboard/src/routes/register-auth-routes.ts index 7b886835e1..6e9d0a9f22 100644 --- a/packages/dashboard/src/routes/register-auth-routes.ts +++ b/packages/dashboard/src/routes/register-auth-routes.ts @@ -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,