From 0bdf5ee4f3393d2d52448df0290da8129c141515 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 12:30:47 +0000 Subject: [PATCH 1/2] fix(providers): hide API key form for OAuth-only providers Only show the API key credential UI when a provider declares API auth (or auth methods are still unknown). OAuth-only plugin providers such as Cursor now show Connect/OAuth only, load auth methods on reconnect, and skip an empty models section until models are discovered. Co-authored-by: Serhii Dziupin --- .../sections/providers/ProvidersPage.test.ts | 51 +++ .../sections/providers/ProvidersPage.tsx | 359 +++++++++--------- .../sections/providers/providerAuth.ts | 57 +++ 3 files changed, 278 insertions(+), 189 deletions(-) create mode 100644 packages/ui/src/components/sections/providers/providerAuth.ts diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.test.ts b/packages/ui/src/components/sections/providers/ProvidersPage.test.ts index e45fb969..448dcc6e 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.test.ts +++ b/packages/ui/src/components/sections/providers/ProvidersPage.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from 'bun:test'; import { shouldLoadAvailableProviders } from './providerAvailability'; +import { + getOAuthAuthMethods, + normalizeAuthType, + parseAuthPayload, + shouldShowApiKeyAuth, +} from './providerAuth'; describe('ProvidersPage available provider loading', () => { test('loads available providers only in add-provider mode', () => { @@ -7,3 +13,48 @@ describe('ProvidersPage available provider loading', () => { expect(shouldLoadAvailableProviders(true)).toBe(true); }); }); + +describe('provider auth method helpers', () => { + test('normalizeAuthType recognizes oauth and api labels', () => { + expect(normalizeAuthType({ type: 'oauth', label: 'Login with Cursor' })).toBe('oauth'); + expect(normalizeAuthType({ type: 'api', label: 'API Key' })).toBe('api'); + expect(normalizeAuthType({ label: 'OAuth browser login' })).toBe('oauth'); + expect(normalizeAuthType({ name: 'API key' })).toBe('api'); + }); + + test('parseAuthPayload keeps only object auth method entries', () => { + expect(parseAuthPayload({ + cursor: [{ type: 'oauth', label: 'Cursor' }, 'skip'], + openai: null, + })).toEqual({ + cursor: [{ type: 'oauth', label: 'Cursor' }], + }); + expect(parseAuthPayload(null)).toEqual({}); + }); + + test('shouldShowApiKeyAuth hides API key for oauth-only providers', () => { + expect(shouldShowApiKeyAuth([{ type: 'oauth', label: 'Cursor OAuth' }])).toBe(false); + expect(shouldShowApiKeyAuth([ + { type: 'api', label: 'API Key' }, + { type: 'oauth', label: 'ChatGPT' }, + ])).toBe(true); + expect(shouldShowApiKeyAuth([{ type: 'api', label: 'API Key' }])).toBe(true); + // Unknown / unloaded methods keep the legacy API key fallback. + expect(shouldShowApiKeyAuth([])).toBe(true); + }); + + test('getOAuthAuthMethods preserves original method indexes', () => { + const methods = [ + { type: 'api', label: 'API Key' }, + { type: 'oauth', label: 'OAuth' }, + { type: 'oauth', label: 'Device' }, + ]; + expect(getOAuthAuthMethods(methods)).toEqual([ + { method: methods[1], methodIndex: 1 }, + { method: methods[2], methodIndex: 2 }, + ]); + expect(getOAuthAuthMethods([{ type: 'oauth', label: 'Cursor' }])).toEqual([ + { method: { type: 'oauth', label: 'Cursor' }, methodIndex: 0 }, + ]); + }); +}); diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index de947250..222b3577 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -26,6 +26,12 @@ import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { opencodeClient } from '@/lib/opencode/client'; import { shouldLoadAvailableProviders } from './providerAvailability'; +import { + getOAuthAuthMethods, + parseAuthPayload, + shouldShowApiKeyAuth, + type AuthMethod, +} from './providerAuth'; import { CustomProviderForm } from './CustomProviderForm'; import { buildAuthSetRequest, @@ -59,16 +65,6 @@ const formatTokens = (value?: number | null) => { const ADD_PROVIDER_ID = '__add_provider__'; -interface AuthMethod { - type?: string; - name?: string; - label?: string; - description?: string; - help?: string; - method?: number; - [key: string]: unknown; -} - interface ProviderOption { id: string; name?: string; @@ -89,28 +85,6 @@ interface ProviderSources { const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; -const normalizeAuthType = (method: AuthMethod) => { - const raw = typeof method.type === 'string' ? method.type : ''; - const label = `${method.name ?? ''} ${method.label ?? ''}`.toLowerCase(); - const merged = `${raw} ${label}`.toLowerCase(); - if (merged.includes('oauth')) return 'oauth'; - if (merged.includes('api')) return 'api'; - return raw.toLowerCase(); -}; - -const parseAuthPayload = (payload: unknown): Record => { - if (!isRecord(payload)) { - return {}; - } - const result: Record = {}; - for (const [providerId, value] of Object.entries(payload)) { - if (Array.isArray(value)) { - result[providerId] = value.filter((entry) => isRecord(entry)) as AuthMethod[]; - } - } - return result; -}; - const normalizeProviderEntry = (entry: unknown): ProviderOption | null => { if (typeof entry === 'string') { return { id: entry }; @@ -205,7 +179,10 @@ export const ProvidersPage: React.FC = () => { }, [providers, selectedProviderId, setSelectedProvider]); React.useEffect(() => { - if (!isAddMode) { + // Auth methods drive which credential UI to show (API key vs OAuth). Load + // them for add-provider and reconnect so OAuth-only providers never fall + // back to an API key form merely because methods were never fetched. + if (!isAddMode && !showAuthPanel) { return; } @@ -236,7 +213,7 @@ export const ProvidersPage: React.FC = () => { return () => { isMounted = false; }; - }, [isAddMode, t]); + }, [isAddMode, showAuthPanel, t]); React.useEffect(() => { if (!shouldLoadAvailableProviders(isAddMode)) { @@ -778,125 +755,126 @@ export const ProvidersPage: React.FC = () => {

{t('settings.providers.page.auth.loadingMethods')}

) : ( <> -
- -
- - setApiKeyInputs((prev) => ({ - ...prev, - [candidateProviderId]: event.target.value, - })) - } - placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')} - className="flex-1 font-mono text-xs" - /> - -
-
- {(() => { const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? []; - const candidateOAuthMethods = candidateAuthMethods.filter( - (method) => normalizeAuthType(method) === 'oauth' - ); - - if (candidateOAuthMethods.length === 0) { - return null; - } + const candidateOAuthMethods = getOAuthAuthMethods(candidateAuthMethods); + const showApiKey = shouldShowApiKeyAuth(candidateAuthMethods); return ( -
- {candidateOAuthMethods.map((method, index) => { - const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(index + 1) }); - const codeKey = `${candidateProviderId}:${index}`; - const isPending = - pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === index; + <> + {showApiKey ? ( +
+ +
+ + setApiKeyInputs((prev) => ({ + ...prev, + [candidateProviderId]: event.target.value, + })) + } + placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')} + className="flex-1 font-mono text-xs" + /> + +
+
+ ) : null} - return ( -
-
-
-
{methodLabel}
- {(method.description || method.help) && ( -
- {String(method.description || method.help)} + {candidateOAuthMethods.length > 0 ? ( +
+ {candidateOAuthMethods.map(({ method, methodIndex }) => { + const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) }); + const codeKey = `${candidateProviderId}:${methodIndex}`; + const isPending = + pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === methodIndex; + + return ( +
+
+
+
{methodLabel}
+ {(method.description || method.help) && ( +
+ {String(method.description || method.help)} +
+ )} +
+ +
+ + {oauthDetails[codeKey]?.instructions && ( +

+ {oauthDetails[codeKey]?.instructions} +

+ )} + + {oauthDetails[codeKey]?.userCode && ( +
+ + +
+ )} + + {oauthDetails[codeKey]?.url && ( +
+ +
+ + +
+
+ )} + + {isPending && ( +
+ + setOauthCodes((prev) => ({ + ...prev, + [codeKey]: event.target.value, + })) + } + placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')} + className="font-mono text-xs" + /> +
)}
- -
- - {oauthDetails[codeKey]?.instructions && ( -

- {oauthDetails[codeKey]?.instructions} -

- )} - - {oauthDetails[codeKey]?.userCode && ( -
- - -
- )} - - {oauthDetails[codeKey]?.url && ( -
- -
- - -
-
- )} - - {isPending && ( -
- - setOauthCodes((prev) => ({ - ...prev, - [codeKey]: event.target.value, - })) - } - placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')} - className="font-mono text-xs" - /> - -
- )} -
- ); - })} -
+ ); + })} +
+ ) : null} + ); })()} @@ -921,7 +899,8 @@ export const ProvidersPage: React.FC = () => { const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : []; const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? []; - const oauthAuthMethods = providerAuthMethods.filter((method) => normalizeAuthType(method) === 'oauth'); + const oauthAuthMethods = getOAuthAuthMethods(providerAuthMethods); + const showApiKeyAuth = shouldShowApiKeyAuth(providerAuthMethods); const sourcesLoaded = Boolean(selectedSources); const isEditableCustomProvider = sourcesLoaded && isConfigDefinedCustomProvider(selectedProvider, selectedSources); @@ -1027,45 +1006,47 @@ export const ProvidersPage: React.FC = () => {
{t('settings.providers.page.auth.loadingMethods')}
) : (
-
- -
- - setApiKeyInputs((prev) => ({ - ...prev, - [selectedProvider.id]: event.target.value, - })) - } - placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')} - className="flex-1 font-mono text-xs" - /> - + {showApiKeyAuth ? ( +
+ +
+ + setApiKeyInputs((prev) => ({ + ...prev, + [selectedProvider.id]: event.target.value, + })) + } + placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')} + className="flex-1 font-mono text-xs" + /> + +
-
+ ) : null} {oauthAuthMethods.length > 0 && ( -
- {oauthAuthMethods.map((method, index) => { - const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(index + 1) }); - const codeKey = `${selectedProvider.id}:${index}`; +
+ {oauthAuthMethods.map(({ method, methodIndex }) => { + const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) }); + const codeKey = `${selectedProvider.id}:${methodIndex}`; const isPending = - pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === index; + pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === methodIndex; return ( -
+
{methodLabel}
@@ -1079,8 +1060,8 @@ export const ProvidersPage: React.FC = () => { variant="outline" size="xs" className="!font-normal" - onClick={() => handleOAuthStart(selectedProvider.id, index)} - disabled={authBusyKey === `oauth:${selectedProvider.id}:${index}`} + onClick={() => handleOAuthStart(selectedProvider.id, methodIndex)} + disabled={authBusyKey === `oauth:${selectedProvider.id}:${methodIndex}`} > {t('settings.providers.page.actions.connect')} @@ -1125,10 +1106,10 @@ export const ProvidersPage: React.FC = () => {
)} @@ -1175,14 +1156,13 @@ export const ProvidersPage: React.FC = () => {
+ {providerModels.length > 0 ? ( 0 ? ( - - ({providerModels.length}) - - ) : null + + ({providerModels.length}) + } headerAction={(
@@ -1291,6 +1271,7 @@ export const ProvidersPage: React.FC = () => {
)}
+ ) : null} ); }; diff --git a/packages/ui/src/components/sections/providers/providerAuth.ts b/packages/ui/src/components/sections/providers/providerAuth.ts new file mode 100644 index 00000000..5d253e0b --- /dev/null +++ b/packages/ui/src/components/sections/providers/providerAuth.ts @@ -0,0 +1,57 @@ +export interface AuthMethod { + type?: string; + name?: string; + label?: string; + description?: string; + help?: string; + method?: number; + [key: string]: unknown; +} + +export interface OAuthAuthMethodEntry { + method: AuthMethod; + /** Index in the full provider auth-methods array (passed to oauth authorize/callback). */ + methodIndex: number; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +export const normalizeAuthType = (method: AuthMethod): string => { + const raw = typeof method.type === 'string' ? method.type : ''; + const label = `${method.name ?? ''} ${method.label ?? ''}`.toLowerCase(); + const merged = `${raw} ${label}`.toLowerCase(); + if (merged.includes('oauth')) return 'oauth'; + if (merged.includes('api')) return 'api'; + return raw.toLowerCase(); +}; + +export const parseAuthPayload = (payload: unknown): Record => { + if (!isRecord(payload)) { + return {}; + } + const result: Record = {}; + for (const [providerId, value] of Object.entries(payload)) { + if (Array.isArray(value)) { + result[providerId] = value.filter((entry) => isRecord(entry)) as AuthMethod[]; + } + } + return result; +}; + +/** + * Show the API key form when the provider declares API auth, or when auth + * methods are still unknown (empty). OAuth-only providers must not get an + * API key prompt. + */ +export const shouldShowApiKeyAuth = (methods: AuthMethod[]): boolean => { + if (methods.length === 0) { + return true; + } + return methods.some((method) => normalizeAuthType(method) === 'api'); +}; + +export const getOAuthAuthMethods = (methods: AuthMethod[]): OAuthAuthMethodEntry[] => + methods + .map((method, methodIndex) => ({ method, methodIndex })) + .filter(({ method }) => normalizeAuthType(method) === 'oauth'); From 8d8bc8edcb3f4817a8d8091ff29df4a954472ec9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 12:39:16 +0000 Subject: [PATCH 2/2] fix(providers): hide models until credentials exist For OAuth-only providers like Cursor, open the auth panel when credentials are missing and omit the models list until auth/env credentials are present so placeholder catalog entries are not shown before login. Co-authored-by: Serhii Dziupin --- .../sections/providers/ProvidersPage.tsx | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index 222b3577..008b04e2 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -179,10 +179,10 @@ export const ProvidersPage: React.FC = () => { }, [providers, selectedProviderId, setSelectedProvider]); React.useEffect(() => { - // Auth methods drive which credential UI to show (API key vs OAuth). Load - // them for add-provider and reconnect so OAuth-only providers never fall + // Auth methods drive which credential UI to show (API key vs OAuth). Keep + // them loaded for the active provider view so OAuth-only plugins never fall // back to an API key form merely because methods were never fetched. - if (!isAddMode && !showAuthPanel) { + if (!selectedProviderId) { return; } @@ -213,7 +213,7 @@ export const ProvidersPage: React.FC = () => { return () => { isMounted = false; }; - }, [isAddMode, showAuthPanel, t]); + }, [selectedProviderId, t]); React.useEffect(() => { if (!shouldLoadAvailableProviders(isAddMode)) { @@ -300,6 +300,26 @@ export const ProvidersPage: React.FC = () => { } }, [selectedProviderId, editingCustomProviderId]); + // Unauthenticated providers (OAuth-only plugins before login) should open the + // auth panel instead of a false "Connected" summary. + React.useEffect(() => { + if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) { + return; + } + const sources = providerSources[selectedProviderId]; + if (!sources) { + return; + } + const provider = providers.find((entry) => entry.id === selectedProviderId); + const envEntries = Array.isArray(provider?.env) + ? provider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) + : []; + const hasCreds = Boolean(sources.auth.exists) || envEntries.length > 0; + if (!hasCreds) { + setShowAuthPanel(true); + } + }, [selectedProviderId, providerSources, providers]); + React.useEffect(() => { if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) { return; @@ -910,7 +930,11 @@ export const ProvidersPage: React.FC = () => { const hasStoredAuth = Boolean(selectedSources?.auth.exists); const hasEnvCredentials = providerEnv.length > 0; const hasCredentials = hasStoredAuth || hasEnvCredentials; - const authStatusIncomplete = isEditableCustomProvider && !hasCredentials; + const authStatusIncomplete = sourcesLoaded && !hasCredentials; + const showModelsSection = providerModels.length > 0 && (!sourcesLoaded || hasCredentials); + const incompleteAuthHint = !showApiKeyAuth && oauthAuthMethods.length > 0 + ? t('settings.providers.page.auth.useReconnectHint') + : t('settings.providers.page.auth.incompleteHint'); const filteredModels = providerModels.filter((model) => { const name = typeof model?.name === 'string' ? model.name : ''; @@ -993,7 +1017,7 @@ export const ProvidersPage: React.FC = () => {
{t('settings.providers.page.auth.incomplete')} - {t('settings.providers.page.auth.incompleteHint')} + {incompleteAuthHint}
) : (
@@ -1156,7 +1180,7 @@ export const ProvidersPage: React.FC = () => {
- {providerModels.length > 0 ? ( + {showModelsSection ? (