Add support for custom AI providers and refresh the built-in model catalog from settings. - Recognize custom provider configuration in provider status and onboarding readiness. - Add bounded model-catalog refresh endpoints and settings controls with localized copy. - Cover provider discovery, cache refresh, routes, and authentication UI behavior with tests. Files changed: .changeset/fn-019-provider-catalog.md | 7 ++ docs/dashboard-guide.md | 6 ++ .../dashboard/app/__tests__/api-models.test.ts | 29 +++++++++ .../dashboard/app/api/settings/provider-status.ts | 15 +++++ ...thenticationSection.modelsCacheRefresh.test.tsx | 46 +++++++++++++- .../__tests__/AuthenticationSection.test.tsx | 10 +++ .../settings/sections/AuthenticationSection.css | 20 ++++++ .../settings/sections/AuthenticationSection.tsx | 37 ++++++++++- .../app/hooks/__tests__/useAuthOnboarding.test.ts | 29 +++++++++ .../app/hooks/__tests__/useSetupReadiness.test.ts | 14 ++++ packages/dashboard/app/hooks/useAuthOnboarding.ts | 9 +-- packages/dashboard/app/hooks/useSetupReadiness.ts | 8 ++- .../register-model-routes-refresh-bounding.test.ts | 74 ++++++++++++++++++++++ .../dashboard/src/__tests__/routes-auth.test.ts | 18 ++++++ .../dashboard/src/routes/register-auth-routes.ts | 17 ++++- .../dashboard/src/routes/register-model-routes.ts | 22 ++++++- packages/i18n/locales/en/app.json | 6 ++ packages/i18n/locales/es/app.json | 6 ++ packages/i18n/locales/fr/app.json | 6 ++ packages/i18n/locales/ko/app.json | 6 ++ packages/i18n/locales/pt-BR/app.json | 6 ++ packages/i18n/locales/zh-CN/app.json | 6 ++ packages/i18n/locales/zh-TW/app.json | 6 ++ packages/i18n/src/resources.d.ts | 6 ++ 24 files changed, 397 insertions(+), 12 deletions(-) Fusion-Task-Id: FN-019 Fusion-Task-Lineage: 1e9daf0e-8aa8-4205-9fc7-686fbdd5bd35 Co-authored-by: Fusion <noreply@runfusion.ai>
454 lines
34 KiB
TypeScript
454 lines
34 KiB
TypeScript
import { useState } from "react";
|
|
import type { Dispatch, SetStateAction } from "react";
|
|
import { formatProviderInstanceKey, refreshBuiltInModels, removeProviderInstance, renameProviderInstance, setProviderDefaultInstance, newProviderInstanceId } from "../../../api";
|
|
import type { AuthProvider, ManualOAuthCodeInfo, OAuthDeviceCodeInfo, ProviderCredentialInstance } 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 { SettingsHelpTip } from "../SettingsHelpTip";
|
|
import { SettingsSelectRow } from "../SettingsSelectRow";
|
|
import type { SectionBaseProps } from "./context";
|
|
import { copyTextToClipboard } from "../../../utils/copyToClipboard";
|
|
import { appendTokenQuery } from "../../../auth";
|
|
import { openExternalUrl } from "../../../utils/open-external";
|
|
import { refreshModelsCache } from "../../../hooks/useModelsCache";
|
|
import "./AuthenticationSection.css";
|
|
export interface AuthenticationSectionData {
|
|
projectId?: string;
|
|
addToast: (message: string, type?: ToastType) => void;
|
|
authProviders: AuthProvider[];
|
|
authLoading: boolean;
|
|
authActionInProgress: string | null | Record<string, boolean>;
|
|
apiKeyInputs: Record<string, string>;
|
|
setApiKeyInputs: Dispatch<SetStateAction<Record<string, string>>>;
|
|
apiKeyErrors: Record<string, string>;
|
|
opencodeApiKeyRefreshStatus: Record<string, {
|
|
tone: "success" | "error";
|
|
message: string;
|
|
}>;
|
|
deviceCodes: Record<string, OAuthDeviceCodeInfo>;
|
|
loginInstructions: Record<string, string>;
|
|
manualCodeConfigs: Record<string, ManualOAuthCodeInfo>;
|
|
manualCodeInputs: Record<string, string>;
|
|
setManualCodeInputs: Dispatch<SetStateAction<Record<string, string>>>;
|
|
manualCodeSubmitInProgress: string | null;
|
|
/*
|
|
FNXC:ProviderAuth 2026-08-18-06:10:
|
|
stateKey whose login is showing in the persistent ProviderLoginDialog, or null. That dialog
|
|
already renders the instructions and paste field, so the row must not render its own copies —
|
|
two inputs for the same code, one of them behind the dialog.
|
|
*/
|
|
activeLoginDialogKey?: string | null;
|
|
loadAuthStatus: () => void | Promise<void>;
|
|
handleLogin: (providerId: string, instanceId?: string, label?: string) => void;
|
|
handleLogout: (providerId: string, instanceId?: string) => void;
|
|
handleCancelLogin: (providerId: string, instanceId?: string) => void;
|
|
handleSaveApiKey: (providerId: string, instanceId?: string, label?: string) => void;
|
|
handleClearApiKey: (providerId: string, instanceId?: string) => void;
|
|
handleSubmitManualCode: (providerId: string, instanceId?: string) => void | Promise<void>;
|
|
onReopenOnboarding?: () => void;
|
|
}
|
|
export interface AuthenticationSectionProps {
|
|
auth: AuthenticationSectionData;
|
|
/** Shell-owned settings form; used only for the Anthropic credential-precedence row. */
|
|
form?: SectionBaseProps["form"];
|
|
setForm?: SectionBaseProps["setForm"];
|
|
}
|
|
const ANTHROPIC_API_KEY_PROVIDER_ID = "anthropic-api-key";
|
|
const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
|
|
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, form, setForm }: AuthenticationSectionProps) {
|
|
const { t } = useTranslation("app");
|
|
const { projectId, addToast, authProviders, authLoading, authActionInProgress, apiKeyInputs, setApiKeyInputs, apiKeyErrors, opencodeApiKeyRefreshStatus, deviceCodes, loginInstructions, manualCodeConfigs, manualCodeInputs, setManualCodeInputs, manualCodeSubmitInProgress, activeLoginDialogKey, loadAuthStatus, handleLogin, handleLogout, handleCancelLogin, handleSaveApiKey, handleClearApiKey, handleSubmitManualCode, onReopenOnboarding, } = auth;
|
|
const [pendingInstances, setPendingInstances] = useState<Record<string, { instanceId: string; label: string }>>({});
|
|
const [builtInRefreshStatus, setBuiltInRefreshStatus] = useState<"idle" | "pending" | "completed" | "timed_out" | "failed" | "stale_in_flight">("idle");
|
|
const isAuthActionActive = (stateKey: string) => typeof authActionInProgress === "string"
|
|
? authActionInProgress === stateKey
|
|
: Boolean(authActionInProgress?.[stateKey]);
|
|
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();
|
|
};
|
|
/*
|
|
FNXC:BuiltInModelRefresh 2026-08-18-23:11:
|
|
Keep built-in catalog refresh manual and panel-local: disable only this action while its request runs, propagate a completed catalog through the shared models cache, and keep the previous picker rows for every deferred or failed outcome.
|
|
*/
|
|
const handleBuiltInModelsRefresh = async () => {
|
|
if (builtInRefreshStatus === "pending") return;
|
|
setBuiltInRefreshStatus("pending");
|
|
try {
|
|
const result = await refreshBuiltInModels();
|
|
setBuiltInRefreshStatus(result.outcome);
|
|
if (result.outcome === "completed") {
|
|
await refreshModelsCache();
|
|
}
|
|
} catch {
|
|
setBuiltInRefreshStatus("failed");
|
|
}
|
|
};
|
|
const renderCliProviderCard = (provider: AuthProvider) => {
|
|
if (provider.id === "claude-cli") {
|
|
return (<ClaudeCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={handleCliProviderToggled}/>);
|
|
}
|
|
if (provider.id === "cursor-cli") {
|
|
return (<CursorCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={handleCliProviderToggled}/>);
|
|
}
|
|
if (provider.id === "grok-cli") {
|
|
return (<GrokCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={handleCliProviderToggled}/>);
|
|
}
|
|
if (provider.id === "omp-cli") {
|
|
return (<OmpCliProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={handleCliProviderToggled}/>);
|
|
}
|
|
return (<LlamaCppProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={handleCliProviderToggled}/>);
|
|
};
|
|
/*
|
|
FNXC:ProviderAuth 2026-07-24-17:05:
|
|
Anthropic is the one provider an operator can hold two live credentials for at once — a
|
|
raw API key AND a Claude subscription OAuth login. Runtime auth has to pick one, and the
|
|
choice was previously invisible: a saved key silently won, so an operator who logged in
|
|
with their subscription but still had a stale/revoked key stored got `401 invalid
|
|
x-api-key` from lanes that call the Anthropic endpoint directly, with nothing on this
|
|
screen explaining why. Surface the conflict where the credentials are managed and let the
|
|
operator pick. The row renders only when BOTH are actually connected — with one credential
|
|
there is nothing to disambiguate and the control would be noise.
|
|
*/
|
|
const anthropicApiKeyConnected = authProviders.some((p) => p.id === ANTHROPIC_API_KEY_PROVIDER_ID && p.authenticated);
|
|
const anthropicSubscriptionConnected = authProviders.some((p) => p.id === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID && p.authenticated);
|
|
const showAnthropicPrecedence = Boolean(form && setForm) && anthropicApiKeyConnected && anthropicSubscriptionConnected;
|
|
const anthropicAuthPreference = form?.anthropicAuthPreference === "subscription" ? "subscription" : "api-key";
|
|
const preferenceIsInEffect = (providerId: string) => showAnthropicPrecedence
|
|
&& (anthropicAuthPreference === "subscription"
|
|
? providerId === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID
|
|
: providerId === ANTHROPIC_API_KEY_PROVIDER_ID);
|
|
/*
|
|
Live state, not description: which of two connected credentials the engine will actually
|
|
send. Rendered on the card itself so the answer is where the operator is looking.
|
|
*/
|
|
const renderAnthropicPrecedenceBadge = (provider: AuthProvider) => {
|
|
if (!showAnthropicPrecedence) {
|
|
return null;
|
|
}
|
|
if (provider.id !== ANTHROPIC_API_KEY_PROVIDER_ID && provider.id !== ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
|
|
return null;
|
|
}
|
|
return preferenceIsInEffect(provider.id)
|
|
? (<span className="auth-status-badge authenticated" data-testid={`auth-precedence-active-${provider.id}`}>
|
|
{t("settings.auth.credentialInUse", "In use")}
|
|
</span>)
|
|
: (<span className="auth-key-hint" data-testid={`auth-precedence-overridden-${provider.id}`}>
|
|
{t("settings.auth.credentialOverridden", "Overridden below")}
|
|
</span>);
|
|
};
|
|
const renderAnthropicPrecedenceRow = () => showAnthropicPrecedence
|
|
? (<SettingsSelectRow descriptor={{
|
|
key: "anthropicAuthPreference",
|
|
label: t("settings.auth.anthropicPreferenceLabel", "Anthropic credential to use"),
|
|
help: t("settings.auth.anthropicPreferenceHint", "You have both an Anthropic API key and a Claude subscription connected. Choose which one Fusion sends when a lane calls Anthropic directly. Default: API key."),
|
|
scope: "global",
|
|
options: [
|
|
{ value: "api-key", label: t("settings.auth.anthropicPreferenceApiKey", "API key") },
|
|
{ value: "subscription", label: t("settings.auth.anthropicPreferenceSubscription", "Claude subscription") },
|
|
],
|
|
}} value={anthropicAuthPreference} onChange={(value) => setForm?.((f) => ({
|
|
...f,
|
|
anthropicAuthPreference: value === "subscription" ? "subscription" : "api-key",
|
|
}))}/>)
|
|
: null;
|
|
const showAuthenticatedGroup = authenticatedProviders.length > 0;
|
|
const showAvailableGroup = unauthenticatedProviders.length > 0;
|
|
const providerSupportsApiKey = (provider: AuthProvider) => provider.type === "api_key";
|
|
/*
|
|
FNXC:ProviderAuth 2026-08-01-06:25:
|
|
Keep single and empty cards free of instance-list chrome. Extra account controls appear only
|
|
after a second credential exists; client-generated ids are opaque and labels remain display-only.
|
|
*/
|
|
const hasMultipleInstances = (provider: AuthProvider) => (provider.instances?.length ?? 0) > 1;
|
|
const instanceProvider = (provider: AuthProvider, instance: ProviderCredentialInstance): AuthProvider => ({
|
|
...provider,
|
|
authenticated: instance.authenticated,
|
|
expired: instance.expired,
|
|
keyHint: instance.keyHint,
|
|
instanceId: instance.instanceId,
|
|
type: instance.type ?? provider.type,
|
|
// A provider-level status can only say that some account is logging in; a row is active
|
|
// only when its instance-keyed local state says so.
|
|
loginInProgress: false,
|
|
});
|
|
/*
|
|
FNXC:ProviderAuth 2026-08-01-06:57:
|
|
Multi-account cards render credential actions inside their matching instance row. This prevents
|
|
a legacy provider-level Save, Cancel, or manual-code control from silently targeting default.
|
|
Single and empty cards deliberately retain the existing provider-level markup and behavior.
|
|
*/
|
|
const renderInstanceControls = (provider: AuthProvider) => {
|
|
if (provider.type === "cli") return null;
|
|
const instances = provider.instances ?? [];
|
|
const pending = pendingInstances[provider.id];
|
|
const add = () => setPendingInstances((current) => ({
|
|
...current,
|
|
[provider.id]: { instanceId: newProviderInstanceId(instances.map((item) => item.instanceId)), label: "" },
|
|
}));
|
|
const discard = () => setPendingInstances((current) => {
|
|
const next = { ...current };
|
|
delete next[provider.id];
|
|
return next;
|
|
});
|
|
return <div className="auth-instance-controls">
|
|
{instances.length > 1 && <div className="auth-instance-list" data-testid={`auth-instances-${provider.id}`}>
|
|
{instances.map((item: ProviderCredentialInstance) => {
|
|
const rowProvider = instanceProvider(provider, item);
|
|
return <div className="auth-instance-row" key={item.instanceId}>
|
|
<span>{item.label || item.instanceId}{item.isDefault ? ` (${t("settings.auth.default", "Default")})` : ""}</span>
|
|
{!item.isDefault && <button className="btn btn-sm" onClick={() => void setProviderDefaultInstance(provider.id, item.instanceId).then(loadAuthStatus)}>{t("settings.auth.makeDefault", "Make default")}</button>}
|
|
<button className="btn btn-sm" onClick={() => {
|
|
const label = window.prompt(t("settings.auth.renameAccount", "Account name"), item.label || "");
|
|
if (label !== null) void renameProviderInstance(provider.id, item.instanceId, label).then(loadAuthStatus);
|
|
}}>{t("settings.actions.rename", "Rename")}</button>
|
|
<button className="btn btn-sm" onClick={() => void removeProviderInstance(provider.id, item.instanceId).then(loadAuthStatus)}>{t("settings.actions.remove", "Remove")}</button>
|
|
{providerSupportsApiKey(rowProvider)
|
|
? renderApiKeySection(rowProvider, item.instanceId)
|
|
: item.authenticated
|
|
? renderAuthenticatedOAuthActions(rowProvider, item.instanceId)
|
|
: renderAvailableOAuthActions(rowProvider, item.instanceId)}
|
|
</div>;
|
|
})}
|
|
</div>}
|
|
{pending && <div className="auth-instance-pending" data-testid={`auth-pending-instance-${provider.id}`}>
|
|
{/*
|
|
FNXC:ProviderAuth 2026-08-02-05:27:
|
|
Each pending credential needs a visible Account name label associated with its provider-specific input. The padded field keeps its purpose separate from credential actions at desktop and mobile widths without changing instance identity or save/login payloads.
|
|
*/}
|
|
<div className="auth-pending-instance-field">
|
|
<label className="auth-pending-instance-label" htmlFor={`auth-pending-instance-${provider.id}-label`}>
|
|
{t("settings.auth.accountLabel", "Account name")}
|
|
</label>
|
|
<input id={`auth-pending-instance-${provider.id}-label`} className="input" value={pending.label} onChange={(event) => setPendingInstances((current) => ({ ...current, [provider.id]: { ...pending, label: event.target.value } }))} />
|
|
</div>
|
|
{providerSupportsApiKey(provider)
|
|
? renderApiKeySection(provider, pending.instanceId, pending.label, true)
|
|
: renderAvailableOAuthActions(provider, pending.instanceId, pending.label || undefined)}
|
|
<button className="btn btn-sm" onClick={discard}>{t("settings.actions.cancel", "Cancel")}</button>
|
|
</div>}
|
|
{!pending && <button className="btn btn-sm" onClick={add}>{t("settings.auth.addAnotherAccount", "Add another account")}</button>}
|
|
</div>;
|
|
};
|
|
/*
|
|
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 as the durable re-auth remediation.
|
|
|
|
FNXC:ProviderAuth 2026-08-15-22:08:
|
|
The loginError used to sit inline beside Login as `<small class="form-error">`. On a narrow Settings card that flex item sized to the sentence's min-content width, so the Anthropic expiry copy overflowed the card and the leaked global `.form-error` border painted as a broken per-line box. Keep it a wrapping block under the header so the banner stays inside the card at every Settings width.
|
|
*/
|
|
const renderProviderAuthError = (provider: AuthProvider) => provider.loginError
|
|
? (<p className="auth-provider-login-error" role="alert">{provider.loginError}</p>)
|
|
: null;
|
|
const renderApiKeySection = (provider: AuthProvider, selectedInstanceId?: string, pendingLabel?: string, isPending = false) => {
|
|
const instanceId = selectedInstanceId ?? provider.instanceId;
|
|
const stateKey = formatProviderInstanceKey({ providerId: provider.id, instanceId: instanceId ?? "default" });
|
|
return <div className="auth-apikey-section">
|
|
<div className="auth-apikey-input-row">
|
|
<input type="password" className="auth-apikey-input" placeholder={t("settings.authentication.enterAPIKey", "Enter API key")} value={apiKeyInputs[stateKey] ?? ""} onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [stateKey]: e.target.value }))} disabled={isAuthActionActive(stateKey)}/>
|
|
{provider.keyHint && !isPending && !apiKeyInputs[stateKey] ? <button className="btn btn-sm" onClick={() => instanceId ? handleClearApiKey(provider.id, instanceId) : handleClearApiKey(provider.id)} disabled={isAuthActionActive(stateKey)}>{t("settings.auth.clearKey", "Clear")}</button> : <button className="btn btn-primary btn-sm" onClick={() => instanceId ? handleSaveApiKey(provider.id, instanceId, pendingLabel || undefined) : handleSaveApiKey(provider.id)} disabled={isAuthActionActive(stateKey)}>{t("settings.actions.save", "Save")}</button>}
|
|
</div>
|
|
{isAuthActionActive(stateKey) && <small className="auth-apikey-progress">{t("settings.auth.savingKey", "Saving…")}</small>}
|
|
{apiKeyErrors[stateKey] && <small className="auth-apikey-error">{apiKeyErrors[stateKey]}</small>}
|
|
{(provider.id === "opencode" || provider.id === "opencode-go") && opencodeApiKeyRefreshStatus[stateKey] && <small className={opencodeApiKeyRefreshStatus[stateKey].tone === "error" ? "form-error" : "text-muted"}>{opencodeApiKeyRefreshStatus[stateKey].message}</small>}
|
|
</div>;
|
|
};
|
|
const renderAuthenticatedOAuthActions = (provider: AuthProvider, selectedInstanceId?: string) => {
|
|
const instanceId = selectedInstanceId ?? provider.instanceId;
|
|
const stateKey = formatProviderInstanceKey({ providerId: provider.id, instanceId: instanceId ?? "default" });
|
|
return <div>
|
|
{isAuthActionActive(stateKey) ? <button className="btn btn-sm" disabled>{t("settings.auth.loggingOut", "Logging out…")}</button>
|
|
: provider.loginInProgress ? <div className="auth-provider-actions-row"><button className="btn btn-sm" disabled>{t("settings.auth.waitingForLogin", "Waiting for login…")}</button><button className="btn btn-sm" onClick={() => instanceId ? handleCancelLogin(provider.id, instanceId) : handleCancelLogin(provider.id)}>{t("settings.actions.cancel", "Cancel")}</button></div>
|
|
: <button className="btn btn-sm" onClick={() => instanceId ? handleLogout(provider.id, instanceId) : handleLogout(provider.id)}>{t("settings.auth.logout", "Logout")}</button>}
|
|
</div>;
|
|
};
|
|
const renderAvailableOAuthActions = (provider: AuthProvider, selectedInstanceId?: string, pendingLabel?: string) => {
|
|
const instanceId = selectedInstanceId ?? provider.instanceId;
|
|
const stateKey = formatProviderInstanceKey({ providerId: provider.id, instanceId: instanceId ?? "default" });
|
|
const isActive = provider.loginInProgress || isAuthActionActive(stateKey);
|
|
return <div>
|
|
{isAuthActionActive(stateKey) ? <div className="auth-provider-actions-row"><button className="btn btn-sm" disabled>{t("settings.auth.waitingForLogin", "Waiting for login…")}</button><button className="btn btn-sm" onClick={() => instanceId ? handleCancelLogin(provider.id, instanceId) : handleCancelLogin(provider.id)}>{t("settings.actions.cancel", "Cancel")}</button></div>
|
|
: provider.loginInProgress ? <div className="auth-provider-actions-row"><button className="btn btn-sm" disabled>{t("settings.auth.waitingForLogin", "Waiting for login…")}</button><button className="btn btn-sm" onClick={() => instanceId ? handleCancelLogin(provider.id, instanceId) : handleCancelLogin(provider.id)}>{t("settings.actions.cancel", "Cancel")}</button></div>
|
|
: <button className="btn btn-primary btn-sm" onClick={() => instanceId ? handleLogin(provider.id, instanceId, pendingLabel) : handleLogin(provider.id)}>{t("settings.auth.login", "Login")}</button>}
|
|
{provider.id === "github-copilot" && deviceCodes[stateKey] && isActive && <div className="auth-device-code-panel" data-testid={`auth-device-code-${stateKey}`}>
|
|
<strong>{t("settings.auth.enterCodeOnGitHub", "Enter this code on GitHub")}</strong>
|
|
<div className="auth-device-code-pill">{deviceCodes[stateKey].userCode}</div>
|
|
<div className="auth-provider-actions-row">
|
|
<button className="btn btn-sm" onClick={() => void copyTextToClipboard(deviceCodes[stateKey].userCode).then((copied) => addToast(copied ? t("settings.auth.copiedCodeToClipboard", "Copied code to clipboard") : t("settings.auth.failedToCopyCode", "Failed to copy code — copy it manually from the box above"), copied ? "success" : "error"))}>{t("settings.auth.copyCode", "Copy code")}</button>
|
|
<button className="btn btn-sm" onClick={() => openExternalUrl(appendTokenQuery(deviceCodes[stateKey].verificationUri))}>{t("settings.auth.openGitHub", "Open GitHub")}</button>
|
|
</div>
|
|
</div>}
|
|
{loginInstructions[stateKey] && isActive && activeLoginDialogKey !== stateKey && <LoginInstructions instructions={loginInstructions[stateKey]} data-testid={`auth-login-instructions-${stateKey}`}/>}
|
|
{manualCodeConfigs[stateKey] && isActive && activeLoginDialogKey !== stateKey && <OAuthManualCodeForm value={manualCodeInputs[stateKey] ?? ""} onChange={(value) => setManualCodeInputs((prev) => ({ ...prev, [stateKey]: value }))} onSubmit={() => void handleSubmitManualCode(provider.id, instanceId)} prompt={manualCodeConfigs[stateKey].prompt} placeholder={manualCodeConfigs[stateKey].placeholder} helpText={manualCodeConfigs[stateKey].helpText} disabled={manualCodeSubmitInProgress === stateKey} submitLabel={manualCodeSubmitInProgress === stateKey ? "Submitting…" : "Submit code"} data-testid={`auth-manual-code-${stateKey}`}/>}
|
|
</div>;
|
|
};
|
|
/*
|
|
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 (<>
|
|
{/* FNXC:SettingsHelp 2026-07-16-12:45: Inline help moved behind the shared "?" affordance — operator requirement: no inline description paragraphs in Settings. The panel-level "changes take effect immediately" blurb now hangs off the section heading. */}
|
|
<div className="settings-field-label-row">
|
|
<h4 className="settings-section-heading">{t("settings.auth.title", "Authentication")}</h4>
|
|
<SettingsHelpTip settingKey="auth-section">{t("settings.auth.hint", "Authentication changes take effect immediately — no need to save.")}</SettingsHelpTip>
|
|
</div>
|
|
<div className="auth-model-refresh" data-testid="built-in-model-refresh">
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
onClick={() => void handleBuiltInModelsRefresh()}
|
|
disabled={builtInRefreshStatus === "pending"}
|
|
aria-busy={builtInRefreshStatus === "pending"}
|
|
>
|
|
{builtInRefreshStatus === "pending"
|
|
? t("settings.auth.refreshModelsPending", "Refreshing models…")
|
|
: t("settings.auth.refreshModels", "Refresh Models")}
|
|
</button>
|
|
{builtInRefreshStatus === "completed" && <span className="auth-model-refresh-feedback auth-model-refresh-feedback--success" role="status">{t("settings.auth.refreshModelsSuccess", "Models refreshed.")}</span>}
|
|
{builtInRefreshStatus === "timed_out" && <span className="auth-model-refresh-feedback auth-model-refresh-feedback--warning" role="status">{t("settings.auth.refreshModelsTimedOut", "Refresh timed out; showing the last available models. Try again.")}</span>}
|
|
{builtInRefreshStatus === "stale_in_flight" && <span className="auth-model-refresh-feedback auth-model-refresh-feedback--warning" role="status">{t("settings.auth.refreshModelsDeferred", "Another refresh is still running; showing the last available models. Try again shortly.")}</span>}
|
|
{builtInRefreshStatus === "failed" && <span className="auth-model-refresh-feedback auth-model-refresh-feedback--error" role="alert">{t("settings.auth.refreshModelsFailed", "Refresh failed; showing the last available models. Try again.")}</span>}
|
|
</div>
|
|
{authLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.auth.loadingStatus", "Loading authentication status…")} /></div>) : authProviders.length === 0 ? (<div className="settings-empty-state settings-muted">
|
|
{t("settings.auth.noProviders", "No providers available")}
|
|
</div>) : (<div className="auth-panel-body">
|
|
<PluginSlot slotId="settings-provider-card" projectId={projectId} renderPlaceholder={false} actions={{ refreshAuthProviders: () => { void loadAuthStatus(); } }}/>
|
|
<PluginSlot slotId="settings-integration-card" projectId={projectId} renderPlaceholder={false} actions={{ refreshAuthProviders: () => { void loadAuthStatus(); } }}/>
|
|
{!showAuthenticatedGroup && (<div className="auth-section-hint">
|
|
{t("settings.auth.signInHint", "Sign in to at least one provider to get started with AI models.")}
|
|
</div>)}
|
|
{showAuthenticatedGroup && (<div className="auth-provider-group">
|
|
<div className="auth-group-label">{t("settings.auth.groupAuthenticated", "Authenticated")}</div>
|
|
{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> */}
|
|
<span className="auth-provider-icon-slot" data-testid={`auth-provider-icon-${provider.id}`} aria-hidden="true">
|
|
<ProviderIcon provider={provider.id} size="md"/>
|
|
</span>
|
|
<strong>{provider.name}</strong>
|
|
<span data-testid={`auth-status-${provider.id}`} className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}>
|
|
{t("settings.auth.statusActive", "✓ Active")}
|
|
</span>
|
|
{renderAnthropicPrecedenceBadge(provider)}
|
|
{provider.authenticated && provider.keyHint && (<span className="auth-key-hint">{t("settings.authentication.key", "Key: ")}{provider.keyHint}</span>)}
|
|
</div>
|
|
{provider.type !== "api_key" && !hasMultipleInstances(provider) && <div className="auth-provider-actions">{renderAuthenticatedOAuthActions(provider)}</div>}
|
|
{providerSupportsApiKey(provider) && !hasMultipleInstances(provider) && renderApiKeySection(provider)}
|
|
</div>
|
|
{provider.type !== "api_key" && !hasMultipleInstances(provider) && renderProviderAuthError(provider)}
|
|
{renderInstanceControls(provider)}
|
|
</div>))}
|
|
{renderAnthropicPrecedenceRow()}
|
|
</div>)}
|
|
{showAvailableGroup && (<div className="auth-provider-group">
|
|
<div className="auth-group-label">{t("settings.auth.groupAvailable", "Available")}</div>
|
|
{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> */}
|
|
<span className="auth-provider-icon-slot" data-testid={`auth-provider-icon-${provider.id}`} aria-hidden="true">
|
|
<ProviderIcon provider={provider.id} size="md"/>
|
|
</span>
|
|
<strong>{provider.name}</strong>
|
|
<span data-testid={`auth-status-${provider.id}`} className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}>
|
|
{t("settings.auth.statusNotConnected", "✗ Not connected")}
|
|
</span>
|
|
{provider.keyHint && (<span className="auth-key-hint">{t("settings.authentication.key", "Key: ")}{provider.keyHint}</span>)}
|
|
</div>
|
|
{provider.type !== "api_key" && !hasMultipleInstances(provider) && <div className="auth-provider-actions">{renderAvailableOAuthActions(provider)}</div>}
|
|
{providerSupportsApiKey(provider) && !hasMultipleInstances(provider) && renderApiKeySection(provider)}
|
|
</div>
|
|
{provider.type !== "api_key" && !hasMultipleInstances(provider) && renderProviderAuthError(provider)}
|
|
{renderInstanceControls(provider)}
|
|
</div>))}
|
|
</div>)}
|
|
</div>)}
|
|
{/*
|
|
FNXC:SettingsHelp 2026-07-16-12:45:
|
|
Save-progress, API-key, and OpenCode refresh `<small>`s stay inline on the control they describe. Provider loginError is a wrapping block banner under the card header so a long OAuth expiry message cannot overflow the card. The two DESCRIPTIVE blurbs this section carried — the panel-level "changes take effect immediately" hint and the reopen-onboarding hint — moved behind the shared "?" affordance per the operator requirement that no inline description paragraphs remain in Settings.
|
|
*/}
|
|
{onReopenOnboarding && (<div className="form-group" style={{ marginTop: "var(--space-md)" }}>
|
|
<div className="settings-field-label-row">
|
|
<button type="button" className="btn btn-sm" onClick={onReopenOnboarding}>
|
|
{t("settings.auth.reopenOnboarding", "Reopen onboarding guide")}
|
|
</button>
|
|
<SettingsHelpTip settingKey="reopen-onboarding">
|
|
{t("settings.auth.reopenOnboardingHint", "Re-run the setup wizard to review or update your AI provider and model configuration.")}
|
|
</SettingsHelpTip>
|
|
</div>
|
|
</div>)}
|
|
|
|
<CustomProvidersSection />
|
|
</>);
|
|
}
|
|
export default AuthenticationSection;
|