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:
7
.changeset/fn-7206-anthropic-api-key.md
Normal file
7
.changeset/fn-7206-anthropic-api-key.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user