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:
7
.changeset/fn-7436-auth-anthropic-order.md
Normal file
7
.changeset/fn-7436-auth-anthropic-order.md
Normal 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.
|
||||
@@ -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" },
|
||||
|
||||
@@ -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> */}
|
||||
|
||||
Reference in New Issue
Block a user