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>
142 lines
6.3 KiB
TypeScript
142 lines
6.3 KiB
TypeScript
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();
|
|
});
|
|
});
|