import type { Dispatch, SetStateAction } from "react"; import type { AuthProvider, ManualOAuthCodeInfo, OAuthDeviceCodeInfo } from "../../../api"; import type { ToastType } from "../../../hooks/useToast"; import { useTranslation } from "react-i18next"; import { ClaudeCliProviderCard } from "../../ClaudeCliProviderCard"; import { CursorCliProviderCard } from "../../CursorCliProviderCard"; import { GrokCliProviderCard } from "../../GrokCliProviderCard"; import { OmpCliProviderCard } from "../../OmpCliProviderCard"; import { LlamaCppProviderCard } from "../../LlamaCppProviderCard"; import { ProviderIcon } from "../../ProviderIcon"; import { PluginSlot } from "../../PluginSlot"; import { LoginInstructions } from "../../LoginInstructions"; import { LoadingSpinner } from "../../LoadingSpinner"; import { OAuthManualCodeForm } from "../../OAuthManualCodeForm"; import { CustomProvidersSection } from "../../CustomProvidersSection"; import { copyTextToClipboard } from "../../../utils/copyToClipboard"; import { appendTokenQuery } from "../../../auth"; import { refreshModelsCache } from "../../../hooks/useModelsCache"; export interface AuthenticationSectionData { projectId?: string; addToast: (message: string, type?: ToastType) => void; authProviders: AuthProvider[]; authLoading: boolean; authActionInProgress: string | null; apiKeyInputs: Record; setApiKeyInputs: Dispatch>>; apiKeyErrors: Record; opencodeApiKeyRefreshStatus: Record; deviceCodes: Record; loginInstructions: Record; manualCodeConfigs: Record; manualCodeInputs: Record; setManualCodeInputs: Dispatch>>; manualCodeSubmitInProgress: string | null; loadAuthStatus: () => void | Promise; handleLogin: (providerId: string) => void; handleLogout: (providerId: string) => void; handleCancelLogin: (providerId: string) => void; handleSaveApiKey: (providerId: string) => void; handleClearApiKey: (providerId: string) => void; handleSubmitManualCode: (providerId: string) => void | Promise; onReopenOnboarding?: () => void; } 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; const hasSeparatedAnthropicProvider = authProviders.some((p) => p.id === "anthropic-subscription" || p.id === "anthropic-api-key"); /* FNXC:ProviderAuth 2026-06-29-23:50: Settings must render Anthropic subscription OAuth and raw Anthropic API-key auth as separate cards; when a mixed/legacy status payload includes the old `anthropic` OAuth id alongside separated cards, hide the legacy card so users never see two OAuth-looking Anthropic entries or a resurrected dual-card surface. */ const visibleAuthProviders = hasSeparatedAnthropicProvider ? authProviders.filter((p) => p.id !== "anthropic") : authProviders; // FNXC:OmpAcp 2026-07-13-22:50: include omp-cli among supported CLI auth cards. const isSupportedCliProvider = (provider: AuthProvider) => provider.id === "claude-cli" || provider.id === "cursor-cli" || provider.id === "grok-cli" || provider.id === "omp-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); /* FNXC:ModelCatalog 2026-07-08-00:00: FN-7710: A CLI provider toggle (Cursor, Grok, Claude CLI, llama.cpp) must refresh the shared model catalog so newly-enabled/disabled `*-cli` models appear in — or disappear from — every live picker (Quick Entry, Task Detail, New Agent, Workflow editor, etc.) without the user needing to navigate to Settings. `onToggled` previously only called `loadAuthStatus()`, which refreshes this panel's own provider list but never touches the shared `useModelsCache()` cache other pickers read from. All four CLI cards share this one `onToggled` handler so the fix applies uniformly — no per-card duplication — and both the enable and disable transitions call it (the cards invoke `onToggled` on every toggle result). */ const handleCliProviderToggled = () => { void loadAuthStatus(); void refreshModelsCache(); }; const renderCliProviderCard = (provider: AuthProvider) => { if (provider.id === "claude-cli") { return (); } if (provider.id === "cursor-cli") { return (); } if (provider.id === "grok-cli") { return (); } if (provider.id === "omp-cli") { return (); } return (); }; const showAuthenticatedGroup = authenticatedProviders.length > 0; const showAvailableGroup = unauthenticatedProviders.length > 0; const providerSupportsApiKey = (provider: AuthProvider) => provider.type === "api_key"; /* FNXC:ProviderAuth 2026-07-14-15:54: Provider authentication failures must remain visible on the affected card. Toasts are transient and can fire while Settings is closed, so render the server's loginError beside the provider actions as the durable re-auth remediation. */ const renderProviderAuthError = (provider: AuthProvider) => provider.loginError ? ({provider.loginError}) : null; const renderApiKeySection = (provider: AuthProvider) => (
setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} disabled={authActionInProgress === provider.id}/> {provider.keyHint && !apiKeyInputs[provider.id] ? () : ()}
{authActionInProgress === provider.id && ({t("settings.auth.savingKey", "Saving…")})} {apiKeyErrors[provider.id] && ({apiKeyErrors[provider.id]})} {(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[provider.id] && ( {opencodeApiKeyRefreshStatus[provider.id].message} )}
); const renderAuthenticatedOAuthActions = (provider: AuthProvider) => (
{authActionInProgress === provider.id ? () : provider.loginInProgress ? (
) : ()}
); const renderAvailableOAuthActions = (provider: AuthProvider) => (
{authActionInProgress === provider.id ? () : provider.loginInProgress ? (
) : ()} {provider.id === "github-copilot" && deviceCodes[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
{t("settings.auth.enterCodeOnGitHub", "Enter this code on GitHub")}
{deviceCodes[provider.id].userCode}
)} {loginInstructions[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ()} {manualCodeConfigs[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && ( setManualCodeInputs((prev) => ({ ...prev, [provider.id]: value }))} onSubmit={() => void handleSubmitManualCode(provider.id)} prompt={manualCodeConfigs[provider.id].prompt} placeholder={manualCodeConfigs[provider.id].placeholder} helpText={manualCodeConfigs[provider.id].helpText} disabled={manualCodeSubmitInProgress === provider.id} submitLabel={manualCodeSubmitInProgress === provider.id ? "Submitting…" : "Submit code"} data-testid={`auth-manual-code-${provider.id}`}/>)}
); /* FNXC:ProviderAuth 2026-06-29-22:18: Settings must render Anthropic subscription OAuth and raw Anthropic API-key auth as separate provider cards. Only `type: "api_key"` cards show key controls so OAuth logout never looks like it will clear `ANTHROPIC_API_KEY`. */ return (<>

{t("settings.auth.title", "Authentication")}

{authLoading ? (
) : authProviders.length === 0 ? (
{t("settings.auth.noProviders", "No providers available")}
) : (
{ void loadAuthStatus(); } }}/> { void loadAuthStatus(); } }}/> {!showAuthenticatedGroup && (
{t("settings.auth.signInHint", "Sign in to at least one provider to get started with AI models.")}
)} {showAuthenticatedGroup && (
{t("settings.auth.groupAuthenticated", "Authenticated")}
{authenticatedProviders.map((provider) => provider.type === "cli" ? renderCliProviderCard(provider) : (
{/* Stable icon wrapper contract for auth card tests: auth-provider-icon- */} {provider.name} {t("settings.auth.statusActive", "✓ Active")} {provider.authenticated && provider.keyHint && ({t("settings.authentication.key", "Key: ")}{provider.keyHint})}
{provider.type !== "api_key" &&
{renderAuthenticatedOAuthActions(provider)}{renderProviderAuthError(provider)}
} {providerSupportsApiKey(provider) && renderApiKeySection(provider)}
))}
)} {showAvailableGroup && (
{t("settings.auth.groupAvailable", "Available")}
{unauthenticatedProviders.map((provider) => provider.type === "cli" ? renderCliProviderCard(provider) : (
{/* Stable icon wrapper contract for auth card tests: auth-provider-icon- */} {provider.name} {t("settings.auth.statusNotConnected", "✗ Not connected")} {provider.keyHint && ({t("settings.authentication.key", "Key: ")}{provider.keyHint})}
{provider.type !== "api_key" &&
{renderAvailableOAuthActions(provider)}{renderProviderAuthError(provider)}
} {providerSupportsApiKey(provider) && renderApiKeySection(provider)}
))}
)}
)} {t("settings.auth.hint", "Authentication changes take effect immediately — no need to save.")} {onReopenOnboarding && (
{t("settings.auth.reopenOnboardingHint", "Re-run the setup wizard to review or update your AI provider and model configuration.")}
)} ); } export default AuthenticationSection;