diff --git a/.changeset/fn-7436-auth-anthropic-order.md b/.changeset/fn-7436-auth-anthropic-order.md new file mode 100644 index 0000000000..ec76f11867 --- /dev/null +++ b/.changeset/fn-7436-auth-anthropic-order.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep Anthropic authentication cards grouped near the top in Settings. +category: fix +dev: Sorts Claude CLI, Anthropic Subscription, and Anthropic API Key before other auth cards within each auth group. diff --git a/packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx b/packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx index f6a45817d1..c682369b16 100644 --- a/packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx +++ b/packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx @@ -28,9 +28,35 @@ vi.mock("../CustomProvidersSection", () => ({ CustomProvidersSection: () =>
, })); -vi.mock("../ClaudeCliProviderCard", () => ({ ClaudeCliProviderCard: () =>
})); -vi.mock("../CursorCliProviderCard", () => ({ CursorCliProviderCard: () =>
})); -vi.mock("../LlamaCppProviderCard", () => ({ LlamaCppProviderCard: () =>
})); +vi.mock("../ClaudeCliProviderCard", () => ({ + ClaudeCliProviderCard: ({ authenticated }: { authenticated: boolean }) => ( +
+ ), +})); +vi.mock("../CursorCliProviderCard", () => ({ + CursorCliProviderCard: ({ authenticated }: { authenticated: boolean }) => ( +
+ ), +})); +vi.mock("../LlamaCppProviderCard", () => ({ + LlamaCppProviderCard: ({ authenticated }: { authenticated: boolean }) => ( +
+ ), +})); + +function authCardOrder(groupLabel: "Authenticated" | "Available") { + const group = screen.getByText(groupLabel).closest(".auth-provider-group") as HTMLElement; + return Array.from(group.children) + .map((child) => { + const element = child as HTMLElement; + if (element.dataset.testid === "claude-cli-provider-card") return "claude-cli"; + if (element.dataset.testid === "cursor-cli-provider-card") return "cursor-cli"; + if (element.dataset.testid === "llama-cpp-provider-card") return "llama-cpp"; + const icon = element.querySelector("[data-testid^='auth-provider-icon-']"); + return icon?.dataset.testid?.replace("auth-provider-icon-", "") ?? null; + }) + .filter((providerId): providerId is string => Boolean(providerId)); +} function renderAuthSection(providers: AuthProvider[], overrides: Partial = {}) { const handleLogin = vi.fn(); @@ -77,6 +103,77 @@ describe("AuthenticationSection", () => { vi.clearAllMocks(); }); + it("sorts visible Anthropic standard providers together near the top", () => { + renderAuthSection([ + { id: "openai", name: "OpenAI", authenticated: false, type: "api_key" }, + { id: "anthropic-api-key", name: "Anthropic API Key", authenticated: false, type: "api_key" }, + { id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth" }, + { id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" }, + { id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" }, + ]); + + expect(authCardOrder("Available")).toEqual([ + "anthropic-subscription", + "anthropic-api-key", + "github-copilot", + "openai", + "openrouter", + ]); + }); + + it("prioritizes Anthropic cards within each auth state without crossing group boundaries", () => { + renderAuthSection([ + { id: "openai", name: "OpenAI", authenticated: true, type: "api_key" }, + { id: "anthropic-api-key", name: "Anthropic API Key", authenticated: false, type: "api_key" }, + { id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth" }, + { id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: true, type: "oauth" }, + { id: "openrouter", name: "OpenRouter", authenticated: true, type: "api_key" }, + ]); + + expect(authCardOrder("Authenticated")).toEqual(["anthropic-subscription", "openai", "openrouter"]); + expect(authCardOrder("Available")).toEqual(["anthropic-api-key", "github-copilot"]); + }); + + it("hides legacy Anthropic when separated cards are present without breaking priority order", () => { + renderAuthSection([ + { id: "openai", name: "OpenAI", authenticated: false, type: "api_key" }, + { id: "anthropic-api-key", name: "Anthropic API Key", authenticated: false, type: "api_key" }, + { id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" }, + { id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" }, + ]); + + expect(screen.queryByTestId("auth-provider-icon-anthropic")).not.toBeInTheDocument(); + expect(authCardOrder("Available")).toEqual(["anthropic-subscription", "anthropic-api-key", "openai"]); + }); + + it("keeps Claude CLI first among CLI-backed authentication cards", () => { + renderAuthSection([ + { id: "llama-cpp", name: "Llama.cpp", authenticated: false, type: "cli" }, + { id: "cursor-cli", name: "Cursor CLI", authenticated: false, type: "cli" }, + { id: "claude-cli", name: "Anthropic — via Claude CLI", authenticated: false, type: "cli" }, + ]); + + expect(authCardOrder("Available")).toEqual(["claude-cli", "cursor-cli", "llama-cpp"]); + }); + + it("groups Anthropic CLI, subscription, and API-key cards before other CLI providers", () => { + renderAuthSection([ + { id: "cursor-cli", name: "Cursor CLI", authenticated: false, type: "cli" }, + { id: "anthropic-api-key", name: "Anthropic API Key", authenticated: false, type: "api_key" }, + { id: "llama-cpp", name: "Llama.cpp", authenticated: false, type: "cli" }, + { id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" }, + { id: "claude-cli", name: "Anthropic — via Claude CLI", authenticated: false, type: "cli" }, + ]); + + expect(authCardOrder("Available")).toEqual([ + "claude-cli", + "anthropic-subscription", + "anthropic-api-key", + "cursor-cli", + "llama-cpp", + ]); + }); + it("renders separate Anthropic subscription OAuth and API-key cards", () => { const { handleLogin, handleSaveApiKey } = renderAuthSection([ { id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" }, diff --git a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx index d657201158..08e01136d4 100644 --- a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx +++ b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx @@ -44,6 +44,32 @@ export interface AuthenticationSectionData { export interface AuthenticationSectionProps { auth: AuthenticationSectionData; } +const ANTHROPIC_AUTH_PROVIDER_PRIORITY: Record = { + "claude-cli": 0, + "anthropic-subscription": 1, + "anthropic-api-key": 2, + anthropic: 3, +}; +const getAuthProviderPriority = (provider: AuthProvider) => ANTHROPIC_AUTH_PROVIDER_PRIORITY[provider.id] ?? Number.POSITIVE_INFINITY; +/* +FNXC:ProviderAuth 2026-07-02-11:26: +Settings groups Anthropic-family auth surfaces near the top so the Claude CLI, subscription OAuth, and API-key paths stay discoverable after the provider split while each Authenticated/Available group keeps its own boundary. +*/ +const compareAuthProviderDisplayOrder = (a: AuthProvider, b: AuthProvider) => { + if (a.authenticated !== b.authenticated) { + return a.authenticated ? -1 : 1; + } + const aPriority = getAuthProviderPriority(a); + const bPriority = getAuthProviderPriority(b); + if (aPriority !== bPriority) { + return aPriority - bPriority; + } + const nameDelta = a.name.localeCompare(b.name); + if (nameDelta !== 0) { + return nameDelta; + } + return a.id.localeCompare(b.id); +}; export function AuthenticationSection({ auth }: AuthenticationSectionProps) { const { t } = useTranslation("app"); const { projectId, addToast, authProviders, authLoading, authActionInProgress, apiKeyInputs, setApiKeyInputs, apiKeyErrors, opencodeApiKeyRefreshStatus, deviceCodes, loginInstructions, manualCodeConfigs, manualCodeInputs, setManualCodeInputs, manualCodeSubmitInProgress, loadAuthStatus, handleLogin, handleLogout, handleCancelLogin, handleSaveApiKey, handleClearApiKey, handleSubmitManualCode, onReopenOnboarding, } = auth; @@ -55,38 +81,33 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) { const visibleAuthProviders = hasSeparatedAnthropicProvider ? authProviders.filter((p) => p.id !== "anthropic") : authProviders; - // CLI-backed providers render their own compact card; filter them out of the - // standard OAuth/API-key sort and render alongside. - const cliAuthProviders = visibleAuthProviders.filter((p) => p.type === "cli"); - const nonCliProviders = visibleAuthProviders.filter((p) => p.type !== "cli"); - const sortedProviders = [...nonCliProviders].sort((a, b) => { - if (a.authenticated !== b.authenticated) { - return a.authenticated ? -1 : 1; - } - return a.name.localeCompare(b.name); - }); + const isSupportedCliProvider = (provider: AuthProvider) => provider.id === "claude-cli" || provider.id === "cursor-cli" || provider.id === "llama-cpp"; + /* + FNXC:ProviderAuth 2026-07-02-12:20: + Authentication ordering must sort supported CLI and non-CLI provider cards in one list so Cursor CLI or llama.cpp cannot split Claude CLI from Anthropic subscription/API-key entries. + */ + const sortedProviders = [...visibleAuthProviders] + .filter((p) => p.type !== "cli" || isSupportedCliProvider(p)) + .sort(compareAuthProviderDisplayOrder); const authenticatedProviders = sortedProviders.filter((p) => p.authenticated); const unauthenticatedProviders = sortedProviders.filter((p) => !p.authenticated); - const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-cli"); - const cursorCliProvider = cliAuthProviders.find((p) => p.id === "cursor-cli"); - const llamaCppProvider = cliAuthProviders.find((p) => p.id === "llama-cpp"); - const claudeCliCard = claudeCliProvider ? ( { - void loadAuthStatus(); - }}/>) : null; - const cursorCliCard = cursorCliProvider ? ( { - void loadAuthStatus(); - }}/>) : null; - const llamaCppCard = llamaCppProvider ? ( { - void loadAuthStatus(); - }}/>) : null; - const showAuthenticatedGroup = authenticatedProviders.length > 0 || - (claudeCliProvider?.authenticated ?? false) || - (cursorCliProvider?.authenticated ?? false) || - (llamaCppProvider?.authenticated ?? false); - const showAvailableGroup = unauthenticatedProviders.length > 0 || - (claudeCliProvider && !claudeCliProvider.authenticated) || - (cursorCliProvider && !cursorCliProvider.authenticated) || - (llamaCppProvider && !llamaCppProvider.authenticated); + const renderCliProviderCard = (provider: AuthProvider) => { + if (provider.id === "claude-cli") { + return ( { + void loadAuthStatus(); + }}/>); + } + if (provider.id === "cursor-cli") { + return ( { + void loadAuthStatus(); + }}/>); + } + return ( { + void loadAuthStatus(); + }}/>); + }; + const showAuthenticatedGroup = authenticatedProviders.length > 0; + const showAvailableGroup = unauthenticatedProviders.length > 0; const providerSupportsApiKey = (provider: AuthProvider) => provider.type === "api_key"; const renderApiKeySection = (provider: AuthProvider) => (
@@ -170,10 +191,7 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
)} {showAuthenticatedGroup && (
{t("settings.auth.groupAuthenticated", "Authenticated")}
- {claudeCliProvider?.authenticated && claudeCliCard} - {cursorCliProvider?.authenticated && cursorCliCard} - {llamaCppProvider?.authenticated && llamaCppCard} - {authenticatedProviders.map((provider) => (
+ {authenticatedProviders.map((provider) => provider.type === "cli" ? renderCliProviderCard(provider) : (
{/* Stable icon wrapper contract for auth card tests: auth-provider-icon- */} @@ -193,10 +211,7 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
)} {showAvailableGroup && (
{t("settings.auth.groupAvailable", "Available")}
- {claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard} - {cursorCliProvider && !cursorCliProvider.authenticated && cursorCliCard} - {llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard} - {unauthenticatedProviders.map((provider) => (
+ {unauthenticatedProviders.map((provider) => provider.type === "cli" ? renderCliProviderCard(provider) : (
{/* Stable icon wrapper contract for auth card tests: auth-provider-icon- */}