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:
7
.changeset/fn-7435-anthropic-subscription-banner.md
Normal file
7
.changeset/fn-7435-anthropic-subscription-banner.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user