diff --git a/backend/src/routes/oauth.test.ts b/backend/src/routes/oauth.test.ts index ed2ceefdc..78da0a079 100644 --- a/backend/src/routes/oauth.test.ts +++ b/backend/src/routes/oauth.test.ts @@ -126,6 +126,41 @@ describe('oauth routes /auth-methods', () => { }) }) +describe('oauth routes /integrations', () => { + it('returns every integration with its methods and connections', async () => { + const client = createStubOpenCodeClient() + const integrations = [ + integrationFixture({ + id: 'openrouter', + name: 'OpenRouter', + methods: [{ type: 'key' }], + }), + integrationFixture({ + id: 'anthropic', + name: 'Anthropic', + methods: [{ type: 'key' }, { type: 'env', names: ['ANTHROPIC_API_KEY'] }], + connections: [{ type: 'credential', id: 'cred_1', label: 'Anthropic', method: 'key' }], + }), + ] + vi.mocked(client.api.integration.list).mockResolvedValueOnce({ location: LOCATION, data: integrations }) + + const res = await createOAuthApp(client).request('/oauth/integrations') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ integrations }) + }) + + it('returns 502 when the upstream is unreachable', async () => { + const client = createStubOpenCodeClient() + vi.mocked(client.api.integration.list).mockRejectedValueOnce(new ClientError('UnexpectedStatus')) + + const res = await createOAuthApp(client).request('/oauth/integrations') + + expect(res.status).toBe(502) + expect(await res.json()).toEqual({ error: 'Failed to list provider integrations', code: 'ClientError' }) + }) +}) + describe('oauth routes /:id/oauth/authorize', () => { it('starts the V2 attempt with the method and answer and returns it', async () => { const client = createStubOpenCodeClient() diff --git a/backend/src/routes/oauth.ts b/backend/src/routes/oauth.ts index 9af60b6a9..6a6bf5e7f 100644 --- a/backend/src/routes/oauth.ts +++ b/backend/src/routes/oauth.ts @@ -23,6 +23,15 @@ export function createOAuthRoutes(openCodeClient: OpenCodeClient) { } }) + app.get('/integrations', async (c) => { + try { + const integrations = await openCodeClient.api.integration.list() + return c.json({ integrations: integrations.data }) + } catch (error) { + return handleOpenCodeError(c, error, 'Failed to list provider integrations') + } + }) + app.post('/:id/oauth/authorize', async (c) => { try { const body = await c.req.json() diff --git a/frontend/src/api/oauth.ts b/frontend/src/api/oauth.ts index 5546b79b4..a4f753061 100644 --- a/frontend/src/api/oauth.ts +++ b/frontend/src/api/oauth.ts @@ -1,5 +1,5 @@ import { API_BASE_URL } from "@/config" -import type { FormAnswer, IntegrationMethod } from "@opencode-manager/shared/opencode" +import type { FormAnswer, IntegrationInfo, IntegrationMethod } from "@opencode-manager/shared/opencode" import type { OAuthAttemptStatus, OAuthAuthorizeResponse } from "@opencode-manager/shared/schemas" import { fetchWrapper, fetchWrapperVoid } from "./fetchWrapper" @@ -7,6 +7,7 @@ export type { FormAnswer, FormField, FormValue, + IntegrationInfo, IntegrationKeyMethod, IntegrationMethod, IntegrationOAuthMethod, @@ -45,4 +46,11 @@ export const oauthApi = { ) return providers }, + + getIntegrations: async (): Promise => { + const { integrations } = await fetchWrapper<{ integrations: IntegrationInfo[] }>( + `${API_BASE_URL}/api/oauth/integrations` + ) + return integrations + }, } diff --git a/frontend/src/api/providers.ts b/frontend/src/api/providers.ts index e1906dfdc..6ab812c30 100644 --- a/frontend/src/api/providers.ts +++ b/frontend/src/api/providers.ts @@ -32,6 +32,7 @@ export interface Model { export interface Provider { id: string; + integrationID?: string; name: string; models: Model[]; } @@ -83,6 +84,7 @@ export async function getProviders(directory?: string, serverId?: string): Promi return { id: provider.id, + integrationID: provider.integrationID, name: provider.name, models, }; diff --git a/frontend/src/components/settings/ProviderSettings.test.tsx b/frontend/src/components/settings/ProviderSettings.test.tsx index a922cda90..199e9e206 100644 --- a/frontend/src/components/settings/ProviderSettings.test.tsx +++ b/frontend/src/components/settings/ProviderSettings.test.tsx @@ -6,7 +6,7 @@ import { ProviderSettings } from './ProviderSettings' import { getProviders, providerCredentialsApi } from '@/api/providers' import { oauthApi } from '@/api/oauth' import type { Provider } from '@/api/providers' -import type { OAuthAuthorizeResponse } from '@/api/oauth' +import type { IntegrationInfo, IntegrationMethod, OAuthAuthorizeResponse } from '@/api/oauth' import type { ReactNode } from 'react' vi.mock('@/api/providers', () => ({ @@ -22,6 +22,7 @@ vi.mock('@/api/providers', () => ({ vi.mock('@/api/oauth', () => ({ oauthApi: { getAuthMethods: vi.fn(), + getIntegrations: vi.fn(), authorize: vi.fn(), callback: vi.fn(), }, @@ -84,9 +85,10 @@ vi.mock('@/components/ui/delete-dialog', () => ({ }) => (open ? : null), })) -function providerFixture(id: string, name: string, modelCount: number): Provider { +function providerFixture(id: string, name: string, modelCount: number, integrationID?: string): Provider { return { id, + integrationID, name, models: Array.from({ length: modelCount }, (_, index) => ({ id: `model-${index}`, @@ -102,6 +104,33 @@ const oauthProviderWithoutKey = providerFixture('google', 'Google', 3) const apiKeyProviderWithKey = providerFixture('openai', 'OpenAI', 1) const apiKeyProviderWithoutKey = providerFixture('together', 'Together AI', 4) +const oauthMethod: IntegrationMethod = { id: 'oauth', type: 'oauth', label: 'OAuth' } +const keyMethod: IntegrationMethod = { type: 'key', label: 'API key' } +const commandMethod: IntegrationMethod = { + id: 'command', + type: 'command', + label: 'Command', + command: ['example-command'], +} +const envMethod: IntegrationMethod = { type: 'env', names: ['ENV_ONLY_API_KEY'] } + +function integrationFixture( + id: string, + name: string, + methods: IntegrationMethod[], + connected = false, + connectionMethod: 'key' | 'oauth' = 'key', +): IntegrationInfo { + return { + id, + name, + methods, + connections: connected + ? [{ type: 'credential', id: `cred_${id}`, label: name, method: connectionMethod }] + : [], + } +} + function mockProviderData() { vi.mocked(getProviders).mockResolvedValue({ providers: [ @@ -112,11 +141,12 @@ function mockProviderData() { ], models: [], }) - vi.mocked(providerCredentialsApi.list).mockResolvedValue(['anthropic', 'openai']) - vi.mocked(oauthApi.getAuthMethods).mockResolvedValue({ - anthropic: [{ id: 'oauth', type: 'oauth', label: 'OAuth' }], - google: [{ id: 'oauth', type: 'oauth', label: 'OAuth' }], - }) + vi.mocked(oauthApi.getIntegrations).mockResolvedValue([ + integrationFixture('anthropic', 'Anthropic', [oauthMethod], true), + integrationFixture('google', 'Google', [oauthMethod]), + integrationFixture('openai', 'OpenAI', [keyMethod], true), + integrationFixture('together', 'Together AI', [keyMethod]), + ]) } function renderSettings() { @@ -270,11 +300,200 @@ describe('ProviderSettings', () => { }) it('shows the empty state when no OAuth-capable providers exist', async () => { - vi.mocked(oauthApi.getAuthMethods).mockResolvedValue({}) + vi.mocked(getProviders).mockResolvedValue({ providers: [], models: [] }) + vi.mocked(oauthApi.getIntegrations).mockResolvedValue([ + integrationFixture('openai', 'OpenAI', [keyMethod]), + integrationFixture('together', 'Together AI', [keyMethod]), + ]) renderSettings() await screen.findByText('No OAuth-capable providers available.') expect(screen.queryByText('Anthropic')).not.toBeInTheDocument() }) + + it('lists an unconfigured provider when the active catalog has no providers', async () => { + vi.mocked(getProviders).mockResolvedValue({ providers: [], models: [] }) + vi.mocked(oauthApi.getIntegrations).mockResolvedValue([ + integrationFixture('openrouter', 'OpenRouter', [keyMethod]), + ]) + const user = userEvent.setup() + renderSettings() + + await screen.findByText('OpenRouter') + expect(screen.getByRole('button', { name: /Available Providers/ })).toHaveTextContent('1') + + await user.type(screen.getByPlaceholderText('Search providers...'), 'openrouter') + expect(screen.getByText('OpenRouter')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Add Key' })) + expect(screen.getByTestId('api-key-dialog')).toHaveTextContent('OpenRouter') + }) + + it('uses integration IDs for provider entries when catalog IDs differ', async () => { + vi.mocked(getProviders).mockResolvedValue({ + providers: [providerFixture('catalog-anthropic', 'Anthropic Catalog', 2, 'anthropic')], + models: [], + }) + vi.mocked(oauthApi.getIntegrations).mockResolvedValue([ + integrationFixture('anthropic', 'Anthropic Integration', [oauthMethod], true), + ]) + const user = userEvent.setup() + renderSettings() + + await screen.findByText('Anthropic Integration') + expect(screen.getByText('2 models')).toBeInTheDocument() + expect(screen.getByText('Connected')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Reconnect' })) + expect(screen.getByTestId('oauth-authorize-dialog')).toHaveTextContent('anthropic') + expect(screen.queryByText('catalog-anthropic')).not.toBeInTheDocument() + }) + + it('groups catalog providers that share an integration and combines their models', async () => { + vi.mocked(getProviders).mockResolvedValue({ + providers: [ + providerFixture('catalog-one', 'Catalog One', 2, 'shared-integration'), + providerFixture('catalog-two', 'Catalog Two', 3, 'shared-integration'), + ], + models: [], + }) + vi.mocked(oauthApi.getIntegrations).mockResolvedValue([ + integrationFixture('shared-integration', 'Shared Integration', [oauthMethod]), + ]) + const user = userEvent.setup() + renderSettings() + + await screen.findByText('Shared Integration') + + expect(screen.getAllByText('Shared Integration')).toHaveLength(1) + expect(screen.getByText('5 models')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Connect' })) + expect(screen.getByTestId('oauth-authorize-dialog')).toHaveTextContent('shared-integration') + }) + + it('offers an API-key action for mixed-method integrations even when connected with OAuth', async () => { + vi.mocked(getProviders).mockResolvedValue({ + providers: [providerFixture('anthropic', 'Anthropic', 2)], + models: [], + }) + vi.mocked(oauthApi.getIntegrations).mockResolvedValue([ + integrationFixture('anthropic', 'Anthropic', [oauthMethod, keyMethod], true, 'oauth'), + ]) + const user = userEvent.setup() + renderSettings() + + await screen.findByText('Anthropic') + const row = screen.getByText('Anthropic').closest('.py-3') as HTMLElement + + expect(within(row).getByRole('button', { name: 'Reconnect' })).toBeInTheDocument() + expect(within(row).getByRole('button', { name: 'Add API key for Anthropic' })).toBeInTheDocument() + + await user.click(within(row).getByRole('button', { name: 'Add API key for Anthropic' })) + expect(screen.getByTestId('api-key-dialog')).toHaveAttribute('data-mode', 'add') + }) + + it('labels an existing API key action as Add API key without changing its handling', async () => { + vi.mocked(getProviders).mockResolvedValue({ + providers: [providerFixture('anthropic', 'Anthropic', 2)], + models: [], + }) + vi.mocked(oauthApi.getIntegrations).mockResolvedValue([ + integrationFixture('anthropic', 'Anthropic', [oauthMethod, keyMethod], true, 'key'), + ]) + const user = userEvent.setup() + renderSettings() + + await screen.findByText('Anthropic') + const row = screen.getByText('Anthropic').closest('.py-3') as HTMLElement + + const addKeyButton = within(row).getByRole('button', { name: 'Add API key for Anthropic' }) + expect(addKeyButton).toHaveTextContent('Add API key') + + await user.click(addKeyButton) + expect(screen.getByTestId('api-key-dialog')).toHaveAttribute('data-mode', 'edit') + }) + + it('lists OAuth integrations that are not in the active catalog', async () => { + vi.mocked(getProviders).mockResolvedValue({ providers: [], models: [] }) + vi.mocked(oauthApi.getIntegrations).mockResolvedValue([ + integrationFixture('github-copilot', 'GitHub Copilot', [oauthMethod]), + ]) + renderSettings() + + await screen.findByText('GitHub Copilot') + expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument() + }) + + it('does not list integration-only entries without OAuth or API-key methods', async () => { + vi.mocked(getProviders).mockResolvedValue({ providers: [], models: [] }) + vi.mocked(oauthApi.getIntegrations).mockResolvedValue([ + integrationFixture('command-only', 'Command Only', [commandMethod]), + integrationFixture('env-only', 'Environment Only', [envMethod]), + ]) + renderSettings() + + await screen.findByText('No OAuth-capable providers available.') + + expect(screen.queryByText('Command Only')).not.toBeInTheDocument() + expect(screen.queryByText('Environment Only')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /Available Providers/ })).toHaveTextContent('0') + }) + + it('includes connected integration-only providers without key or OAuth methods', async () => { + vi.mocked(getProviders).mockResolvedValue({ providers: [], models: [] }) + vi.mocked(oauthApi.getIntegrations).mockResolvedValue([ + integrationFixture('connected-command', 'Connected Command', [commandMethod], true), + ]) + const user = userEvent.setup() + renderSettings() + + await screen.findByText('No OAuth-capable providers available.') + expect(screen.getByRole('button', { name: /Connected/ })).toHaveTextContent('1') + + await user.click(screen.getByRole('button', { name: /Connected/ })) + const connectedCard = screen.getByText('Connected Command').closest('.bg-card') as HTMLElement + expect(connectedCard).toBeInTheDocument() + expect(within(connectedCard).getByRole('button', { name: 'Remove credentials for Connected Command' })).toBeInTheDocument() + expect(within(connectedCard).queryByRole('button', { name: 'Edit API key for Connected Command' })).not.toBeInTheDocument() + }) + + it('keeps connected non-key providers visible without exposing API-key edit actions', async () => { + vi.mocked(getProviders).mockResolvedValue({ + providers: [ + providerFixture('command-only', 'Command Only', 1), + providerFixture('command-connected', 'Connected Command Only', 1), + providerFixture('env-only', 'Environment Only', 1), + providerFixture('empty-methods', 'No Auth Methods', 1), + ], + models: [], + }) + vi.mocked(oauthApi.getIntegrations).mockResolvedValue([ + integrationFixture('command-only', 'Command Only', [commandMethod]), + integrationFixture('command-connected', 'Connected Command Only', [commandMethod], true), + integrationFixture('env-only', 'Environment Only', [envMethod]), + integrationFixture('empty-methods', 'No Auth Methods', []), + ]) + const user = userEvent.setup() + renderSettings() + + expect(await screen.findByRole('button', { name: /Available Providers/ })).toHaveTextContent('0') + expect(screen.getByText('No available providers.')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Add Key' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /Connected/ })) + const connectedCard = screen.getByText('Connected Command Only').closest('.bg-card') as HTMLElement + expect(within(connectedCard).getByRole('button', { name: 'Remove credentials for Connected Command Only' })).toBeInTheDocument() + expect(within(connectedCard).queryByRole('button', { name: 'Edit API key for Connected Command Only' })).not.toBeInTheDocument() + }) + + it('shows an integration-list error instead of guessing connection status', async () => { + vi.mocked(oauthApi.getIntegrations).mockRejectedValue(new Error('Server unavailable')) + renderSettings() + + expect(await screen.findByRole('alert')).toHaveTextContent('Unable to load provider integrations.') + expect(screen.queryByText('Anthropic')).not.toBeInTheDocument() + expect(screen.queryByText('No OAuth-capable providers available.')).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/components/settings/ProviderSettings.tsx b/frontend/src/components/settings/ProviderSettings.tsx index cdcabb6da..fc4ebf7e4 100644 --- a/frontend/src/components/settings/ProviderSettings.tsx +++ b/frontend/src/components/settings/ProviderSettings.tsx @@ -7,14 +7,92 @@ import { DeleteDialog } from '@/components/ui/delete-dialog' import { Loader2, Check, X, Shield, ChevronDown, ChevronRight, Key, Search, Pencil, Trash2 } from 'lucide-react' import { providerCredentialsApi } from '@/api/providers' import type { Provider } from '@/api/providers' -import { type OAuthAuthorizeResponse } from '@/api/oauth' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { type IntegrationInfo, type IntegrationMethod, type OAuthAuthorizeResponse } from '@/api/oauth' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { OAuthAuthorizeDialog } from './OAuthAuthorizeDialog' import { OAuthCallbackDialog } from './OAuthCallbackDialog' import { ApiKeyDialog } from '@/components/model/ApiKeyDialog' import { invalidateProviderCaches } from '@/lib/queryInvalidation' import { useProviders } from '@/hooks/useProviders' -import { useProviderAuthMethods } from '@/hooks/useProviderAuthMethods' +import { useProviderIntegrations } from '@/hooks/useProviderIntegrations' + +interface ProviderEntry { + id: string + name: string + models: Provider['models'] + methods: IntegrationMethod[] + connected: boolean + keyConnected: boolean +} + +function hasCredentialConnection(integration: IntegrationInfo): boolean { + return integration.connections.some((connection) => connection.type === 'credential') +} + +function hasKeyConnection(integration: IntegrationInfo): boolean { + return integration.connections.some( + (connection) => connection.type === 'credential' && connection.method === 'key', + ) +} + +function supportsOAuthMethods(methods: IntegrationMethod[]): boolean { + return methods.some((method) => method.type === 'oauth') +} + +function supportsSettingsAuthentication(methods: IntegrationMethod[]): boolean { + return methods.some((method) => method.type === 'oauth' || method.type === 'key') +} + +function buildProviderEntries( + providers: Provider[] | undefined, + integrations: IntegrationInfo[] | undefined, +): ProviderEntry[] { + const integrationsByID = new Map((integrations ?? []).map((integration) => [integration.id, integration])) + const seen = new Set() + const entriesByID = new Map() + const entries: ProviderEntry[] = [] + + for (const provider of providers ?? []) { + const integrationID = provider.integrationID ?? provider.id + const integration = integrationsByID.get(integrationID) + seen.add(integrationID) + + const existingEntry = entriesByID.get(integrationID) + if (existingEntry) { + existingEntry.models.push(...provider.models) + continue + } + + const entry: ProviderEntry = { + id: integrationID, + name: integration?.name || provider.name || provider.id, + models: [...provider.models], + methods: integration?.methods ?? [], + connected: integration ? hasCredentialConnection(integration) : false, + keyConnected: integration ? hasKeyConnection(integration) : false, + } + entriesByID.set(integrationID, entry) + entries.push(entry) + } + + const integrationOnly = (integrations ?? []) + .filter( + (integration) => + !seen.has(integration.id) && + (supportsSettingsAuthentication(integration.methods) || hasCredentialConnection(integration)), + ) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((integration): ProviderEntry => ({ + id: integration.id, + name: integration.name || integration.id, + models: [], + methods: integration.methods, + connected: hasCredentialConnection(integration), + keyConnected: hasKeyConnection(integration), + })) + + return [...entries, ...integrationOnly] +} export function ProviderSettings() { const [selectedProvider, setSelectedProvider] = useState(null) @@ -34,12 +112,16 @@ export function ProviderSettings() { const providers = providersData?.providers - const { data: credentialsList, isLoading: credentialsLoading } = useQuery({ - queryKey: ['provider-credentials'], - queryFn: () => providerCredentialsApi.list(), - }) + const { + data: integrations, + isLoading: integrationsLoading, + isError: integrationsError, + } = useProviderIntegrations() - const { data: authMethods } = useProviderAuthMethods() + const providerEntries = useMemo( + () => integrationsError ? [] : buildProviderEntries(providers, integrations), + [providers, integrations, integrationsError], + ) const deleteCredentialMutation = useMutation({ mutationFn: (providerId: string) => providerCredentialsApi.delete(providerId), @@ -81,32 +163,23 @@ export function ProviderSettings() { setSelectedProvider(null) } - const supportsOAuth = useCallback((providerId: string) => { - const methods = authMethods?.[providerId] || [] - return methods.some(method => method.type === 'oauth') - }, [authMethods]) - - const hasCredentials = useCallback((providerId: string) => { - return credentialsList?.includes(providerId) || false - }, [credentialsList]) - const oauthProviders = useMemo(() => { - if (!providers || !authMethods) return [] - const oauth = providers.filter(provider => supportsOAuth(provider.id)) - return oauth.slice().sort((a, b) => { - const aConnected = hasCredentials(a.id) ? 1 : 0 - const bConnected = hasCredentials(b.id) ? 1 : 0 - return bConnected - aConnected - }) - }, [providers, authMethods, supportsOAuth, hasCredentials]) + return providerEntries + .filter((provider) => supportsOAuthMethods(provider.methods)) + .sort((a, b) => Number(b.connected) - Number(a.connected)) + }, [providerEntries]) const apiKeyProviders = useMemo(() => { - if (!providers || !authMethods) return { connected: [], available: [] } - const nonOAuthProviders = providers.filter(provider => !supportsOAuth(provider.id)) - const connected = nonOAuthProviders.filter(provider => hasCredentials(provider.id)) - const available = nonOAuthProviders.filter(provider => !hasCredentials(provider.id)) - return { connected, available } - }, [providers, authMethods, supportsOAuth, hasCredentials]) + const nonOAuthProviders = providerEntries.filter( + (provider) => + !supportsOAuthMethods(provider.methods) && + (provider.connected || provider.methods.some((method) => method.type === 'key')), + ) + return { + connected: nonOAuthProviders.filter((provider) => provider.connected), + available: nonOAuthProviders.filter((provider) => !provider.connected), + } + }, [providerEntries]) const filteredAvailableProviders = useMemo(() => { if (!availableSearch.trim()) return apiKeyProviders.available @@ -119,8 +192,13 @@ export function ProviderSettings() { const selectedProviderName = useMemo(() => { if (!selectedProvider) return '' - return providers?.find(p => p.id === selectedProvider)?.name || selectedProvider - }, [selectedProvider, providers]) + return providerEntries.find((provider) => provider.id === selectedProvider)?.name || selectedProvider + }, [selectedProvider, providerEntries]) + + const selectedProviderMethods = useMemo(() => { + if (!selectedProvider) return [] + return providerEntries.find((provider) => provider.id === selectedProvider)?.methods ?? [] + }, [selectedProvider, providerEntries]) const handleAddApiKey = useCallback((provider: Provider) => { setApiKeyProvider(provider) @@ -147,7 +225,18 @@ export function ProviderSettings() { } }, []) - if (providersLoading || credentialsLoading) { + if (integrationsError) { + return ( +
+

Unable to load provider integrations.

+

+ Provider connection status and available authentication methods could not be verified. Reload the settings page to try again. +

+
+ ) + } + + if (providersLoading || integrationsLoading) { return (
@@ -177,7 +266,8 @@ export function ProviderSettings() { ) : (
{oauthProviders.map((provider) => { - const hasKey = hasCredentials(provider.id) + const isConnected = provider.connected + const keyConnected = provider.keyConnected const modelCount = provider.models.length return ( @@ -193,7 +283,7 @@ export function ProviderSettings() { )}
- {hasKey ? ( + {isConnected ? ( Connected @@ -206,16 +296,27 @@ export function ProviderSettings() { )} - {hasKey && ( + {provider.methods.some((method) => method.type === 'key') && ( + + )} + {isConnected && ( + {provider.methods.some((method) => method.type === 'key') && ( + + )}