FN-7435: suppress misleading Anthropic re-login banner

Suppress the global Anthropic Subscription re-login warning when another Anthropic auth path can run agents.

- Hide expired Anthropic Subscription OAuth from the urgent dashboard banner when Anthropic API Key or Claude CLI auth is active.
- Preserve unrelated expired OAuth provider warnings and keep Settings documentation clear that Subscription remains expired until re-login.
- Add regression coverage for CLI, API-key, fallback-missing, and unrelated-provider banner visibility cases.
- Add a patch changeset for the user-facing banner fix.

Files changed:
 .changeset/fn-7435-anthropic-subscription-banner.md       |  7 +++
 docs/dashboard-guide.md                            |  2 +-
 docs/settings-reference.md                         |  4 +-
 packages/dashboard/app/components/OAuthReloginBanner.tsx          | 31 ++++++++++--
 packages/dashboard/app/components/__tests__/OAuthReloginBanner.test.tsx          | 59 ++++++++++++++++++++--
 5 files changed, 91 insertions(+), 12 deletions(-)

Fusion-Task-Id: FN-7435

Fusion-Task-Lineage: 04462f77-a1e4-402d-97b2-dd47778398df

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 12:10:13 -07:00
parent b493a1e9e5
commit e5be9242d7
5 changed files with 91 additions and 12 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Suppress misleading Anthropic Subscription re-login banners when another Anthropic auth method is active.
category: fix
dev: Keeps subscription OAuth expired in Settings while hiding only the global urgent banner entry when API key or Claude CLI auth is active.

View File

@@ -634,7 +634,7 @@ For Claude/Anthropic OAuth credentials, the same `/auth/status` poll also attemp
If the OAuth credential has no refresh token, the refresh request fails, or the provider is not Anthropic, the provider stays expired and the banner remains visible. Re-authenticate with manual re-login from **Settings → Authentication** or Model Onboarding.
Anthropic also supports a raw `ANTHROPIC_API_KEY` from a separate **Anthropic API Key** card in **Settings → Authentication** and Model Onboarding. Claude subscription OAuth remains on the **Anthropic Subscription** card for auth status, usage/subscription checks, and banner clearing; it also drives direct agent execution on the `anthropic` provider — a subscription/OAuth token runs `anthropic/*` selections against `https://api.anthropic.com/v1` with Claude Code identity headers, no API key required. CLI-backed execution remains the distinct, explicit **Claude CLI** provider (`pi-claude-cli`); subscription OAuth does not require it. A configured API key takes precedence over OAuth on the direct provider. Saving or clearing an API key does not affect the OAuth sign-in path or turn OAuth tokens into raw API-key material. The dashboard only displays masked key hints after a key is saved.
Anthropic also supports a raw `ANTHROPIC_API_KEY` from a separate **Anthropic API Key** card in **Settings → Authentication** and Model Onboarding. Claude subscription OAuth remains on the **Anthropic Subscription** card for auth status, usage/subscription checks, and banner clearing; it also drives direct agent execution on the `anthropic` provider — a subscription/OAuth token runs `anthropic/*` selections against `https://api.anthropic.com/v1` with Claude Code identity headers, no API key required. CLI-backed execution remains the distinct, explicit **Claude CLI** provider (`pi-claude-cli`); subscription OAuth does not require it. When Anthropic Subscription is expired but Anthropic API Key or Anthropic — via Claude CLI is already authenticated, the global banner suppresses only the urgent subscription re-login entry so it does not imply agents are blocked; Settings still shows the subscription OAuth card as expired/not connected and re-login remains available. A configured API key takes precedence over OAuth on the direct provider. Saving or clearing an API key does not affect the OAuth sign-in path or turn OAuth tokens into raw API-key material. The dashboard only displays masked key hints after a key is saved.
## Smart Pull

View File

@@ -741,13 +741,13 @@ Recovery entrypoints in the dashboard:
Fusion automatically refreshes Claude/Anthropic OAuth credentials before reporting auth status when the stored OAuth credential includes a refresh token and the access token is expired or within the refresh buffer. For Anthropic, this status path is the `anthropic-subscription` surface (including legacy `anthropic` OAuth rows), not Claude CLI state. A successful refresh updates auth storage and prevents `oauth-token-expired` notifications or startup warnings for that provider, so users usually do not need manual re-login after the initial Claude OAuth login.
Manual re-login is still required when no refresh token is stored, the refresh request fails, or the expired OAuth credential belongs to a non-Anthropic provider. In those cases the credential remains expired, `oauth-token-expired` notifications/startup warnings may fire subject to their 12-hour provider throttle, and users should re-authenticate from **Settings → Authentication** or Model Onboarding.
Manual re-login is still required when no refresh token is stored, the refresh request fails, or the expired OAuth credential belongs to a non-Anthropic provider. In those cases the credential remains expired, `oauth-token-expired` notifications/startup warnings may fire subject to their 12-hour provider throttle, and users should re-authenticate from **Settings → Authentication** or Model Onboarding. The top-level dashboard re-login banner suppresses only the urgent **Anthropic Subscription** entry when **Anthropic API Key** or **Anthropic — via Claude CLI** is already authenticated, so the banner does not imply all Anthropic agent execution is blocked; Settings still shows the subscription OAuth state as expired/not connected until it is refreshed or re-logged-in.
### Anthropic API-key authentication
Anthropic has three independent authentication/routing paths:
- **Anthropic Subscription** (`anthropic-subscription`) is Claude subscription OAuth. It powers login/logout, `/api/auth/status`, usage/subscription checks through `https://api.anthropic.com/api/oauth/usage`, and the OAuth re-login banner. Legacy `anthropic` OAuth rows are treated as this subscription surface. It is **also an execution surface**: subscription OAuth resolves for the direct `anthropic` provider at runtime, so `anthropic/*` selections run on `https://api.anthropic.com/v1` with Claude Code identity headers (Bearer OAuth, no API key required), and `anthropic/*` rows appear in the model picker when subscription OAuth is connected.
- **Anthropic Subscription** (`anthropic-subscription`) is Claude subscription OAuth. It powers login/logout, `/api/auth/status`, usage/subscription checks through `https://api.anthropic.com/api/oauth/usage`, and the OAuth re-login banner when no authenticated Anthropic API-key/CLI fallback is present. Legacy `anthropic` OAuth rows are treated as this subscription surface. It is **also an execution surface**: subscription OAuth resolves for the direct `anthropic` provider at runtime, so `anthropic/*` selections run on `https://api.anthropic.com/v1` with Claude Code identity headers (Bearer OAuth, no API key required), and `anthropic/*` rows appear in the model picker when subscription OAuth is connected.
- **Claude CLI** (`pi-claude-cli`) is the CLI-backed execution provider. Use it when you want sessions to run through the local `claude` CLI; CLI availability does not prove the subscription OAuth status is valid. It is a separate, explicit choice — subscription OAuth does not require or reroute to it. When Claude CLI is enabled, model selectors show the registered `pi-claude-cli/*` rows (for example `pi-claude-cli/claude-sonnet-5`) in addition to the direct `anthropic/*` rows.
- **Anthropic API Key** (`anthropic` direct `/v1`) is raw API-key auth. It accepts `ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential, uses `x-api-key`, and takes precedence over subscription OAuth when both are configured for the direct `https://api.anthropic.com/v1` provider.

View File

@@ -1,11 +1,34 @@
import { useCallback, useEffect, useMemo, useState, type JSX } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, X } from "lucide-react";
import { fetchAuthStatus } from "../api";
import { fetchAuthStatus, type AuthProvider } from "../api";
import { OAUTH_RELOGIN_SUCCESS_EVENT } from "../auth";
import "./OAuthReloginBanner.css";
const DISMISS_STORAGE_KEY = "fusion:oauth-relogin-dismissed";
const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
const ANTHROPIC_FALLBACK_PROVIDER_IDS = new Set(["anthropic-api-key", "claude-cli"]);
type ExpiredBannerProvider = { id: string; name: string };
function isAuthenticatedAnthropicFallback(provider: AuthProvider): boolean {
return ANTHROPIC_FALLBACK_PROVIDER_IDS.has(provider.id) && provider.authenticated === true;
}
function getVisibleExpiredOAuthProvidersForGlobalBanner(providers: AuthProvider[]): ExpiredBannerProvider[] {
const hasAuthenticatedAnthropicFallback = providers.some(isAuthenticatedAnthropicFallback);
return providers
.filter((provider) => provider.type === "oauth" && provider.expired === true)
.filter((provider) => {
/*
FNXC:ProviderAuth 2026-07-02-12:00:
Active Anthropic API-key or Claude CLI auth suppresses only the global urgent Subscription OAuth banner. Settings must still show `anthropic-subscription` as expired/not connected, and CLI/API-key availability must never mark subscription OAuth healthy.
*/
return !(provider.id === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID && hasAuthenticatedAnthropicFallback);
})
.map((provider) => ({ id: provider.id, name: provider.name }));
}
function loadDismissedProviderIds(): Set<string> {
if (typeof window === "undefined") {
@@ -37,15 +60,13 @@ export function OAuthReloginBanner({
pollIntervalMs?: number;
}): JSX.Element | null {
const { t } = useTranslation("app");
const [expiredProviders, setExpiredProviders] = useState<Array<{ id: string; name: string }>>([]);
const [expiredProviders, setExpiredProviders] = useState<ExpiredBannerProvider[]>([]);
const [dismissedProviderIds, setDismissedProviderIds] = useState<Set<string>>(() => loadDismissedProviderIds());
const refreshAuthStatus = useCallback(async () => {
try {
const { providers } = await fetchAuthStatus();
const nextExpiredProviders = providers
.filter((provider) => provider.type === "oauth" && provider.expired === true)
.map((provider) => ({ id: provider.id, name: provider.name }));
const nextExpiredProviders = getVisibleExpiredOAuthProvidersForGlobalBanner(providers);
setExpiredProviders(nextExpiredProviders);
setDismissedProviderIds((currentDismissed) => {

View File

@@ -39,19 +39,70 @@ describe("OAuthReloginBanner", () => {
window.localStorage.clear();
});
it("shows expired Anthropic subscription OAuth even when Claude CLI is authenticated", async () => {
it("hides expired Anthropic subscription urgency when Claude CLI is authenticated", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic-subscription", name: "Anthropic Subscription", type: "oauth", authenticated: false, expired: true },
{ id: "claude-cli", name: "Claude CLI", type: "cli", authenticated: true },
{ id: "claude-cli", name: "Anthropic — via Claude CLI", type: "cli", authenticated: true },
],
ghCli: { available: false, authenticated: false },
});
render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
expect(await screen.findByRole("status")).toHaveTextContent("Anthropic Subscription");
expect(screen.getByRole("status")).toHaveTextContent("Re-login required");
await waitFor(() => expect(mockFetchAuthStatus).toHaveBeenCalledTimes(1));
expect(screen.queryByRole("status")).toBeNull();
expect(screen.queryByText(/Re-login required: Anthropic Subscription/i)).toBeNull();
expect(screen.queryByText(/keep agents running/i)).toBeNull();
});
it("hides expired Anthropic subscription urgency when API key and Claude CLI are authenticated", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic-subscription", name: "Anthropic Subscription", type: "oauth", authenticated: false, expired: true },
{ id: "anthropic-api-key", name: "Anthropic API Key", type: "api_key", authenticated: true, keyHint: "sk-••••1234" },
{ id: "claude-cli", name: "Anthropic — via Claude CLI", type: "cli", authenticated: true },
],
ghCli: { available: false, authenticated: false },
});
render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
await waitFor(() => expect(mockFetchAuthStatus).toHaveBeenCalledTimes(1));
expect(screen.queryByRole("status")).toBeNull();
expect(screen.queryByText(/Re-login required: Anthropic Subscription/i)).toBeNull();
});
it("shows expired Anthropic subscription urgency when no Anthropic fallback is authenticated", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic-subscription", name: "Anthropic Subscription", type: "oauth", authenticated: false, expired: true },
{ id: "anthropic-api-key", name: "Anthropic API Key", type: "api_key", authenticated: false },
{ id: "claude-cli", name: "Anthropic — via Claude CLI", type: "cli", authenticated: false },
],
ghCli: { available: false, authenticated: false },
});
render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
expect(await screen.findByRole("status")).toHaveTextContent("Re-login required: Anthropic Subscription");
expect(screen.getByRole("status")).toHaveTextContent("keep agents running");
});
it("keeps unrelated expired OAuth providers visible when Anthropic fallback suppresses subscription urgency", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "anthropic-subscription", name: "Anthropic Subscription", type: "oauth", authenticated: false, expired: true },
{ id: "anthropic-api-key", name: "Anthropic API Key", type: "api_key", authenticated: true, keyHint: "sk-••••1234" },
{ id: "openai-codex", name: "OpenAI Codex", type: "oauth", authenticated: false, expired: true },
],
ghCli: { available: false, authenticated: false },
});
render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
expect(await screen.findByRole("status")).toHaveTextContent("OpenAI Codex");
expect(screen.getByRole("status")).not.toHaveTextContent("Anthropic Subscription");
});
it("clears the banner when OAuth success is dispatched for the status provider id", async () => {