FN-7436: group Anthropic auth options first

Keep Anthropic authentication options grouped at the top of Settings while preserving auth-state sections.

- Prioritize Claude CLI, Anthropic Subscription, and Anthropic API Key before other providers in the shared card order.
- Render supported CLI and non-CLI auth cards from one sorted list so other CLI providers cannot split Anthropic entries.
- Cover the mixed CLI plus Anthropic subscription/API-key ordering case and add a patch changeset.

Files changed:
 .changeset/fn-7436-auth-anthropic-order.md         |   7 ++
 .../__tests__/AuthenticationSection.test.tsx       | 103 ++++++++++++++++++++-
 .../settings/sections/AuthenticationSection.tsx    |  91 ++++++++++--------
 3 files changed, 160 insertions(+), 41 deletions(-)

Fusion-Task-Id: FN-7436

Fusion-Task-Lineage: 653a0126-ea2b-43a6-a829-dc72dc47bfde

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 12:27:04 -07:00
parent e5be9242d7
commit 326c72b1e4
3 changed files with 160 additions and 41 deletions

View File

@@ -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.

View File

@@ -28,9 +28,35 @@ 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 /> }));
vi.mock("../ClaudeCliProviderCard", () => ({
ClaudeCliProviderCard: ({ authenticated }: { authenticated: boolean }) => (
<div data-testid="claude-cli-provider-card" data-authenticated={authenticated ? "true" : "false"} />
),
}));
vi.mock("../CursorCliProviderCard", () => ({
CursorCliProviderCard: ({ authenticated }: { authenticated: boolean }) => (
<div data-testid="cursor-cli-provider-card" data-authenticated={authenticated ? "true" : "false"} />
),
}));
vi.mock("../LlamaCppProviderCard", () => ({
LlamaCppProviderCard: ({ authenticated }: { authenticated: boolean }) => (
<div data-testid="llama-cpp-provider-card" data-authenticated={authenticated ? "true" : "false"} />
),
}));
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<HTMLElement>("[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<AuthenticationSectionData> = {}) {
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" },

View File

@@ -44,6 +44,32 @@ export interface AuthenticationSectionData {
export interface AuthenticationSectionProps {
auth: AuthenticationSectionData;
}
const ANTHROPIC_AUTH_PROVIDER_PRIORITY: Record<string, number> = {
"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 ? (<ClaudeCliProviderCard compact authenticated={claudeCliProvider.authenticated} onToggled={() => {
void loadAuthStatus();
}}/>) : null;
const cursorCliCard = cursorCliProvider ? (<CursorCliProviderCard compact authenticated={cursorCliProvider.authenticated} onToggled={() => {
void loadAuthStatus();
}}/>) : null;
const llamaCppCard = llamaCppProvider ? (<LlamaCppProviderCard compact authenticated={llamaCppProvider.authenticated} onToggled={() => {
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 (<ClaudeCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={() => {
void loadAuthStatus();
}}/>);
}
if (provider.id === "cursor-cli") {
return (<CursorCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={() => {
void loadAuthStatus();
}}/>);
}
return (<LlamaCppProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={() => {
void loadAuthStatus();
}}/>);
};
const showAuthenticatedGroup = authenticatedProviders.length > 0;
const showAvailableGroup = unauthenticatedProviders.length > 0;
const providerSupportsApiKey = (provider: AuthProvider) => provider.type === "api_key";
const renderApiKeySection = (provider: AuthProvider) => (<div className="auth-apikey-section">
<div className="auth-apikey-input-row">
@@ -170,10 +191,7 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
</div>)}
{showAuthenticatedGroup && (<div className="auth-provider-group">
<div className="auth-group-label">{t("settings.auth.groupAuthenticated", "Authenticated")}</div>
{claudeCliProvider?.authenticated && claudeCliCard}
{cursorCliProvider?.authenticated && cursorCliCard}
{llamaCppProvider?.authenticated && llamaCppCard}
{authenticatedProviders.map((provider) => (<div key={provider.id} className="auth-provider-card auth-provider-card--authenticated">
{authenticatedProviders.map((provider) => provider.type === "cli" ? renderCliProviderCard(provider) : (<div key={provider.id} className="auth-provider-card auth-provider-card--authenticated">
<div className="auth-provider-header">
<div className="auth-provider-info">
{/* Stable icon wrapper contract for auth card tests: auth-provider-icon-<providerId> */}
@@ -193,10 +211,7 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
</div>)}
{showAvailableGroup && (<div className="auth-provider-group">
<div className="auth-group-label">{t("settings.auth.groupAvailable", "Available")}</div>
{claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard}
{cursorCliProvider && !cursorCliProvider.authenticated && cursorCliCard}
{llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard}
{unauthenticatedProviders.map((provider) => (<div key={provider.id} className="auth-provider-card">
{unauthenticatedProviders.map((provider) => provider.type === "cli" ? renderCliProviderCard(provider) : (<div key={provider.id} className="auth-provider-card">
<div className="auth-provider-header">
<div className="auth-provider-info">
{/* Stable icon wrapper contract for auth card tests: auth-provider-icon-<providerId> */}