diff --git a/.changeset/fn-7224-separate-anthropic-api-key.md b/.changeset/fn-7224-separate-anthropic-api-key.md new file mode 100644 index 0000000000..fa99b73899 --- /dev/null +++ b/.changeset/fn-7224-separate-anthropic-api-key.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Separate Anthropic API-key auth from Claude subscription login cards. +category: fix +dev: Adds anthropic-subscription OAuth and anthropic-api-key UI ids mapped to upstream Anthropic credential storage. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 9549330e3a..bce226953f 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -568,7 +568,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 the same provider card in **Settings → Authentication** and Model Onboarding, so operators can use the API-key row without removing the OAuth sign-in path. 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, so saving or clearing an API key does not affect the OAuth sign-in path. The dashboard only displays masked key hints after a key is saved. ## Smart Pull diff --git a/docs/settings-reference.md b/docs/settings-reference.md index c439391400..a47d9ec903 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -710,7 +710,7 @@ Manual re-login is still required when no refresh token is stored, the refresh r ### Anthropic API-key authentication -Anthropic can be connected with a raw API key from both Model Onboarding and **Settings → Authentication**. Anthropic appears as a dual-auth provider: the same Anthropic card keeps the OAuth Login/Logout controls and also shows an API-key row for `ANTHROPIC_API_KEY`, with only masked key hints returned by `/api/auth/status`. +Anthropic can be connected with a raw API key from both Model Onboarding and **Settings → Authentication**. Anthropic API-key auth appears as a separate **Anthropic API Key** card for `ANTHROPIC_API_KEY`, while Claude subscription OAuth appears as **Anthropic Subscription** with Login/Logout controls. `/api/auth/status` returns only masked key hints for the API-key card. ### Authentication troubleshooting (mobile OAuth fallback) diff --git a/packages/cli/src/commands/__tests__/provider-auth.test.ts b/packages/cli/src/commands/__tests__/provider-auth.test.ts index d52c82d5c1..e46157e65c 100644 --- a/packages/cli/src/commands/__tests__/provider-auth.test.ts +++ b/packages/cli/src/commands/__tests__/provider-auth.test.ts @@ -10,7 +10,9 @@ function makeAuthStorage(credentials: Record []), hasAuth: vi.fn((provider: string) => Boolean(credentials[provider])), login: vi.fn(), - logout: vi.fn(), + logout: vi.fn((provider: string) => { + delete credentials[provider]; + }), set: vi.fn((provider: string, credential: { type: string; key?: string }) => { credentials[provider] = credential; }), @@ -122,7 +124,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(providerIds).toContain("opencode-go"); }); - it("keeps built-in API key providers when OAuth provider ids collide", () => { + it("keeps explicit API-key aliases when OAuth provider ids collide", () => { const fusionAuth = makeAuthStorage(); fusionAuth.getOAuthProviders = vi.fn(() => [ { id: "anthropic", name: "Anthropic OAuth" }, @@ -133,7 +135,8 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); const providerIds = wrapped.getApiKeyProviders().map((provider) => provider.id); - expect(providerIds).toEqual(expect.arrayContaining(["anthropic", "opencode-go"])); + expect(providerIds).toEqual(expect.arrayContaining(["anthropic-api-key", "opencode-go"])); + expect(providerIds).not.toContain("anthropic"); }); it("reads legacy auth JSON without creating missing files", async () => { @@ -150,7 +153,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(existsSync(missingLegacyAuth)).toBe(false); }); - it("reads non-expired OAuth credentials from legacy auth JSON", async () => { + it("reads non-expired OAuth credentials from legacy auth JSON except Anthropic model API-key auth", async () => { const tempDir = tempWorkspace("fusion-provider-auth-oauth-"); const legacyAgentDir = join(tempDir, ".pi", "agent"); const legacyAgentAuth = join(legacyAgentDir, "auth.json"); @@ -164,16 +167,23 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { refresh: "legacy-refresh-token", expires: Date.now() + 60_000, }, + anthropic: { + type: "oauth", + access: "legacy-anthropic-access-token", + refresh: "legacy-anthropic-refresh-token", + expires: Date.now() + 60_000, + }, }), ); const storage = createReadOnlyAuthFileStorage([legacyAgentAuth]); expect(await storage.getApiKey("openai-codex")).toBe("legacy-access-token"); + expect(await storage.getApiKey("anthropic")).toBeUndefined(); }); describe("Anthropic provider classification", () => { - it("keeps anthropic in getOAuthProviders when upstream reports it as OAuth", () => { + it("exposes Anthropic subscription OAuth under anthropic and API-key auth under a separate alias", () => { const fusionAuth = makeAuthStorage(); fusionAuth.getOAuthProviders = vi.fn(() => [ { id: "anthropic", name: "Anthropic" }, @@ -183,23 +193,30 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); const oauthProviders = wrapped.getOAuthProviders(); + const apiKeyProviders = wrapped.getApiKeyProviders(); const oauthIds = oauthProviders.map((p) => p.id); expect(oauthIds).toContain("anthropic"); + expect(oauthIds).not.toContain("anthropic-api-key"); + expect(oauthProviders).toContainEqual({ id: "anthropic", name: "Anthropic Subscription" }); expect(oauthIds).toContain("github-copilot"); + expect(apiKeyProviders).toContainEqual({ id: "anthropic-api-key", name: "Anthropic API Key" }); }); - it("includes anthropic in getApiKeyProviders when OAuth-backed", () => { + it("keeps OpenAI API-key provider id unchanged when OAuth uses openai-codex", () => { const fusionAuth = makeAuthStorage(); fusionAuth.getOAuthProviders = vi.fn(() => [ - { id: "anthropic", name: "Anthropic" }, + { id: "openai-codex", name: "OpenAI Codex" }, ]); - const modelRegistry = { getAll: vi.fn(() => []) } as any; + const modelRegistry = { getAll: vi.fn(() => [ + { provider: "openai", id: "openai/gpt-4o" }, + ]) } as any; const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); const apiKeyProviders = wrapped.getApiKeyProviders(); - expect(apiKeyProviders).toContainEqual({ id: "anthropic", name: "Anthropic" }); + expect(apiKeyProviders).toContainEqual({ id: "openai", name: "Openai" }); + expect(apiKeyProviders.some((p) => p.id === "openai-codex")).toBe(false); }); it("keeps only explicit built-ins when a model-registry-derived provider is also OAuth-backed", () => { @@ -220,7 +237,86 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(apiKeyProviders.some((p) => p.id === "github-copilot")).toBe(false); }); - it("round-trips anthropic API key credentials", async () => { + it("keeps Anthropic subscription login from overwriting an existing anthropic API key", async () => { + const fusionAuth = makeAuthStorage({ + anthropic: { type: "api_key", key: "sk-ant-api03-existing" }, + }); + fusionAuth.getOAuthProviders = vi.fn(() => [ + { id: "anthropic", name: "Anthropic" }, + ]); + fusionAuth.login = vi.fn(async (provider: string) => { + fusionAuth.set(provider, { + type: "oauth", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }); + }); + const modelRegistry = { getAll: vi.fn(() => []) } as any; + + const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); + await wrapped.login("anthropic", {} as any); + + expect(fusionAuth.login).toHaveBeenCalledWith("anthropic", expect.any(Object)); + expect(wrapped.get("anthropic")?.type).toBe("oauth"); + expect(wrapped.get("anthropic-subscription")?.type).toBe("oauth"); + expect(wrapped.hasAuth("anthropic-subscription")).toBe(true); + expect(wrapped.get("anthropic-api-key")).toEqual({ type: "api_key", key: "sk-ant-api03-existing" }); + expect(await wrapped.getApiKey("anthropic")).toBe("sk-ant-api03-existing"); + }); + + it("logs out Anthropic subscription alias without clearing the raw API key", () => { + const fusionAuth = makeAuthStorage({ + anthropic: { type: "api_key", key: "sk-ant-api03-existing" }, + "anthropic-subscription": { + type: "oauth", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }); + fusionAuth.getOAuthProviders = vi.fn(() => [ + { id: "anthropic", name: "Anthropic" }, + ]); + const modelRegistry = { getAll: vi.fn(() => []) } as any; + + const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); + wrapped.logout("anthropic-subscription"); + + expect(fusionAuth.logout).toHaveBeenCalledWith("anthropic-subscription"); + expect(fusionAuth.logout).not.toHaveBeenCalledWith("anthropic"); + expect(wrapped.get("anthropic-api-key")).toEqual({ type: "api_key", key: "sk-ant-api03-existing" }); + }); + + it("logs out legacy Anthropic OAuth stored under the raw anthropic id", () => { + const fusionAuth = makeAuthStorage({ + anthropic: { + type: "oauth", + access: "legacy-oauth-access", + refresh: "legacy-oauth-refresh", + expires: Date.now() + 60_000, + }, + }); + fusionAuth.getOAuthProviders = vi.fn(() => [ + { id: "anthropic", name: "Anthropic" }, + ]); + const modelRegistry = { getAll: vi.fn(() => []) } as any; + + const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); + expect(wrapped.get("anthropic")?.type).toBe("oauth"); + + wrapped.logout("anthropic"); + wrapped.reload(); + + expect(fusionAuth.get).toHaveBeenCalledWith("anthropic"); + expect(fusionAuth.logout).toHaveBeenCalledWith("anthropic-subscription"); + expect(fusionAuth.logout).toHaveBeenCalledWith("anthropic"); + expect(wrapped.get("anthropic")).toBeUndefined(); + expect(wrapped.get("anthropic-subscription")).toBeUndefined(); + expect(wrapped.get("anthropic-api-key")).toBeUndefined(); + }); + + it("round-trips anthropic API-key alias through the underlying anthropic credential", async () => { const fusionAuth = makeAuthStorage(); fusionAuth.getOAuthProviders = vi.fn(() => [ { id: "anthropic", name: "Anthropic" }, @@ -228,19 +324,130 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { const modelRegistry = { getAll: vi.fn(() => []) } as any; const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); - wrapped.setApiKey("anthropic", "sk-ant-api03-test-key"); + wrapped.setApiKey("anthropic-api-key", "sk-ant-api03-test-key"); expect(fusionAuth.set).toHaveBeenCalledWith("anthropic", { type: "api_key", key: "sk-ant-api03-test-key", }); + expect(wrapped.hasApiKey("anthropic-api-key")).toBe(true); expect(wrapped.hasApiKey("anthropic")).toBe(true); + expect(await wrapped.getApiKey("anthropic-api-key")).toBe("sk-ant-api03-test-key"); expect(await wrapped.getApiKey("anthropic")).toBe("sk-ant-api03-test-key"); - expect(wrapped.get("anthropic")).toEqual({ type: "api_key", key: "sk-ant-api03-test-key" }); + expect(wrapped.get("anthropic-api-key")).toEqual({ type: "api_key", key: "sk-ant-api03-test-key" }); - wrapped.clearApiKey("anthropic"); + wrapped.clearApiKey("anthropic-api-key"); expect(fusionAuth.remove).toHaveBeenCalledWith("anthropic"); + expect(wrapped.hasApiKey("anthropic-api-key")).toBe(false); + expect(await wrapped.getApiKey("anthropic-api-key")).toBeUndefined(); + expect(await wrapped.getApiKey("anthropic")).toBeUndefined(); + }); + + it("preserves legacy Anthropic OAuth under anthropic when saving the separated API-key alias", async () => { + const fusionAuth = makeAuthStorage({ + anthropic: { + type: "oauth", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }); + fusionAuth.getOAuthProviders = vi.fn(() => [ + { id: "anthropic", name: "Anthropic" }, + ]); + const modelRegistry = { getAll: vi.fn(() => []) } as any; + + const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); + wrapped.setApiKey("anthropic-api-key", "sk-ant-api03-new-key"); + const legacyReadCallIndex = fusionAuth.get.mock.calls.findIndex(([provider]) => provider === "anthropic"); + const apiKeyWriteCallIndex = fusionAuth.set.mock.calls.findIndex(([provider]) => provider === "anthropic"); + + expect(legacyReadCallIndex).toBeGreaterThanOrEqual(0); + expect(fusionAuth.get.mock.invocationCallOrder[legacyReadCallIndex]).toBeLessThan( + fusionAuth.set.mock.invocationCallOrder[apiKeyWriteCallIndex], + ); + expect(fusionAuth.set).toHaveBeenCalledWith("anthropic-subscription", expect.objectContaining({ type: "oauth" })); + expect(fusionAuth.set).toHaveBeenCalledWith("anthropic", { + type: "api_key", + key: "sk-ant-api03-new-key", + }); + expect(wrapped.get("anthropic")?.type).toBe("oauth"); + expect(wrapped.get("anthropic-subscription")?.type).toBe("oauth"); + expect(wrapped.get("anthropic-api-key")).toEqual({ type: "api_key", key: "sk-ant-api03-new-key" }); + expect(await wrapped.getApiKey("anthropic")).toBe("sk-ant-api03-new-key"); + }); + + it("preserves legacy Anthropic OAuth under anthropic when clearing the separated API-key alias", async () => { + const fusionAuth = makeAuthStorage({ + anthropic: { + type: "oauth", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }); + fusionAuth.getOAuthProviders = vi.fn(() => [ + { id: "anthropic", name: "Anthropic" }, + ]); + const modelRegistry = { getAll: vi.fn(() => []) } as any; + + const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); + wrapped.clearApiKey("anthropic-api-key"); + + expect(fusionAuth.set).toHaveBeenCalledWith("anthropic-subscription", expect.objectContaining({ type: "oauth" })); + expect(fusionAuth.remove).toHaveBeenCalledWith("anthropic"); + expect(wrapped.get("anthropic")?.type).toBe("oauth"); + expect(wrapped.get("anthropic-subscription")?.type).toBe("oauth"); + expect(wrapped.get("anthropic-api-key")).toBeUndefined(); + expect(await wrapped.getApiKey("anthropic")).toBeUndefined(); + }); + + it("does not treat legacy Anthropic OAuth under anthropic as the model API key", async () => { + const fusionAuth = makeAuthStorage({ + anthropic: { + type: "oauth", + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 60_000, + }, + }); + fusionAuth.getOAuthProviders = vi.fn(() => [ + { id: "anthropic", name: "Anthropic" }, + ]); + const modelRegistry = { getAll: vi.fn(() => []) } as any; + + const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); + + expect(wrapped.hasAuth("anthropic")).toBe(true); + expect(wrapped.get("anthropic")?.type).toBe("oauth"); + expect(wrapped.get("anthropic-subscription")?.type).toBe("oauth"); + expect(wrapped.hasApiKey("anthropic-api-key")).toBe(false); + expect(wrapped.get("anthropic-api-key")).toBeUndefined(); + expect(await wrapped.getApiKey("anthropic-api-key")).toBeUndefined(); + expect(await wrapped.getApiKey("anthropic")).toBeUndefined(); + }); + + it("hydrates fallback Anthropic OAuth as subscription-only instead of the model API key", async () => { + const fusionAuth = makeAuthStorage(); + const fallbackAuth = makeAuthStorage({ + anthropic: { + type: "oauth", + access: "legacy-oauth-access", + refresh: "legacy-oauth-refresh", + expires: Date.now() + 60_000, + }, + }); + fusionAuth.getOAuthProviders = vi.fn(() => [ + { id: "anthropic", name: "Anthropic" }, + ]); + const modelRegistry = { getAll: vi.fn(() => []) } as any; + + const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry, [fallbackAuth]); + + expect(fusionAuth.set).toHaveBeenCalledWith("anthropic-subscription", expect.objectContaining({ type: "oauth" })); + expect(wrapped.get("anthropic-subscription")?.type).toBe("oauth"); + expect(wrapped.get("anthropic-api-key")).toBeUndefined(); expect(wrapped.hasApiKey("anthropic")).toBe(false); expect(await wrapped.getApiKey("anthropic")).toBeUndefined(); }); diff --git a/packages/cli/src/commands/provider-auth.ts b/packages/cli/src/commands/provider-auth.ts index ea0defc62b..ace07aee83 100644 --- a/packages/cli/src/commands/provider-auth.ts +++ b/packages/cli/src/commands/provider-auth.ts @@ -41,8 +41,12 @@ interface ReadFallbackAuthStorage { type StoredCredential = StoredAuthCredential; +const ANTHROPIC_API_KEY_PROVIDER_ID = "anthropic-api-key"; +const ANTHROPIC_STORAGE_PROVIDER_ID = "anthropic"; +const ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID = "anthropic-subscription"; + const BUILT_IN_API_KEY_PROVIDERS: Array<{ id: string; name: string }> = [ - { id: "anthropic", name: "Anthropic" }, + { id: ANTHROPIC_API_KEY_PROVIDER_ID, name: "Anthropic API Key" }, { id: "brave", name: "Brave Search" }, { id: "kimi-coding", name: "Kimi" }, { id: "minimax", name: "Minimax" }, @@ -54,6 +58,10 @@ const BUILT_IN_API_KEY_PROVIDERS: Array<{ id: string; name: string }> = [ const CLI_PROVIDER_IDS = new Set(["pi-claude-cli", "droid-cli"]); +function toApiKeyStorageProviderId(providerId: string): string { + return providerId === ANTHROPIC_API_KEY_PROVIDER_ID ? ANTHROPIC_STORAGE_PROVIDER_ID : providerId; +} + function getProviderDisplayName(providerId: string): string { const knownProviderNames = new Map( BUILT_IN_API_KEY_PROVIDERS.map((provider) => [provider.id, provider.name]), @@ -76,19 +84,89 @@ export function wrapAuthStorageWithApiKeyProviders( ): DashboardAuthStorage { const mergedAuthStorage = mergeAuthStorageReads(authStorage, readFallbackAuthStorages); + const getAnthropicSubscriptionCredential = () => { + const syntheticCredential = mergedAuthStorage.get(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID); + if (syntheticCredential) return syntheticCredential; + const legacyCredential = mergedAuthStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID); + return legacyCredential?.type === "oauth" ? legacyCredential : undefined; + }; + + const migrateStoredAnthropicSubscriptionCredential = () => { + const existingSubscription = authStorage.get(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) as StoredCredential | undefined; + if (existingSubscription?.type === "oauth") { + return existingSubscription; + } + + const legacySubscription = authStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID) as StoredCredential | undefined; + if (legacySubscription?.type !== "oauth") { + return undefined; + } + + /* + FNXC:ProviderAuth 2026-06-29-23:58: + Saving or clearing the separated `anthropic-api-key` provider overwrites the raw `anthropic` storage slot used by model execution. + Read the primary auth storage directly and migrate legacy subscription OAuth from `anthropic` to `anthropic-subscription` before that write, because merged Anthropic reads intentionally expose `anthropic` as API-key-only. + */ + mergedAuthStorage.set(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID, legacySubscription as AuthCredential); + return legacySubscription; + }; + return { reload: () => mergedAuthStorage.reload(), getOAuthProviders: () => mergedAuthStorage .getOAuthProviders() - .map((provider) => ({ id: provider.id, name: provider.name })), - hasAuth: (provider) => mergedAuthStorage.hasAuth(provider), - login: (providerId, callbacks) => - mergedAuthStorage.login( - providerId as Parameters[0], + .map((provider) => provider.id === ANTHROPIC_STORAGE_PROVIDER_ID + ? ({ id: ANTHROPIC_STORAGE_PROVIDER_ID, name: "Anthropic Subscription" }) + : ({ id: provider.id, name: provider.name })), + hasAuth: (provider) => provider === ANTHROPIC_STORAGE_PROVIDER_ID || provider === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID + ? Boolean(getAnthropicSubscriptionCredential()) + : mergedAuthStorage.hasAuth(provider), + login: async (providerId, callbacks) => { + if (providerId !== ANTHROPIC_STORAGE_PROVIDER_ID && providerId !== ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) { + await mergedAuthStorage.login( + providerId as Parameters[0], + callbacks as Parameters[1], + ); + return; + } + + const existingApiKey = mergedAuthStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID); + await mergedAuthStorage.login( + ANTHROPIC_STORAGE_PROVIDER_ID as Parameters[0], callbacks as Parameters[1], - ), - logout: (provider) => mergedAuthStorage.logout(provider), + ); + const oauthCredential = authStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID) as StoredCredential | undefined; + if (oauthCredential?.type === "oauth") { + /* + FNXC:ProviderAuth 2026-06-29-23:15: + Anthropic subscription OAuth and raw Anthropic API-key auth must be separate UI providers: OAuth stays `anthropic`, while the UI/API key card uses `anthropic-api-key` and maps back to the `anthropic` model credential. + Store subscription OAuth under an internal key after upstream login because the OAuth library writes through the same `anthropic` id used by model API-key execution. + */ + mergedAuthStorage.set(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID, oauthCredential as AuthCredential); + if (existingApiKey?.type === "api_key") { + mergedAuthStorage.set(ANTHROPIC_STORAGE_PROVIDER_ID, existingApiKey as AuthCredential); + } else { + authStorage.remove(ANTHROPIC_STORAGE_PROVIDER_ID); + } + } + }, + logout: (provider) => { + if (provider !== ANTHROPIC_STORAGE_PROVIDER_ID && provider !== ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) { + mergedAuthStorage.logout(provider); + return; + } + mergedAuthStorage.logout(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID); + /* + FNXC:ProviderAuth 2026-06-29-23:59: + Logging out Anthropic subscription auth must also remove pre-split OAuth credentials still stored under `anthropic`. + Check primary storage directly because merged Anthropic reads expose `anthropic` as the model API-key credential only, so an OAuth credential would otherwise survive reload and reappear as `anthropic-subscription`. + */ + const legacyAnthropicCredential = authStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID) as StoredCredential | undefined; + if (legacyAnthropicCredential?.type === "oauth") { + mergedAuthStorage.logout(ANTHROPIC_STORAGE_PROVIDER_ID); + } + }, getApiKeyProviders: () => { const oauthProviderIds = new Set( mergedAuthStorage @@ -99,9 +177,9 @@ export function wrapAuthStorageWithApiKeyProviders( for (const provider of BUILT_IN_API_KEY_PROVIDERS) { /* - FNXC:ProviderAuth 2026-06-28-15:53: - Anthropic supports raw API-key credentials next to its OAuth-capable provider surface, so built-in API-key providers must remain visible even when their id also appears in the OAuth provider list. - Keep OAuth-id exclusion only for registry-derived providers to avoid accidentally reclassifying unrelated OAuth providers while preserving explicit API-key targets. + FNXC:ProviderAuth 2026-06-29-23:32: + Anthropic subscription OAuth and Anthropic API-key auth are separate UI providers: the API-key card is `anthropic-api-key`, but reads and writes the `anthropic` model credential through toApiKeyStorageProviderId(). + Keep OAuth-id exclusion only for registry-derived providers so OpenAI stays split as `openai-codex` OAuth plus `openai` API key, while unrelated OAuth providers are not reclassified. */ providers.set(provider.id, provider.name); } @@ -124,17 +202,44 @@ export function wrapAuthStorageWithApiKeyProviders( ); }, setApiKey: (providerId, apiKey) => { - mergedAuthStorage.set(providerId, { type: "api_key", key: apiKey }); + const storageProviderId = toApiKeyStorageProviderId(providerId); + if (storageProviderId === ANTHROPIC_STORAGE_PROVIDER_ID) { + migrateStoredAnthropicSubscriptionCredential(); + } + mergedAuthStorage.set(storageProviderId, { type: "api_key", key: apiKey }); }, clearApiKey: (providerId) => { - mergedAuthStorage.remove(providerId); + const storageProviderId = toApiKeyStorageProviderId(providerId); + if (storageProviderId === ANTHROPIC_STORAGE_PROVIDER_ID) { + migrateStoredAnthropicSubscriptionCredential(); + } + mergedAuthStorage.remove(storageProviderId); }, hasApiKey: (providerId) => { - const credential = mergedAuthStorage.get(providerId); + const credential = mergedAuthStorage.get(toApiKeyStorageProviderId(providerId)); return credential?.type === "api_key" && !!credential.key; }, - getApiKey: (providerId) => mergedAuthStorage.getApiKey(providerId), - get: (providerId) => mergedAuthStorage.get(providerId), + getApiKey: async (providerId) => { + const storageProviderId = toApiKeyStorageProviderId(providerId); + if (storageProviderId === ANTHROPIC_STORAGE_PROVIDER_ID) { + const credential = mergedAuthStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID); + return credential?.type === "api_key" ? resolveStoredApiKey(credential.key) : undefined; + } + return mergedAuthStorage.getApiKey(storageProviderId); + }, + get: (providerId) => { + if (providerId === ANTHROPIC_API_KEY_PROVIDER_ID) { + const credential = mergedAuthStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID); + return credential?.type === "api_key" ? credential : undefined; + } + if (providerId === ANTHROPIC_STORAGE_PROVIDER_ID) { + return getAnthropicSubscriptionCredential(); + } + if (providerId === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) { + return getAnthropicSubscriptionCredential(); + } + return mergedAuthStorage.get(providerId); + }, }; } @@ -155,7 +260,24 @@ export function mergeAuthStorageReads( ): StoredCredential | undefined => { let best: StoredCredential | undefined; for (const storage of storages) { - best = choosePreferredStoredCredential(best, storage.get(providerId)); + const credential = storage.get(providerId); + if (providerId === ANTHROPIC_STORAGE_PROVIDER_ID) { + if (credential?.type === "api_key") { + best = choosePreferredStoredCredential(best, credential); + } + continue; + } + if (providerId === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) { + if (credential?.type === "oauth") { + best = choosePreferredStoredCredential(best, credential); + } + const legacyAnthropic = storage.get(ANTHROPIC_STORAGE_PROVIDER_ID); + if (legacyAnthropic?.type === "oauth") { + best = choosePreferredStoredCredential(best, legacyAnthropic); + } + continue; + } + best = choosePreferredStoredCredential(best, credential); } return best; }; @@ -170,16 +292,23 @@ export function mergeAuthStorageReads( const syncFallbackOauthCredentials = () => { const providerIds = new Set(readFallbackAuthStorages.flatMap((storage) => storage.list())); for (const providerId of providerIds) { - if (loggedOutProviders.has(providerId)) { + const storageProviderId = providerId === ANTHROPIC_STORAGE_PROVIDER_ID + ? ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID + : providerId; + if (loggedOutProviders.has(providerId) || loggedOutProviders.has(storageProviderId)) { continue; } - const current = authStorage.get(providerId) as StoredCredential | undefined; - const candidate = selectCredential(providerId, readFallbackAuthStorages); + const current = authStorage.get(storageProviderId) as StoredCredential | undefined; + const candidate = selectCredential(storageProviderId, readFallbackAuthStorages); if (!shouldHydrateStoredCredential(current, candidate)) { continue; } if (candidate && (candidate.type === "oauth" || candidate.type === "api_key")) { - authStorage.set(providerId, candidate as AuthCredential); + /* + FNXC:ProviderAuth 2026-06-29-23:48: + Legacy Anthropic OAuth files may still store subscription credentials under `anthropic`; hydrate those as `anthropic-subscription` so Anthropic model/API-key reads only trust `api_key` credentials under `anthropic`. + */ + authStorage.set(storageProviderId, candidate as AuthCredential); } } }; @@ -227,6 +356,9 @@ export function mergeAuthStorageReads( if (loggedOutProviders.has(provider)) { return false; } + if (provider === ANTHROPIC_STORAGE_PROVIDER_ID || provider === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) { + return Boolean(getCredential(provider)); + } return readAuthStorages.some((storage) => Boolean(storage.get(provider))); }; } @@ -236,6 +368,9 @@ export function mergeAuthStorageReads( if (loggedOutProviders.has(provider)) { return false; } + if (provider === ANTHROPIC_STORAGE_PROVIDER_ID || provider === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) { + return Boolean(getCredential(provider)); + } return readAuthStorages.some((storage) => storage.hasAuth(provider)); }; } @@ -243,6 +378,9 @@ export function mergeAuthStorageReads( if (prop === "getAll") { return () => { const providerIds = new Set(readAuthStorages.flatMap((storage) => storage.list())); + if (providerIds.has(ANTHROPIC_STORAGE_PROVIDER_ID)) { + providerIds.add(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID); + } const merged: Record = {}; for (const providerId of providerIds) { if (loggedOutProviders.has(providerId)) { @@ -259,8 +397,11 @@ export function mergeAuthStorageReads( if (prop === "list") { return () => { - const providers = readAuthStorages.flatMap((storage) => storage.list()); - return Array.from(new Set(providers.filter((p) => !loggedOutProviders.has(p)))); + const providers = new Set(readAuthStorages.flatMap((storage) => storage.list())); + if (providers.has(ANTHROPIC_STORAGE_PROVIDER_ID) && !loggedOutProviders.has(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID)) { + providers.add(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID); + } + return Array.from(providers).filter((p) => !loggedOutProviders.has(p) && getCredential(p)); }; } @@ -269,6 +410,13 @@ export function mergeAuthStorageReads( if (loggedOutProviders.has(providerId)) { return undefined; } + const credential = getCredential(providerId); + if (providerId === ANTHROPIC_STORAGE_PROVIDER_ID) { + return credential?.type === "api_key" ? resolveStoredApiKey(credential.key) : undefined; + } + if (providerId === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID && credential) { + return resolveStoredCredentialApiKey(providerId, credential); + } for (const storage of readAuthStorages) { const apiKey = await storage.getApiKey(providerId); if (apiKey) return apiKey; @@ -298,13 +446,19 @@ function resolveOAuthApiKey(providerId: string, credential: StoredCredential): s return undefined; } - return getOAuthProvider(providerId)?.getApiKey(credential as OAuthCredentials); + const oauthProviderId = providerId === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID + ? ANTHROPIC_STORAGE_PROVIDER_ID + : providerId; + return getOAuthProvider(oauthProviderId)?.getApiKey(credential as OAuthCredentials); } function resolveStoredCredentialApiKey(providerId: string, credential: StoredCredential | undefined): string | undefined { if (credential?.type === "api_key") { return resolveStoredApiKey(credential.key); } + if (providerId === ANTHROPIC_STORAGE_PROVIDER_ID) { + return undefined; + } if (credential?.type === "oauth") { return resolveOAuthApiKey(providerId, credential); } diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index cc4c93080a..fdf81e7ff3 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -1730,8 +1730,6 @@ export interface AuthProvider { * one-click Enable/Disable + Test button rather than login/key inputs. */ type?: "oauth" | "api_key" | "cli"; - /** Provider accepts a raw API key in addition to its primary auth method, e.g. an OAuth provider that also accepts ANTHROPIC_API_KEY. */ - supportsApiKey?: boolean; /** Masked hint of the stored API key (first 3 + bullets + last 4 chars) */ keyHint?: string; } diff --git a/packages/dashboard/app/components/ModelOnboardingModal.tsx b/packages/dashboard/app/components/ModelOnboardingModal.tsx index 037d6b4893..b13acbaf19 100644 --- a/packages/dashboard/app/components/ModelOnboardingModal.tsx +++ b/packages/dashboard/app/components/ModelOnboardingModal.tsx @@ -101,6 +101,19 @@ function getProviderInfoMap(t: (key: string, defaultValue: string) => string): R usageDescription: t("setup.apiKeyUsage.anthropic", "Used for Claude models in task execution and planning"), }, }, + "anthropic-api-key": { + description: t("setup.providerDesc.anthropicApiKey", "Claude models via raw Anthropic API key"), + apiKeyInfo: { + fieldLabel: t("setup.apiKeyLabel.anthropic", "Anthropic API Key"), + setupInstructions: t("setup.apiKeySetup.anthropic", "Create an API key from your Anthropic Console under API keys."), + dashboardUrl: "https://console.anthropic.com/settings/keys", + inputPlaceholder: "sk-ant-...", + usageDescription: t("setup.apiKeyUsage.anthropic", "Used for Claude models in task execution and planning"), + }, + }, + "anthropic-subscription": { + description: t("setup.providerDesc.anthropicSubscription", "Claude subscription OAuth — sign in with your Anthropic account"), + }, openai: { description: t("setup.providerDesc.openai", "GPT models — versatile for a wide range of tasks"), apiKeyInfo: { @@ -174,6 +187,7 @@ const PROVIDER_KEY_HINTS: Record = { anthropic: { pattern: /^sk-ant-/, hint: "Starts with sk-ant-", example: "sk-ant-api03-..." }, + "anthropic-api-key": { pattern: /^sk-ant-/, hint: "Starts with sk-ant-", example: "sk-ant-api03-..." }, openai: { pattern: /^sk-/, hint: "Starts with sk-", example: "sk-..." }, "openai-codex": { pattern: /^sk-/, hint: "Starts with sk-", example: "sk-..." }, openrouter: { pattern: /^sk-or-/, hint: "Starts with sk-or-", example: "sk-or-v1-..." }, @@ -195,6 +209,8 @@ const PROVIDER_KEY_HINTS_FALLBACK = { const PROVIDER_DISPLAY_NAMES: Record = { anthropic: "Anthropic", + "anthropic-api-key": "Anthropic API Key", + "anthropic-subscription": "Anthropic Subscription", openai: "OpenAI", "openai-codex": "OpenAI Codex", openrouter: "OpenRouter", @@ -228,7 +244,11 @@ function getProviderDisplayName(providerId: string): string { .join(" "); } -const QUICK_START_PROVIDER_IDS = ["anthropic", "openai", "google", "gemini", "ollama"] as const; +/* +FNXC:ProviderAuth 2026-06-29-23:58: +Onboarding quick start must show Anthropic subscription OAuth and raw Anthropic API-key auth as separate first-class cards; keep the legacy `anthropic` id as a fallback only for older status payloads. +*/ +const QUICK_START_PROVIDER_IDS = ["anthropic-subscription", "anthropic-api-key", "anthropic", "openai", "google", "gemini", "ollama"] as const; const ONBOARDING_CURATED_PROVIDER_FAMILY_ORDER = [ "anthropic", @@ -244,6 +264,8 @@ const ONBOARDING_CURATED_PROVIDER_FAMILY_ORDER = [ ] as const; const ONBOARDING_PROVIDER_FAMILY_ALIASES: Record = { + "anthropic-subscription": "anthropic", + "anthropic-api-key": "anthropic", anthropic: "anthropic", "claude-cli": "claude-cli", "droid-cli": "droid-cli", @@ -1811,7 +1833,7 @@ export function ModelOnboardingModal({ const showShellConnectionSetup = shellState.host !== "web" && !shellState.activeProfileId; const orderedAiProviders = [...aiProviders].sort(compareOnboardingProviders); const hasOauthProviders = orderedAiProviders.some((provider) => !provider.type || provider.type === "oauth"); - const providerSupportsApiKey = (provider: AuthProvider) => provider.type === "api_key" || provider.supportsApiKey === true; + const providerSupportsApiKey = (provider: AuthProvider) => provider.type === "api_key"; const hasApiKeyProviders = orderedAiProviders.some((provider) => providerSupportsApiKey(provider)); const connectedAiProviders = aiProviders.filter((provider) => provider.authenticated); const hasAiProvider = connectedAiProviders.length > 0; @@ -1956,7 +1978,18 @@ export function ModelOnboardingModal({ ""; const quickStartSet = new Set(QUICK_START_PROVIDER_IDS); - const quickStartProviders = orderedAiProviders + const hasSeparatedAnthropicProvider = orderedAiProviders.some( + (provider) => provider.id === "anthropic-subscription" || provider.id === "anthropic-api-key", + ); + /* + FNXC:ProviderAuth 2026-06-29-23:59: + Onboarding may receive a stale legacy `anthropic` status row during rollout, but the separated `anthropic-subscription` and `anthropic-api-key` cards are now the source of truth. + Suppress the legacy row whenever either separated card is present so users never see duplicate Anthropic auth choices or a dual-purpose card. + */ + const visibleOrderedAiProviders = orderedAiProviders.filter( + (provider) => !(provider.id === "anthropic" && hasSeparatedAnthropicProvider), + ); + const quickStartProviders = visibleOrderedAiProviders .filter((provider) => quickStartSet.has(provider.id)) .sort((a, b) => { const rankA = QUICK_START_PROVIDER_IDS.indexOf(a.id as (typeof QUICK_START_PROVIDER_IDS)[number]); @@ -1966,10 +1999,10 @@ export function ModelOnboardingModal({ } return compareOnboardingProviders(a, b); }); - const connectedNonQuickStartProviders = orderedAiProviders.filter( + const connectedNonQuickStartProviders = visibleOrderedAiProviders.filter( (provider) => provider.authenticated && !quickStartSet.has(provider.id), ); - const advancedProviders = orderedAiProviders.filter( + const advancedProviders = visibleOrderedAiProviders.filter( (provider) => !provider.authenticated && !quickStartSet.has(provider.id), ); @@ -2018,13 +2051,12 @@ export function ModelOnboardingModal({ if (providerSupportsApiKey(provider)) { const providerInfo = getProviderInfo(provider.id, t); const apiKeyInfo = getApiKeyInfo(provider, t); - const isDualAuthProvider = provider.type !== "api_key" && provider.supportsApiKey === true; const hasStoredApiKey = Boolean(provider.keyHint); /* - FNXC:ProviderAuth 2026-06-28-16:14: - Onboarding should offer Anthropic's raw API-key path next to the existing OAuth login path, while OpenAI continues using its existing standalone API-key card. - Use `supportsApiKey` for dual providers so OAuth-only authentication does not hide the API-key input. + FNXC:ProviderAuth 2026-06-29-22:20: + Onboarding must offer Anthropic subscription OAuth as its own login card and raw Anthropic API-key auth as a separate key card, matching OpenAI Codex versus OpenAI API-key behavior. + Treat only `type: "api_key"` providers as key-entry cards so the separated invariant cannot regress through `supportsApiKey`. */ return (
{t("setup.apiKeyHint", "Key: {{keyHint}}", { keyHint: provider.keyHint })} )}
- {isDualAuthProvider && ( -
- {authActionInProgress === provider.id ? ( - provider.authenticated ? ( - - ) : ( - <> - - - - ) - ) : showRemoteLoginInProgress ? ( - <> - - - - ) : provider.authenticated ? ( - - ) : ( - - )} -
- )}
- {isDualAuthProvider && (authActionInProgress === provider.id || showRemoteLoginInProgress) && provider.id === "github-copilot" && deviceCodes[provider.id] && ( -
- {t("setup.enterCodeOnGitHub", "Enter this code on GitHub")} -
{deviceCodes[provider.id].userCode}
-
- - -
-
- )} - {isDualAuthProvider && (authActionInProgress === provider.id || showRemoteLoginInProgress) && loginInstructions[provider.id] && ( - - )} - {isDualAuthProvider && (authActionInProgress === provider.id || showRemoteLoginInProgress) && manualCodeConfigs[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 ? t("setup.submittingCode", "Submitting…") : t("setup.submitCode", "Submit code")} - data-testid={`onboarding-manual-code-${provider.id}`} - /> - )} ); } diff --git a/packages/dashboard/app/components/ProviderIcon.tsx b/packages/dashboard/app/components/ProviderIcon.tsx index 8e0d26699c..d62c6fedec 100644 --- a/packages/dashboard/app/components/ProviderIcon.tsx +++ b/packages/dashboard/app/components/ProviderIcon.tsx @@ -717,7 +717,14 @@ const providerConfig: Record< { component: typeof AnthropicIcon; color: string; label?: string } > = { // Branded provider colors are tokenized in app/styles.css for theme-system consistency. + /* + FNXC:ProviderAuth 2026-06-29-23:45: + Settings renders Anthropic subscription OAuth and raw Anthropic API-key auth as separate cards, but both remain Anthropic-branded credentials. + Keep both synthetic ids on the Anthropic icon so the split provider cards do not fall back to a generic icon. + */ anthropic: { component: AnthropicIcon, color: "var(--provider-anthropic)" }, + "anthropic-api-key": { component: AnthropicIcon, color: "var(--provider-anthropic)", label: "Anthropic API Key" }, + "anthropic-subscription": { component: AnthropicIcon, color: "var(--provider-anthropic)", label: "Anthropic Subscription" }, "claude-cli": { component: ClaudeCliIcon, color: "var(--provider-anthropic)", label: "Anthropic — via Claude CLI" }, "pi-claude-cli": { component: ClaudeCliIcon, color: "var(--provider-anthropic)", label: "Anthropic — via Claude CLI" }, "droid-cli": { component: DroidCliIcon, color: "var(--provider-openai)", label: "Factory AI — via Droid CLI" }, diff --git a/packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx b/packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx index b0d6814bd7..f6a45817d1 100644 --- a/packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx +++ b/packages/dashboard/app/components/__tests__/AuthenticationSection.test.tsx @@ -77,65 +77,64 @@ describe("AuthenticationSection", () => { vi.clearAllMocks(); }); - it("renders an unauthenticated dual Anthropic card with OAuth login and API-key save", () => { + it("renders separate Anthropic subscription OAuth and API-key cards", () => { const { handleLogin, handleSaveApiKey } = renderAuthSection([ - { id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", supportsApiKey: true }, + { id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" }, + { id: "anthropic-api-key", name: "Anthropic API Key", authenticated: false, type: "api_key" }, + { id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" }, ]); - const card = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement; - expect(within(card).getByRole("button", { name: "Login" })).toBeInTheDocument(); - fireEvent.change(within(card).getByPlaceholderText("Enter API key"), { target: { value: "sk-ant-api03-new" } }); - const saveButton = within(card).getByRole("button", { name: "Save" }); - expect(saveButton).toHaveClass("btn-primary"); - fireEvent.click(saveButton); - fireEvent.click(within(card).getByRole("button", { name: "Login" })); + const subscriptionCard = screen.getByTestId("auth-provider-icon-anthropic-subscription").closest(".auth-provider-card") as HTMLElement; + const apiKeyCard = screen.getByTestId("auth-provider-icon-anthropic-api-key").closest(".auth-provider-card") as HTMLElement; + expect(screen.queryByTestId("auth-provider-icon-anthropic")).not.toBeInTheDocument(); - expect(handleSaveApiKey).toHaveBeenCalledWith("anthropic"); - expect(handleLogin).toHaveBeenCalledWith("anthropic"); + fireEvent.click(within(subscriptionCard).getByRole("button", { name: "Login" })); + expect(within(subscriptionCard).queryByPlaceholderText("Enter API key")).not.toBeInTheDocument(); + expect(handleLogin).toHaveBeenCalledWith("anthropic-subscription"); + + fireEvent.change(within(apiKeyCard).getByPlaceholderText("Enter API key"), { target: { value: "sk-ant-api03-new" } }); + fireEvent.click(within(apiKeyCard).getByRole("button", { name: "Save" })); + expect(within(apiKeyCard).queryByRole("button", { name: "Login" })).not.toBeInTheDocument(); + expect(handleSaveApiKey).toHaveBeenCalledWith("anthropic-api-key"); }); - it("renders OAuth-only dual Anthropic as authenticated while keeping the API-key input", () => { - const { handleLogout } = renderAuthSection([ - { id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth", supportsApiKey: true }, + it("keeps Anthropic OAuth logout separate from a stored API key clear action", () => { + const { handleLogout, handleClearApiKey } = renderAuthSection([ + { id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: true, type: "oauth" }, + { id: "anthropic-api-key", name: "Anthropic API Key", authenticated: true, type: "api_key", keyHint: "sk-•••••dkey" }, ]); - const card = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement; - expect(card).toHaveClass("auth-provider-card--authenticated"); - expect(within(card).getByRole("button", { name: "Logout" })).toBeInTheDocument(); - expect(within(card).getByRole("button", { name: "Save" })).toBeInTheDocument(); - expect(within(card).getByPlaceholderText("Enter API key")).toBeInTheDocument(); - fireEvent.click(within(card).getByRole("button", { name: "Logout" })); + const subscriptionCard = screen.getByTestId("auth-provider-icon-anthropic-subscription").closest(".auth-provider-card") as HTMLElement; + const apiKeyCard = screen.getByTestId("auth-provider-icon-anthropic-api-key").closest(".auth-provider-card") as HTMLElement; - expect(handleLogout).toHaveBeenCalledWith("anthropic"); + fireEvent.click(within(subscriptionCard).getByRole("button", { name: "Logout" })); + expect(within(subscriptionCard).queryByRole("button", { name: "Clear" })).not.toBeInTheDocument(); + expect(handleLogout).toHaveBeenCalledWith("anthropic-subscription"); + + expect(within(apiKeyCard).getByText("Key: sk-•••••dkey")).toBeInTheDocument(); + fireEvent.click(within(apiKeyCard).getByRole("button", { name: "Clear" })); + expect(within(apiKeyCard).queryByRole("button", { name: "Logout" })).not.toBeInTheDocument(); + expect(handleClearApiKey).toHaveBeenCalledWith("anthropic-api-key"); }); - it("renders API-key-only dual Anthropic with masked key hint and Clear", () => { - const { handleClearApiKey } = renderAuthSection([ - { id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", supportsApiKey: true, keyHint: "sk-•••••1234" }, + it("ignores legacy supportsApiKey flags on OAuth cards", () => { + const { handleLogin, handleSaveApiKey } = renderAuthSection([ + { + id: "anthropic-subscription", + name: "Anthropic Subscription", + authenticated: false, + type: "oauth", + supportsApiKey: true, + } as AuthProvider & { supportsApiKey: true }, ]); - const card = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement; - expect(card).not.toHaveClass("auth-provider-card--authenticated"); - expect(within(card).getByRole("button", { name: "Login" })).toBeInTheDocument(); - expect(within(card).queryByRole("button", { name: "Logout" })).not.toBeInTheDocument(); - expect(within(card).getByText("Key: sk-•••••1234")).toBeInTheDocument(); - expect(within(card).getByRole("button", { name: "Clear" })).toBeInTheDocument(); - fireEvent.change(within(card).getByPlaceholderText("Enter API key"), { target: { value: "sk-ant-api03-replacement" } }); - expect(within(card).queryByRole("button", { name: "Clear" })).not.toBeInTheDocument(); - expect(within(card).getByRole("button", { name: "Save" })).toBeInTheDocument(); - fireEvent.change(within(card).getByPlaceholderText("Enter API key"), { target: { value: "" } }); - fireEvent.click(within(card).getByRole("button", { name: "Clear" })); + const subscriptionCard = screen.getByTestId("auth-provider-icon-anthropic-subscription").closest(".auth-provider-card") as HTMLElement; - expect(handleClearApiKey).toHaveBeenCalledWith("anthropic"); - }); + expect(within(subscriptionCard).getByRole("button", { name: "Login" })).toBeInTheDocument(); + expect(within(subscriptionCard).queryByPlaceholderText("Enter API key")).not.toBeInTheDocument(); - it("renders both OAuth logout and API-key Clear when Anthropic has both credentials", () => { - renderAuthSection([ - { id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth", supportsApiKey: true, keyHint: "sk-•••••dkey" }, - ]); - - const card = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement; - expect(within(card).getByRole("button", { name: "Logout" })).toBeInTheDocument(); - expect(within(card).getByRole("button", { name: "Clear" })).toBeInTheDocument(); + fireEvent.click(within(subscriptionCard).getByRole("button", { name: "Login" })); + expect(handleLogin).toHaveBeenCalledWith("anthropic-subscription"); + expect(handleSaveApiKey).not.toHaveBeenCalled(); }); }); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx index f8a084a5bb..a13481d099 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx @@ -1069,21 +1069,21 @@ describe("SettingsModal", () => { it("warns before starting manual-code oauth login and stops when cancelled", async () => { vi.spyOn(window, "open").mockImplementation(() => null); mockFetchAuthStatus.mockResolvedValueOnce({ - providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", requiresManualCode: true }], + providers: [{ id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth", requiresManualCode: true }], }); mockConfirm.mockResolvedValueOnce(false); renderModal(); await waitForSettingsModalReady(); - const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement; + const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic-subscription").closest(".auth-provider-card") as HTMLElement; await settingsModalUser.click(within(anthropicCard).getByRole("button", { name: "Login" })); await waitFor(() => { expect(mockConfirm).toHaveBeenCalledWith({ title: "Heads up — manual paste-back required", message: - "After you sign in with Anthropic, the browser will try to redirect to a localhost address that this dashboard can't reach. The redirect tab will look like it failed. Before that happens, copy the full URL from the browser address bar — you'll paste it back here to finish login. Continue?", + "After you sign in with Anthropic Subscription, the browser will try to redirect to a localhost address that this dashboard can't reach. The redirect tab will look like it failed. Before that happens, copy the full URL from the browser address bar — you'll paste it back here to finish login. Continue?", confirmLabel: "Continue to login", cancelLabel: "Cancel", }); @@ -1094,7 +1094,7 @@ describe("SettingsModal", () => { it("continues manual-code oauth login after confirmation", async () => { const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); mockFetchAuthStatus.mockResolvedValueOnce({ - providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", requiresManualCode: true }], + providers: [{ id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth", requiresManualCode: true }], }); mockLoginProvider.mockResolvedValueOnce({ url: "https://claude.ai/oauth/authorize" }); mockConfirm.mockResolvedValueOnce(true); @@ -1102,12 +1102,12 @@ describe("SettingsModal", () => { renderModal(); await waitForSettingsModalReady(); - const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement; + const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic-subscription").closest(".auth-provider-card") as HTMLElement; await settingsModalUser.click(within(anthropicCard).getByRole("button", { name: "Login" })); await waitFor(() => { expect(mockConfirm).toHaveBeenCalled(); - expect(mockLoginProvider).toHaveBeenCalledWith("anthropic"); + expect(mockLoginProvider).toHaveBeenCalledWith("anthropic-subscription"); expect(openSpy).toHaveBeenCalledWith("https://claude.ai/oauth/authorize", "_blank"); }); }); @@ -1134,7 +1134,7 @@ describe("SettingsModal", () => { it("renders Anthropic pasted-code form when login response includes manualCode", async () => { const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); mockFetchAuthStatus.mockResolvedValueOnce({ - providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" }], + providers: [{ id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" }], }); mockLoginProvider.mockResolvedValueOnce({ url: "https://claude.ai/oauth/authorize", @@ -1148,7 +1148,7 @@ describe("SettingsModal", () => { renderModal(); await waitForSettingsModalReady(); - const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement; + const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic-subscription").closest(".auth-provider-card") as HTMLElement; await settingsModalUser.click(within(anthropicCard).getByRole("button", { name: "Login" })); expect(await within(anthropicCard).findByText("Paste the final redirect URL or authorization code")).toBeInTheDocument(); @@ -1156,7 +1156,7 @@ describe("SettingsModal", () => { await settingsModalUser.click(within(anthropicCard).getByRole("button", { name: "Submit code" })); await waitFor(() => { - expect(mockSubmitProviderManualCode).toHaveBeenCalledWith("anthropic", "anthropic-code"); + expect(mockSubmitProviderManualCode).toHaveBeenCalledWith("anthropic-subscription", "anthropic-code"); }); expect(openSpy).toHaveBeenCalled(); }); @@ -1183,7 +1183,7 @@ describe("SettingsModal", () => { }); mockFetchAuthStatus.mockResolvedValueOnce({ - providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" }], + providers: [{ id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" }], }); mockLoginProvider.mockResolvedValueOnce({ url: "https://claude.ai/oauth/authorize", @@ -1195,7 +1195,7 @@ describe("SettingsModal", () => { renderModal(); await waitForSettingsModalReady(); - const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement; + const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic-subscription").closest(".auth-provider-card") as HTMLElement; await settingsModalUser.click(within(anthropicCard).getByRole("button", { name: "Login" })); const textarea = await within(anthropicCard).findByRole("textbox"); @@ -1358,53 +1358,46 @@ describe("SettingsModal", () => { expect(addToast).toHaveBeenCalledWith(expect.stringContaining("manually"), "error"); }); - it("renders dual Anthropic OAuth and API-key controls in Authentication settings", async () => { + it("renders separate Anthropic subscription and API-key controls in Authentication settings", async () => { mockFetchAuthStatus.mockResolvedValueOnce({ - providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", supportsApiKey: true }], + providers: [ + { id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" }, + { id: "anthropic-api-key", name: "Anthropic API Key", authenticated: false, type: "api_key" }, + ], }); render(); await settingsModalUser.click(await screen.findByRole("button", { name: "Authentication" })); - const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement; - expect(within(anthropicCard).getByRole("button", { name: "Login" })).toBeInTheDocument(); - await settingsModalUser.type(within(anthropicCard).getByPlaceholderText("Enter API key"), "sk-ant-api03-settings"); - await settingsModalUser.click(within(anthropicCard).getByRole("button", { name: "Save" })); + const subscriptionCard = screen.getByTestId("auth-provider-icon-anthropic-subscription").closest(".auth-provider-card") as HTMLElement; + const apiKeyCard = screen.getByTestId("auth-provider-icon-anthropic-api-key").closest(".auth-provider-card") as HTMLElement; + expect(within(subscriptionCard).getByRole("button", { name: "Login" })).toBeInTheDocument(); + expect(within(subscriptionCard).queryByPlaceholderText("Enter API key")).not.toBeInTheDocument(); + await settingsModalUser.type(within(apiKeyCard).getByPlaceholderText("Enter API key"), "sk-ant-api03-settings"); + await settingsModalUser.click(within(apiKeyCard).getByRole("button", { name: "Save" })); - expect(mockSaveApiKey).toHaveBeenCalledWith("anthropic", "sk-ant-api03-settings"); + expect(mockSaveApiKey).toHaveBeenCalledWith("anthropic-api-key", "sk-ant-api03-settings"); }); - it("renders Login and Clear for an Anthropic API-key-only card", async () => { + it("renders API-key clear separately from Anthropic subscription logout", async () => { mockFetchAuthStatus.mockResolvedValueOnce({ - providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", supportsApiKey: true, keyHint: "sk-•••••1234" }], + providers: [ + { id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: true, type: "oauth" }, + { id: "anthropic-api-key", name: "Anthropic API Key", authenticated: true, type: "api_key", keyHint: "sk-•••••dkey" }, + ], }); render(); await settingsModalUser.click(await screen.findByRole("button", { name: "Authentication" })); - const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement; - expect(within(anthropicCard).getByRole("button", { name: "Login" })).toBeInTheDocument(); - expect(within(anthropicCard).queryByRole("button", { name: "Logout" })).not.toBeInTheDocument(); - expect(within(anthropicCard).getByText("Key: sk-•••••1234")).toBeInTheDocument(); - await settingsModalUser.click(within(anthropicCard).getByRole("button", { name: "Clear" })); + const subscriptionCard = screen.getByTestId("auth-provider-icon-anthropic-subscription").closest(".auth-provider-card") as HTMLElement; + const apiKeyCard = screen.getByTestId("auth-provider-icon-anthropic-api-key").closest(".auth-provider-card") as HTMLElement; + expect(within(subscriptionCard).getByRole("button", { name: "Logout" })).toBeInTheDocument(); + expect(within(subscriptionCard).queryByRole("button", { name: "Clear" })).not.toBeInTheDocument(); + expect(within(apiKeyCard).getByText("Key: sk-•••••dkey")).toBeInTheDocument(); + await settingsModalUser.click(within(apiKeyCard).getByRole("button", { name: "Clear" })); - expect(mockClearApiKey).toHaveBeenCalledWith("anthropic"); - }); - - it("renders Clear beside OAuth controls for stored Anthropic API keys", async () => { - mockFetchAuthStatus.mockResolvedValueOnce({ - providers: [{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth", supportsApiKey: true, keyHint: "sk-•••••dkey" }], - }); - - render(); - await settingsModalUser.click(await screen.findByRole("button", { name: "Authentication" })); - - const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement; - expect(within(anthropicCard).getByRole("button", { name: "Logout" })).toBeInTheDocument(); - expect(within(anthropicCard).getByText("Key: sk-•••••dkey")).toBeInTheDocument(); - await settingsModalUser.click(within(anthropicCard).getByRole("button", { name: "Clear" })); - - expect(mockClearApiKey).toHaveBeenCalledWith("anthropic"); + expect(mockClearApiKey).toHaveBeenCalledWith("anthropic-api-key"); }); it("scrolls settings content to top after API key save succeeds", async () => { diff --git a/packages/dashboard/app/components/__tests__/onboarding-flow.test.tsx b/packages/dashboard/app/components/__tests__/onboarding-flow.test.tsx index 60c1b324f1..0465444098 100644 --- a/packages/dashboard/app/components/__tests__/onboarding-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/onboarding-flow.test.tsx @@ -503,31 +503,42 @@ describe("onboarding flow integration", () => { expect(screen.getByTestId("claude-cli-provider-card")).toHaveAttribute("data-authenticated", "false"); }); - it("renders dual Anthropic OAuth and API-key controls in onboarding", async () => { + it("renders separate Anthropic OAuth and API-key controls in onboarding", async () => { mockFetchAuthStatus.mockResolvedValue({ providers: [ - { id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", supportsApiKey: true }, + { id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" }, + { id: "anthropic-api-key", name: "Anthropic API Key", authenticated: false, type: "api_key" }, + { id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" }, { id: "openai", name: "OpenAI", authenticated: false, type: "api_key" }, ], }); - renderModal(); + const { container } = renderModal(); await waitFor(() => { - expect(screen.getByTestId("onboarding-apikey-input-anthropic")).toBeInTheDocument(); + expect(screen.getByTestId("onboarding-apikey-input-anthropic-api-key")).toBeInTheDocument(); }); - const anthropicCard = screen.getByTestId("onboarding-provider-card-anthropic"); - expect(within(anthropicCard).getByRole("button", { name: "Login" })).toBeInTheDocument(); + const subscriptionCard = screen.getByTestId("onboarding-provider-card-anthropic-subscription"); + const anthropicCard = screen.getByTestId("onboarding-provider-card-anthropic-api-key"); + const renderedAnthropicAuthCards = container.querySelectorAll( + '[data-testid^="onboarding-provider-card-anthropic"]', + ); + expect(renderedAnthropicAuthCards).toHaveLength(2); + expect(screen.queryByTestId("onboarding-provider-card-anthropic")).not.toBeInTheDocument(); + expect(within(subscriptionCard).getByRole("button", { name: "Login" })).toBeInTheDocument(); + expect(within(subscriptionCard).queryByTestId("onboarding-apikey-input-anthropic-subscription")).not.toBeInTheDocument(); + expect(within(anthropicCard).queryByRole("button", { name: "Login" })).not.toBeInTheDocument(); + expect(screen.getByTestId("onboarding-apikey-input-anthropic-api-key")).toBeInTheDocument(); expect(screen.getByTestId("onboarding-apikey-input-openai")).toBeInTheDocument(); - fireEvent.change(screen.getByTestId("onboarding-apikey-input-anthropic"), { + fireEvent.change(screen.getByTestId("onboarding-apikey-input-anthropic-api-key"), { target: { value: "sk-ant-api03-flow-test" }, }); - fireEvent.click(screen.getByTestId("onboarding-apikey-save-anthropic")); + fireEvent.click(screen.getByTestId("onboarding-apikey-save-anthropic-api-key")); await waitFor(() => { - expect(mockSaveApiKey).toHaveBeenCalledWith("anthropic", "sk-ant-api03-flow-test"); + expect(mockSaveApiKey).toHaveBeenCalledWith("anthropic-api-key", "sk-ant-api03-flow-test"); }); }); diff --git a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx index d39b45e7cc..fe1f18d920 100644 --- a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx @@ -47,7 +47,10 @@ vi.mock("../../api", () => ({ fetchSettingsByScope: vi.fn(() => Promise.resolve({ global: { ...defaultSettings }, project: {} })), updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })), updateGlobalSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })), - fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", supportsApiKey: true }] })), + fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [ + { id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" }, + { id: "anthropic-api-key", name: "Anthropic API Key", authenticated: false, type: "api_key" }, + ] })), loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })), logoutProvider: vi.fn(() => Promise.resolve({ success: true })), saveApiKey: vi.fn(() => Promise.resolve({ success: true })), @@ -375,7 +378,7 @@ describe("SettingsModal mobile adaptations", () => { expect(getByText("These settings are shared across all your Fusion projects.")).toBeTruthy(); }); - it("renders dual Anthropic Authentication controls on mobile", async () => { + it("renders separate Anthropic Authentication controls on mobile", async () => { mockSettingsViewport(true); Object.defineProperty(window, "innerWidth", { configurable: true, value: 375 }); const user = userEvent.setup(); @@ -383,10 +386,12 @@ describe("SettingsModal mobile adaptations", () => { await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); await user.selectOptions(getByLabelText("Settings Section"), "authentication"); - const card = (await findByTestId("auth-provider-icon-anthropic")).closest(".auth-provider-card") as HTMLElement; - expect(within(card).getByRole("button", { name: "Login" })).toBeTruthy(); - expect(within(card).getByPlaceholderText("Enter API key")).toBeTruthy(); - expect(within(card).getByRole("button", { name: "Save" })).toBeTruthy(); + const subscriptionCard = (await findByTestId("auth-provider-icon-anthropic-subscription")).closest(".auth-provider-card") as HTMLElement; + const apiKeyCard = (await findByTestId("auth-provider-icon-anthropic-api-key")).closest(".auth-provider-card") as HTMLElement; + expect(within(subscriptionCard).getByRole("button", { name: "Login" })).toBeTruthy(); + expect(within(subscriptionCard).queryByPlaceholderText("Enter API key")).toBeNull(); + expect(within(apiKeyCard).getByPlaceholderText("Enter API key")).toBeTruthy(); + expect(within(apiKeyCard).getByRole("button", { name: "Save" })).toBeTruthy(); }); it("renders notification provider cards responsively on mobile", async () => { diff --git a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx index ca37e87a12..d657201158 100644 --- a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx +++ b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx @@ -47,10 +47,18 @@ export interface AuthenticationSectionProps { 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; // CLI-backed providers render their own compact card; filter them out of the // standard OAuth/API-key sort and render alongside. - const cliAuthProviders = authProviders.filter((p) => p.type === "cli"); - const nonCliProviders = authProviders.filter((p) => p.type !== "cli"); + const cliAuthProviders = visibleAuthProviders.filter((p) => p.type === "cli"); + const nonCliProviders = visibleAuthProviders.filter((p) => p.type !== "cli"); const sortedProviders = [...nonCliProviders].sort((a, b) => { if (a.authenticated !== b.authenticated) { return a.authenticated ? -1 : 1; @@ -79,7 +87,7 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) { (claudeCliProvider && !claudeCliProvider.authenticated) || (cursorCliProvider && !cursorCliProvider.authenticated) || (llamaCppProvider && !llamaCppProvider.authenticated); - const providerSupportsApiKey = (provider: AuthProvider) => provider.type === "api_key" || provider.supportsApiKey === true; + const providerSupportsApiKey = (provider: AuthProvider) => provider.type === "api_key"; const renderApiKeySection = (provider: AuthProvider) => (
setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} disabled={authActionInProgress === provider.id}/> @@ -146,9 +154,9 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) { {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-28-16:02: - A provider can be dual-auth: Anthropic keeps its OAuth login controls while also accepting an `ANTHROPIC_API_KEY` stored through the same API-key row as standalone providers. - Render both intentional controls on one card so Settings does not create duplicate provider cards or orphaned action wrappers. + 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")}

diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index 76d3f4ab71..de5256b380 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -709,7 +709,7 @@ describe("GET /auth/status", () => { expect(res.status).toBe(200); const openAiCodex = res.body.providers.find((p: any) => p.id === "openai-codex"); - const anthropic = res.body.providers.find((p: any) => p.id === "anthropic"); + const anthropic = res.body.providers.find((p: any) => p.id === "anthropic-subscription"); const githubCopilot = res.body.providers.find((p: any) => p.id === "github-copilot"); const openrouter = res.body.providers.find((p: any) => p.id === "openrouter"); const claudeCli = res.body.providers.find((p: any) => p.id === "claude-cli"); @@ -769,13 +769,13 @@ describe("GET /auth/status", () => { (authStorage.getOAuthProviders as ReturnType).mockReturnValue([ { id: "anthropic", name: "Anthropic" }, ]); - (authStorage.hasAuth as ReturnType).mockImplementation((provider: string) => provider === "anthropic"); - (authStorage.get as ReturnType).mockImplementation(() => ({ + (authStorage.hasAuth as ReturnType).mockImplementation((provider: string) => provider === "anthropic-subscription"); + (authStorage.get as ReturnType).mockImplementation((provider: string) => provider === "anthropic-subscription" ? ({ type: "oauth", access: refreshed ? "refreshed-token" : "expired-token", refresh: "refresh", expires: refreshed ? now + 3_600_000 : now - 1_000, - })); + }) : undefined); (authStorage.getApiKey as ReturnType).mockImplementation(async () => { refreshed = true; return "refreshed-token"; @@ -784,8 +784,8 @@ describe("GET /auth/status", () => { const res = await GET(app, "/api/auth/status"); expect(res.status).toBe(200); - const anthropic = res.body.providers.find((p: any) => p.id === "anthropic"); - expect(authStorage.getApiKey).toHaveBeenCalledWith("anthropic"); + const anthropic = res.body.providers.find((p: any) => p.id === "anthropic-subscription"); + expect(authStorage.getApiKey).toHaveBeenCalledWith("anthropic-subscription"); expect(anthropic).toMatchObject({ authenticated: true, expired: false }); }); @@ -794,20 +794,20 @@ describe("GET /auth/status", () => { (authStorage.getOAuthProviders as ReturnType).mockReturnValue([ { id: "anthropic", name: "Anthropic" }, ]); - (authStorage.hasAuth as ReturnType).mockImplementation((provider: string) => provider === "anthropic"); - (authStorage.get as ReturnType).mockReturnValue({ + (authStorage.hasAuth as ReturnType).mockImplementation((provider: string) => provider === "anthropic-subscription"); + (authStorage.get as ReturnType).mockImplementation((provider: string) => provider === "anthropic-subscription" ? ({ type: "oauth", access: "expired-token", refresh: "refresh", expires: now - 1_000, - }); + }) : undefined); (authStorage.getApiKey as ReturnType).mockRejectedValue(new Error("refresh failed")); const res = await GET(app, "/api/auth/status"); expect(res.status).toBe(200); - const anthropic = res.body.providers.find((p: any) => p.id === "anthropic"); - expect(authStorage.getApiKey).toHaveBeenCalledWith("anthropic"); + const anthropic = res.body.providers.find((p: any) => p.id === "anthropic-subscription"); + expect(authStorage.getApiKey).toHaveBeenCalledWith("anthropic-subscription"); expect(anthropic).toMatchObject({ authenticated: false, expired: true }); }); @@ -848,57 +848,68 @@ describe("GET /auth/status", () => { expect(openrouter.type).toBe("api_key"); }); - it("marks an OAuth provider as supporting API keys when provider ids collide", async () => { + it("separates Anthropic subscription OAuth from Anthropic API-key status when ids collide", async () => { (authStorage.getOAuthProviders as ReturnType).mockReturnValue([ { id: "anthropic", name: "Anthropic" }, ]); (authStorage.getApiKeyProviders as ReturnType).mockReturnValue([ - { id: "anthropic", name: "Anthropic" }, + { id: "anthropic-api-key", name: "Anthropic API Key" }, ]); (authStorage.hasAuth as ReturnType).mockReturnValue(false); - (authStorage.hasApiKey as ReturnType).mockImplementation((provider: string) => provider === "anthropic"); + (authStorage.hasApiKey as ReturnType).mockImplementation((provider: string) => provider === "anthropic-api-key"); (authStorage.get as ReturnType).mockImplementation((provider: string) => ( - provider === "anthropic" ? { type: "api_key", key: "sk-ant-api03-abcdef1234" } : undefined + provider === "anthropic-api-key" ? { type: "api_key", key: "sk-ant-api03-abcdef1234" } : undefined )); const res = await GET(app, "/api/auth/status"); expect(res.status).toBe(200); - const anthropicProviders = res.body.providers.filter((p: any) => p.id === "anthropic"); - expect(anthropicProviders).toHaveLength(1); - expect(anthropicProviders[0]).toMatchObject({ - id: "anthropic", - name: "Anthropic", - authenticated: true, + const subscription = res.body.providers.find((p: any) => p.id === "anthropic-subscription"); + const apiKey = res.body.providers.find((p: any) => p.id === "anthropic-api-key"); + expect(subscription).toMatchObject({ + id: "anthropic-subscription", + name: "Anthropic Subscription", + authenticated: false, type: "oauth", - supportsApiKey: true, - keyHint: "sk-•••••1234", requiresManualCode: true, }); + expect(subscription).not.toHaveProperty("supportsApiKey"); + expect(apiKey).toMatchObject({ + id: "anthropic-api-key", + name: "Anthropic API Key", + authenticated: true, + type: "api_key", + keyHint: "sk-•••••1234", + }); }); - it("preserves OAuth authentication while surfacing a stored dual-provider API key", async () => { + it("preserves Anthropic OAuth authentication while surfacing a separate stored API key", async () => { (authStorage.getOAuthProviders as ReturnType).mockReturnValue([ { id: "anthropic", name: "Anthropic" }, ]); (authStorage.getApiKeyProviders as ReturnType).mockReturnValue([ - { id: "anthropic", name: "Anthropic" }, + { id: "anthropic-api-key", name: "Anthropic API Key" }, ]); - (authStorage.hasAuth as ReturnType).mockImplementation((provider: string) => provider === "anthropic"); - (authStorage.hasApiKey as ReturnType).mockImplementation((provider: string) => provider === "anthropic"); + (authStorage.hasAuth as ReturnType).mockImplementation((provider: string) => provider === "anthropic-subscription"); + (authStorage.hasApiKey as ReturnType).mockImplementation((provider: string) => provider === "anthropic-api-key"); (authStorage.get as ReturnType).mockImplementation((provider: string) => ( - provider === "anthropic" ? { type: "api_key", key: "sk-ant-api03-oauthandkey" } : undefined + provider === "anthropic-api-key" ? { type: "api_key", key: "sk-ant-api03-oauthandkey" } : undefined )); const res = await GET(app, "/api/auth/status"); expect(res.status).toBe(200); - const anthropicProviders = res.body.providers.filter((p: any) => p.id === "anthropic"); - expect(anthropicProviders).toHaveLength(1); - expect(anthropicProviders[0]).toMatchObject({ + const subscription = res.body.providers.find((p: any) => p.id === "anthropic-subscription"); + const apiKey = res.body.providers.find((p: any) => p.id === "anthropic-api-key"); + expect(subscription).toMatchObject({ authenticated: true, type: "oauth", - supportsApiKey: true, + }); + expect(subscription).not.toHaveProperty("keyHint"); + expect(subscription).not.toHaveProperty("supportsApiKey"); + expect(apiKey).toMatchObject({ + authenticated: true, + type: "api_key", keyHint: "sk-•••••dkey", }); }); @@ -1596,7 +1607,7 @@ describe("POST /auth/login", () => { }); }); - it("returns manual-code flow for anthropic and skips callback rewrite on remote hosts", async () => { + it("maps Anthropic subscription login to upstream anthropic OAuth and skips callback rewrite", async () => { const unchangedUrl = "https://claude.ai/oauth/authorize?state=anthropic-state&redirect_uri=http%3A%2F%2Flocalhost%3A3210%2Fauth%2Fcallback"; @@ -1612,10 +1623,11 @@ describe("POST /auth/login", () => { buildApp(), "POST", "/api/auth/login", - JSON.stringify({ provider: "anthropic", origin: "https://my-host.example.com" }), + JSON.stringify({ provider: "anthropic-subscription", origin: "https://my-host.example.com" }), { "Content-Type": "application/json" }, ); + expect(authStorage.login).toHaveBeenCalledWith("anthropic", expect.any(Object)); expect(res.status).toBe(200); expect(res.body.url).toBe(unchangedUrl); expect(res.body.instructions).toContain("After Claude sign-in"); @@ -2162,17 +2174,32 @@ describe("POST /auth/api-key", () => { it("saves an Anthropic API key when Anthropic is also an OAuth provider", async () => { (authStorage.getApiKeyProviders as ReturnType).mockReturnValue([ - { id: "anthropic", name: "Anthropic" }, + { id: "anthropic-api-key", name: "Anthropic API Key" }, ]); const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({ - provider: "anthropic", + provider: "anthropic-api-key", apiKey: " sk-ant-api03-test-key ", }), { "Content-Type": "application/json" }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); - expect(authStorage.setApiKey).toHaveBeenCalledWith("anthropic", "sk-ant-api03-test-key"); + expect(authStorage.setApiKey).toHaveBeenCalledWith("anthropic-api-key", "sk-ant-api03-test-key"); + }); + + it("returns 400 and does not save when Anthropic subscription OAuth is submitted as an API-key provider", async () => { + (authStorage.getApiKeyProviders as ReturnType).mockReturnValue([ + { id: "anthropic-api-key", name: "Anthropic API Key" }, + ]); + + const res = await REQUEST(buildApp(), "POST", "/api/auth/api-key", JSON.stringify({ + provider: "anthropic-subscription", + apiKey: "sk-ant-api03-wrong-card", + }), { "Content-Type": "application/json" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("Unknown API key provider"); + expect(authStorage.setApiKey).not.toHaveBeenCalled(); }); it("returns 400 when provider is missing", async () => { @@ -2348,6 +2375,16 @@ describe("DELETE /auth/api-key", () => { expect(res.body.error).toBe("provider is required"); }); + it("returns 400 and does not clear when provider is not API-key-backed", async () => { + const res = await REQUEST(buildApp(), "DELETE", "/api/auth/api-key", JSON.stringify({ + provider: "anthropic-subscription", + }), { "Content-Type": "application/json" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("Unknown API key provider"); + expect(authStorage.clearApiKey).not.toHaveBeenCalled(); + }); + it("returns 400 when storage does not support API keys", async () => { const storageWithoutApiKeys = createMockAuthStorage({ clearApiKey: undefined, diff --git a/packages/dashboard/src/routes/register-auth-routes.ts b/packages/dashboard/src/routes/register-auth-routes.ts index 6e9d0a9f22..a8e9d736ba 100644 --- a/packages/dashboard/src/routes/register-auth-routes.ts +++ b/packages/dashboard/src/routes/register-auth-routes.ts @@ -147,7 +147,32 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { }; } + const ANTHROPIC_OAUTH_PROVIDER_ID = "anthropic"; + const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription"; + + function toOauthLoginProviderId(providerId: string): string { + return providerId === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID ? ANTHROPIC_OAUTH_PROVIDER_ID : providerId; + } + + function toOauthCredentialProviderId(providerId: string): string { + return providerId === ANTHROPIC_OAUTH_PROVIDER_ID ? ANTHROPIC_SUBSCRIPTION_PROVIDER_ID : providerId; + } + + function toAuthStatusProvider(provider: { id: string; name: string }): { id: string; name: string } { + if (provider.id !== ANTHROPIC_OAUTH_PROVIDER_ID) { + return provider; + } + /* + FNXC:ProviderAuth 2026-06-29-22:12: + Anthropic subscription OAuth and raw `ANTHROPIC_API_KEY` credentials share the upstream auth id `anthropic`, but dashboard users need separate cards so saving or clearing an API key never appears to replace Claude subscription login. + Expose OAuth through a synthetic UI id and map it back only at route boundaries. + */ + return { id: ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, name: "Anthropic Subscription" }; + } + function shouldRewriteOauthRedirect(providerId: string, origin: string | undefined): boolean { + const storageProviderId = toOauthLoginProviderId(providerId); + if (!origin || isLocalhostOrigin(origin)) { return false; } @@ -155,7 +180,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { // These providers do not use a redirect_uri-based callback: // - openai-codex, anthropic: pasted-code UX with their own localhost callbacks // - github-copilot: OAuth device-code flow (verification_uri has no state/redirect_uri) - if (providerId === "openai-codex" || providerId === "anthropic" || providerId === "github-copilot") { + if (storageProviderId === "openai-codex" || storageProviderId === "anthropic" || storageProviderId === "github-copilot") { return false; } @@ -272,12 +297,13 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { type: "oauth" | "api_key" | "cli"; expired?: boolean; keyHint?: string; - supportsApiKey?: boolean; loginInProgress?: boolean; requiresManualCode?: boolean; }[] = await Promise.all(oauthProviders.map(async (p) => { - let hasAuth = storage.hasAuth(p.id); - let expired = hasAuth && isExpiredOauthCredential(p.id, storage); + const statusProvider = toAuthStatusProvider(p); + const storageProviderId = toOauthCredentialProviderId(statusProvider.id); + let hasAuth = storage.hasAuth(storageProviderId); + let expired = hasAuth && isExpiredOauthCredential(storageProviderId, storage); if (expired && storage.getApiKey) { /* FNXC:ClaudeOAuth 2026-06-13-22:46: @@ -285,21 +311,21 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { Keep this best-effort so providers without refresh support still report expired and ask the user to re-authenticate. */ try { - await storage.getApiKey(p.id); + await storage.getApiKey(storageProviderId); } catch { // Best-effort refresh only; preserve the expired status below. } - hasAuth = storage.hasAuth(p.id); - expired = hasAuth && isExpiredOauthCredential(p.id, storage); + hasAuth = storage.hasAuth(storageProviderId); + expired = hasAuth && isExpiredOauthCredential(storageProviderId, storage); } return { - id: p.id, - name: p.name, + id: statusProvider.id, + name: statusProvider.name, authenticated: hasAuth && !expired, type: "oauth" as const, expired, - loginInProgress: loginInProgress.has(p.id), - requiresManualCode: getManualCodeConfig(p.id, origin) !== undefined || undefined, + loginInProgress: loginInProgress.has(statusProvider.id), + requiresManualCode: getManualCodeConfig(toOauthLoginProviderId(statusProvider.id), origin) !== undefined || undefined, }; })); @@ -314,20 +340,6 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { keyHint = maskApiKey(cred.key); } } - const existing = providers.find((provider) => provider.id === p.id); - if (existing) { - /* - FNXC:ProviderAuth 2026-06-28-15:58: - Anthropic can be authenticated by either OAuth or `ANTHROPIC_API_KEY`, so `/auth/status` must expose one dual-auth card instead of duplicating the provider or hiding the key row. - Mark API-key-only credentials authenticated here because settings groups cards solely by `authenticated`. - */ - existing.supportsApiKey = true; - existing.keyHint = keyHint; - if (!existing.authenticated && storage.hasApiKey) { - existing.authenticated = storage.hasApiKey(p.id); - } - continue; - } providers.push({ id: p.id, name: p.name, @@ -847,6 +859,8 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { throw badRequest("origin must be a string when provided"); } + const storageProvider = toOauthLoginProviderId(provider); + // Prevent concurrent logins for the same provider if (loginInProgress.has(provider)) { throw conflict(`Login already in progress for ${provider}`); @@ -854,10 +868,11 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { const storage = getAuthStorage(); const oauthProviders = storage.getOAuthProviders(); - const found = oauthProviders.find((p) => p.id === provider); + const found = oauthProviders.find((p) => p.id === provider || p.id === storageProvider); if (!found) { throw badRequest(`Unknown provider: ${provider}`); } + const loginProvider = found.id === provider ? provider : storageProvider; const abortController = new AbortController(); let resolveInput: (value: string) => void = () => {}; @@ -881,7 +896,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { resolveInput, rejectInput, inputSubmitted: false, - manualCode: getManualCodeConfig(provider, origin), + manualCode: getManualCodeConfig(storageProvider, origin), }; loginInProgress.set(provider, pendingLogin); @@ -910,11 +925,11 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { let resolvedDeviceCode: DeviceCodeInfo | undefined; // Start login flow in background — don't await the full login - const loginPromise = storage.login(provider, { + const loginPromise = storage.login(loginProvider, { onAuth: (info) => { if (!resolvedDeviceCode) { const parsedUserCode = - provider === "github-copilot" && info.instructions + storageProvider === "github-copilot" && info.instructions ? parseGitHubCopilotDeviceCode(info.instructions) : undefined; if (parsedUserCode) { @@ -927,7 +942,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { resolveAuthInfo({ url: info.url, - instructions: appendManualCodeHint(info.instructions, provider, origin), + instructions: appendManualCodeHint(info.instructions, storageProvider, origin), deviceCode: resolvedDeviceCode, }); }, @@ -939,12 +954,12 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { resolveAuthInfo({ url: info.verificationUri, - instructions: appendManualCodeHint(undefined, provider, origin), + instructions: appendManualCodeHint(undefined, storageProvider, origin), deviceCode: resolvedDeviceCode, }); }, onPrompt: async (_prompt) => { - if (providerWantsAutoPrompt(provider) && !autoPromptConsumed) { + if (providerWantsAutoPrompt(storageProvider) && !autoPromptConsumed) { autoPromptConsumed = true; return ""; } @@ -955,7 +970,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { // to race pasted codes against the localhost callback server. onManualCodeInput: async () => await pendingLogin.inputPromise, onProgress: () => {}, // no-op for web UI - onSelect: async (prompt) => selectOauthOption(provider, prompt), + onSelect: async (prompt) => selectOauthOption(storageProvider, prompt), signal: abortController.signal, }); @@ -1131,7 +1146,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { } const storage = getAuthStorage(); - storage.logout(provider); + storage.logout(toOauthCredentialProviderId(provider)); clearUsageCache(); res.json({ success: true }); } catch (err: unknown) { @@ -1225,6 +1240,16 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { throw badRequest("API key management is not supported"); } + /* + FNXC:ProviderAuth 2026-06-29-23:55: + API-key save and clear must share the same provider-id allowlist so separated OAuth cards such as `anthropic-subscription` cannot accidentally clear raw API-key storage. + */ + const apiKeyProviders = storage.getApiKeyProviders?.() ?? []; + const found = apiKeyProviders.find((p) => p.id === provider); + if (!found) { + throw badRequest(`Unknown API key provider: ${provider}`); + } + storage.clearApiKey(provider); // No model refresh needed on delete: removing the key leaves nothing to sync. clearUsageCache();