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" />,
|
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" },
|
||||||
|
|||||||
@@ -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> */}
|
||||||
|
|||||||
Reference in New Issue
Block a user