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" />, CustomProvidersSection: () => <div data-testid="custom-providers-section" />,
})); }));
vi.mock("../ClaudeCliProviderCard", () => ({ ClaudeCliProviderCard: () => <div /> })); vi.mock("../ClaudeCliProviderCard", () => ({
vi.mock("../CursorCliProviderCard", () => ({ CursorCliProviderCard: () => <div /> })); ClaudeCliProviderCard: ({ authenticated }: { authenticated: boolean }) => (
vi.mock("../LlamaCppProviderCard", () => ({ LlamaCppProviderCard: () => <div /> })); <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> = {}) { function renderAuthSection(providers: AuthProvider[], overrides: Partial<AuthenticationSectionData> = {}) {
const handleLogin = vi.fn(); const handleLogin = vi.fn();
@@ -77,6 +103,77 @@ describe("AuthenticationSection", () => {
vi.clearAllMocks(); 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", () => { it("renders separate Anthropic subscription OAuth and API-key cards", () => {
const { handleLogin, handleSaveApiKey } = renderAuthSection([ const { handleLogin, handleSaveApiKey } = renderAuthSection([
{ id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" }, { id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" },

View File

@@ -44,6 +44,32 @@ export interface AuthenticationSectionData {
export interface AuthenticationSectionProps { export interface AuthenticationSectionProps {
auth: AuthenticationSectionData; 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) { export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
const { t } = useTranslation("app"); 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; 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 const visibleAuthProviders = hasSeparatedAnthropicProvider
? authProviders.filter((p) => p.id !== "anthropic") ? authProviders.filter((p) => p.id !== "anthropic")
: authProviders; : authProviders;
// CLI-backed providers render their own compact card; filter them out of the const isSupportedCliProvider = (provider: AuthProvider) => provider.id === "claude-cli" || provider.id === "cursor-cli" || provider.id === "llama-cpp";
// standard OAuth/API-key sort and render alongside. /*
const cliAuthProviders = visibleAuthProviders.filter((p) => p.type === "cli"); FNXC:ProviderAuth 2026-07-02-12:20:
const nonCliProviders = visibleAuthProviders.filter((p) => p.type !== "cli"); 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 = [...nonCliProviders].sort((a, b) => { */
if (a.authenticated !== b.authenticated) { const sortedProviders = [...visibleAuthProviders]
return a.authenticated ? -1 : 1; .filter((p) => p.type !== "cli" || isSupportedCliProvider(p))
} .sort(compareAuthProviderDisplayOrder);
return a.name.localeCompare(b.name);
});
const authenticatedProviders = sortedProviders.filter((p) => p.authenticated); const authenticatedProviders = sortedProviders.filter((p) => p.authenticated);
const unauthenticatedProviders = sortedProviders.filter((p) => !p.authenticated); const unauthenticatedProviders = sortedProviders.filter((p) => !p.authenticated);
const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-cli"); const renderCliProviderCard = (provider: AuthProvider) => {
const cursorCliProvider = cliAuthProviders.find((p) => p.id === "cursor-cli"); if (provider.id === "claude-cli") {
const llamaCppProvider = cliAuthProviders.find((p) => p.id === "llama-cpp"); return (<ClaudeCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={() => {
const claudeCliCard = claudeCliProvider ? (<ClaudeCliProviderCard compact authenticated={claudeCliProvider.authenticated} onToggled={() => { void loadAuthStatus();
void loadAuthStatus(); }}/>);
}}/>) : null; }
const cursorCliCard = cursorCliProvider ? (<CursorCliProviderCard compact authenticated={cursorCliProvider.authenticated} onToggled={() => { if (provider.id === "cursor-cli") {
void loadAuthStatus(); return (<CursorCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={() => {
}}/>) : null; void loadAuthStatus();
const llamaCppCard = llamaCppProvider ? (<LlamaCppProviderCard compact authenticated={llamaCppProvider.authenticated} onToggled={() => { }}/>);
void loadAuthStatus(); }
}}/>) : null; return (<LlamaCppProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={() => {
const showAuthenticatedGroup = authenticatedProviders.length > 0 || void loadAuthStatus();
(claudeCliProvider?.authenticated ?? false) || }}/>);
(cursorCliProvider?.authenticated ?? false) || };
(llamaCppProvider?.authenticated ?? false); const showAuthenticatedGroup = authenticatedProviders.length > 0;
const showAvailableGroup = unauthenticatedProviders.length > 0 || const showAvailableGroup = unauthenticatedProviders.length > 0;
(claudeCliProvider && !claudeCliProvider.authenticated) ||
(cursorCliProvider && !cursorCliProvider.authenticated) ||
(llamaCppProvider && !llamaCppProvider.authenticated);
const providerSupportsApiKey = (provider: AuthProvider) => provider.type === "api_key"; const providerSupportsApiKey = (provider: AuthProvider) => provider.type === "api_key";
const renderApiKeySection = (provider: AuthProvider) => (<div className="auth-apikey-section"> const renderApiKeySection = (provider: AuthProvider) => (<div className="auth-apikey-section">
<div className="auth-apikey-input-row"> <div className="auth-apikey-input-row">
@@ -170,10 +191,7 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
</div>)} </div>)}
{showAuthenticatedGroup && (<div className="auth-provider-group"> {showAuthenticatedGroup && (<div className="auth-provider-group">
<div className="auth-group-label">{t("settings.auth.groupAuthenticated", "Authenticated")}</div> <div className="auth-group-label">{t("settings.auth.groupAuthenticated", "Authenticated")}</div>
{claudeCliProvider?.authenticated && claudeCliCard} {authenticatedProviders.map((provider) => provider.type === "cli" ? renderCliProviderCard(provider) : (<div key={provider.id} className="auth-provider-card auth-provider-card--authenticated">
{cursorCliProvider?.authenticated && cursorCliCard}
{llamaCppProvider?.authenticated && llamaCppCard}
{authenticatedProviders.map((provider) => (<div key={provider.id} className="auth-provider-card auth-provider-card--authenticated">
<div className="auth-provider-header"> <div className="auth-provider-header">
<div className="auth-provider-info"> <div className="auth-provider-info">
{/* Stable icon wrapper contract for auth card tests: auth-provider-icon-<providerId> */} {/* Stable icon wrapper contract for auth card tests: auth-provider-icon-<providerId> */}
@@ -193,10 +211,7 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
</div>)} </div>)}
{showAvailableGroup && (<div className="auth-provider-group"> {showAvailableGroup && (<div className="auth-provider-group">
<div className="auth-group-label">{t("settings.auth.groupAvailable", "Available")}</div> <div className="auth-group-label">{t("settings.auth.groupAvailable", "Available")}</div>
{claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard} {unauthenticatedProviders.map((provider) => provider.type === "cli" ? renderCliProviderCard(provider) : (<div key={provider.id} className="auth-provider-card">
{cursorCliProvider && !cursorCliProvider.authenticated && cursorCliCard}
{llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard}
{unauthenticatedProviders.map((provider) => (<div key={provider.id} className="auth-provider-card">
<div className="auth-provider-header"> <div className="auth-provider-header">
<div className="auth-provider-info"> <div className="auth-provider-info">
{/* Stable icon wrapper contract for auth card tests: auth-provider-icon-<providerId> */} {/* Stable icon wrapper contract for auth card tests: auth-provider-icon-<providerId> */}