From 0f9d62558159b5c6916223f6b3a470863b53c049 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 28 Jul 2026 13:13:53 -0500 Subject: [PATCH] fix(web): explain unavailable OAuth integrations --- .../components/settings/Integrations.test.tsx | 77 +++++++++++++++ .../src/components/settings/Integrations.tsx | 94 +++++++++++++++---- apps/web/src/hooks/mcp-connections/index.ts | 1 + .../mcp-connections/useMcpOauthReadiness.ts | 11 +++ apps/web/src/lib/docs.ts | 5 + .../src/lib/server/mcp-static-oauth.test.ts | 24 +++++ apps/web/src/lib/server/mcp-static-oauth.ts | 53 +++++++++-- .../trpc/commands/mcp-connections/index.ts | 64 +++++++------ apps/web/src/trpc/routers/_app.ts | 5 + 9 files changed, 279 insertions(+), 55 deletions(-) create mode 100644 apps/web/src/hooks/mcp-connections/useMcpOauthReadiness.ts diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index 8d229bc9f..a0a6ba42b 100644 --- a/apps/web/src/components/settings/Integrations.test.tsx +++ b/apps/web/src/components/settings/Integrations.test.tsx @@ -14,6 +14,10 @@ import { MCP_TOOL_CATALOG_REQUIRES_PERSONAL_CONNECTION } from '@/lib/mcp-tool-er const state = vi.hoisted(() => ({ deploymentEnablements: [] as Array<{ mcpId: string; enabled: boolean }>, + oauthReadiness: [{ mcpId: 'linear', status: 'ready' as const }] as Array<{ + mcpId: string; + status: 'ready' | 'missing' | 'partial'; + }>, userConnections: [] as Array<{ id?: string; mcpId: string; @@ -158,6 +162,10 @@ vi.mock('@/hooks/mcp-connections', () => ({ useDeploymentMcpEnablements: () => ({ data: state.deploymentEnablements, }), + useMcpOauthReadiness: () => ({ + data: state.oauthReadiness, + isPending: false, + }), useUserMcpConnections: () => ({ data: state.userConnections, isPending: false, @@ -375,6 +383,7 @@ describe('Integrations settings', () => { vi.clearAllMocks(); window.history.replaceState(null, '', '/settings/integrations'); state.deploymentEnablements = []; + state.oauthReadiness = [{ mcpId: 'linear', status: 'ready' }]; state.userConnections = []; state.mcpTools = null; state.mcpToolsError = null; @@ -399,6 +408,74 @@ describe('Integrations settings', () => { ); }); + it('explains missing Linear OAuth setup to admins before they connect', () => { + state.linearInstallation = null; + state.oauthReadiness = [{ mcpId: 'linear', status: 'missing' }]; + + render(); + + const linearCard = screen + .getByRole('heading', { name: 'Linear' }) + .closest('[id="integration-linear"]'); + + expect(linearCard).toHaveTextContent( + 'Linear OAuth is not configured for this deployment. View setup guide.', + ); + expect( + screen.queryByRole('button', { name: 'Enable Linear' }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'View setup guide' }), + ).toHaveAttribute('href', 'https://docs.roomote.dev/integrations/linear'); + }); + + it('distinguishes incomplete Linear OAuth setup for admins', () => { + state.linearInstallation = null; + state.oauthReadiness = [{ mcpId: 'linear', status: 'partial' }]; + + render(); + + const linearCard = screen + .getByRole('heading', { name: 'Linear' }) + .closest('[id="integration-linear"]'); + + expect(linearCard).toHaveTextContent( + 'Linear OAuth setup is incomplete. Finish configuring both client credentials before connecting. View setup guide.', + ); + }); + + it('asks non-admins to contact an administrator when OAuth is unavailable', () => { + state.isAdmin = false; + state.linearInstallation = null; + state.oauthReadiness = [{ mcpId: 'linear', status: 'missing' }]; + + render(); + + expect( + screen.getByText( + 'Linear is not configured for this deployment. Ask an administrator to finish its OAuth setup.', + ), + ).toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: 'View setup guide' }), + ).not.toBeInTheDocument(); + }); + + it('surfaces the server error when starting Linear fails', () => { + state.linearInstallation = null; + mutations.connectLinear.mockImplementation((_variables, options) => { + options?.onError?.(new Error('Linear OAuth setup changed. Try again.')); + }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Enable Linear' })); + + expect(toast.error).toHaveBeenCalledWith( + 'Linear OAuth setup changed. Try again.', + ); + }); + it('sorts integrations alphabetically and splits installed vs available', () => { const sorted = sortIntegrationItems([ { id: 'sentry', name: 'Sentry', enabled: false }, diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index 79924a5d0..1ec0c0685 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -24,6 +24,7 @@ import { useDisconnectMcp, useGrafanaConnection, useDeploymentMcpEnablements, + useMcpOauthReadiness, useSaveAsanaConnection, useSaveGrafanaConnection, useSaveSnowflakeConnection, @@ -34,6 +35,7 @@ import { useVercelConnection, } from '@/hooks/mcp-connections'; import { useAuthorizedUser } from '@/hooks/useUser'; +import { DOCS_LINEAR_INTEGRATION_URL } from '@/lib/docs'; import { SETTINGS_PATHS } from '@/lib/settings'; import { saveAsanaConnectionSchema, @@ -144,8 +146,39 @@ type IntegrationItem = { highlighted?: boolean; }; +type OauthReadinessStatus = 'ready' | 'missing' | 'partial'; + type McpIntegrationDefinition = (typeof MCP_INTEGRATIONS)[number]; +function getLinearOauthSetupStatus( + status: Exclude, + isAdmin: boolean, +): ReactNode { + if (!isAdmin) { + return 'Linear is not configured for this deployment. Ask an administrator to finish its OAuth setup.'; + } + + const message = + status === 'partial' + ? 'Linear OAuth setup is incomplete. Finish configuring both client credentials before connecting.' + : 'Linear OAuth is not configured for this deployment.'; + + return ( + <> + {message}{' '} + + View setup guide + + . + + ); +} + type AdminConfiguredIntegrationItemOptions = { integration: McpIntegrationDefinition; connection?: { authStatus?: string | null }; @@ -1122,6 +1155,7 @@ export function Integrations() { const disconnectLinear = useDisconnectLinear(); const deploymentEnablements = useDeploymentMcpEnablements(); + const oauthReadiness = useMcpOauthReadiness(); const setDeploymentEnabled = useSetDeploymentMcpEnabled(); const userMcpConnections = useUserMcpConnections(); const connectMcp = useConnectMcp(); @@ -1263,6 +1297,13 @@ export function Integrations() { const userConnectionMap = new Map( (userMcpConnections.data ?? []).map((entry) => [entry.mcpId, entry]), ); + const oauthReadinessMap = new Map( + (oauthReadiness.data ?? []).map((entry) => [entry.mcpId, entry.status]), + ); + const linearOauthStatus = oauthReadinessMap.get('linear'); + const linearOauthUnavailable = + !linearInstallation.data && + (linearOauthStatus === 'missing' || linearOauthStatus === 'partial'); const openMcpToolDialog = (integration: McpIntegrationDefinition) => setToolDialogState({ mcpId: integration.id, @@ -1301,27 +1342,44 @@ export function Integrations() { isMcpBased: false, isPending: linearInstallation.isPending || + (!linearInstallation.data && oauthReadiness.isPending) || connectLinear.isPending || disconnectLinear.isPending, - onAction: () => { - if (linearInstallation.data) { - disconnectLinear.mutate(undefined, { - onSuccess: () => - toast.success('Linear disabled for this deployment.'), - onError: () => - toast.error('Failed to disable Linear. Please try again.'), - }); - return; - } + status: linearOauthUnavailable + ? getLinearOauthSetupStatus(linearOauthStatus, isAdmin) + : undefined, + statusIcon: linearOauthUnavailable ? ( + + ) : undefined, + onAction: linearOauthUnavailable + ? undefined + : () => { + if (linearInstallation.data) { + disconnectLinear.mutate(undefined, { + onSuccess: () => + toast.success('Linear disabled for this deployment.'), + onError: (error) => + toast.error( + error instanceof Error + ? error.message + : 'Failed to disable Linear. Please try again.', + ), + }); + return; + } - connectLinear.mutate(undefined, { - onSuccess: (url) => { - window.location.href = url; + connectLinear.mutate(undefined, { + onSuccess: (url) => { + window.location.href = url; + }, + onError: (error) => + toast.error( + error instanceof Error + ? error.message + : 'Failed to enable Linear. Please try again.', + ), + }); }, - onError: () => - toast.error('Failed to enable Linear. Please try again.'), - }); - }, }, ...visibleMcpIntegrations .filter((integration) => { @@ -1558,6 +1616,8 @@ export function Integrations() { grafanaConnection.isPending, linearInstallation.data, linearInstallation.isPending, + oauthReadiness.data, + oauthReadiness.isPending, isAdmin, isGrafanaDialogOpen, saveAsanaConnection.isPending, diff --git a/apps/web/src/hooks/mcp-connections/index.ts b/apps/web/src/hooks/mcp-connections/index.ts index bcf44ff77..d7133e7d4 100644 --- a/apps/web/src/hooks/mcp-connections/index.ts +++ b/apps/web/src/hooks/mcp-connections/index.ts @@ -2,6 +2,7 @@ export { useDeploymentMcpEnablements } from './useDeploymentMcpEnablements'; export { useUserMcpConnections } from './useUserMcpConnections'; export { useMcpConnectionTools } from './useMcpConnectionTools'; +export { useMcpOauthReadiness } from './useMcpOauthReadiness'; // Mutations export { useSetDeploymentMcpEnabled } from './useSetDeploymentMcpEnabled'; diff --git a/apps/web/src/hooks/mcp-connections/useMcpOauthReadiness.ts b/apps/web/src/hooks/mcp-connections/useMcpOauthReadiness.ts new file mode 100644 index 000000000..e4567e213 --- /dev/null +++ b/apps/web/src/hooks/mcp-connections/useMcpOauthReadiness.ts @@ -0,0 +1,11 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; + +import { useTRPC } from '@/trpc/client'; + +export function useMcpOauthReadiness() { + const trpc = useTRPC(); + + return useQuery(trpc.mcpConnections.oauthReadiness.queryOptions()); +} diff --git a/apps/web/src/lib/docs.ts b/apps/web/src/lib/docs.ts index 7f9a9dcc7..50b2ba5aa 100644 --- a/apps/web/src/lib/docs.ts +++ b/apps/web/src/lib/docs.ts @@ -13,3 +13,8 @@ export const DOCS_ENVIRONMENT_DEFINITION_URL = `${DOCS_BASE_URL}/environments/de * Public docs page for self-hosting, upgrades, and day-2 operations. */ export const DOCS_SELF_HOSTING_URL = `${DOCS_BASE_URL}/self-hosting`; + +/** + * Public setup guide for the Linear integration. + */ +export const DOCS_LINEAR_INTEGRATION_URL = `${DOCS_BASE_URL}/integrations/linear`; diff --git a/apps/web/src/lib/server/mcp-static-oauth.test.ts b/apps/web/src/lib/server/mcp-static-oauth.test.ts index fc4a59eb8..1eab9d7de 100644 --- a/apps/web/src/lib/server/mcp-static-oauth.test.ts +++ b/apps/web/src/lib/server/mcp-static-oauth.test.ts @@ -1,6 +1,7 @@ import { getMcpIntegration } from '@roomote/types'; import { + getStaticOauthReadiness, getStaticOauthEnvPartnerKey, resolveStaticOauthClientInformation, } from './mcp-static-oauth'; @@ -35,4 +36,27 @@ describe('Linear static OAuth configuration', () => { ), ).toBeUndefined(); }); + + it.each([ + { + env: { + R_LINEAR_CLIENT_ID: 'linear-client', + R_LINEAR_CLIENT_SECRET: 'linear-secret', + }, + expected: 'ready', + }, + { env: {}, expected: 'missing' }, + { + env: { R_LINEAR_CLIENT_ID: 'linear-client' }, + expected: 'partial', + }, + ] as const)('reports $expected OAuth readiness', ({ env, expected }) => { + expect(getStaticOauthReadiness(env, linear!)).toBe(expected); + }); + + it('reports that integrations without static credentials need no setup', () => { + expect(getStaticOauthReadiness({}, getMcpIntegration('notion')!)).toBe( + 'not_required', + ); + }); }); diff --git a/apps/web/src/lib/server/mcp-static-oauth.ts b/apps/web/src/lib/server/mcp-static-oauth.ts index 8eda15445..4faa87bb7 100644 --- a/apps/web/src/lib/server/mcp-static-oauth.ts +++ b/apps/web/src/lib/server/mcp-static-oauth.ts @@ -10,6 +10,12 @@ type StaticOauthPairResolution = status: 'missing' | 'partial'; }; +export type StaticOauthReadiness = + | 'not_required' + | 'ready' + | 'missing' + | 'partial'; + const STATIC_OAUTH_FALLBACKS: Partial> = {}; @@ -136,7 +142,7 @@ function resolveStaticOauthCandidateInformation( }; } -export function getStaticOauthEnvCandidates( +function getStaticOauthEnvCandidates( integration: Pick, ): StaticOauthClientEnv[] { if (!integration.oauthClientEnv) { @@ -149,7 +155,7 @@ export function getStaticOauthEnvCandidates( ]; } -export function resolveStaticOauthClientInformation( +function resolveStaticOauthIntegration( env: unknown, integration: Pick, ) { @@ -160,18 +166,45 @@ export function resolveStaticOauthClientInformation( ); if (candidateInformation.status === 'configured') { - return { - client_id: candidateInformation.client_id, - client_secret: candidateInformation.client_secret, - token_endpoint_auth_method: - candidateInformation.token_endpoint_auth_method, - }; + return candidateInformation; } if (candidateInformation.status === 'partial') { - return undefined; + return candidateInformation; } } - return undefined; + return { status: 'missing' as const }; +} + +export function resolveStaticOauthClientInformation( + env: unknown, + integration: Pick, +) { + const resolution = resolveStaticOauthIntegration(env, integration); + + if (resolution.status !== 'configured') { + return undefined; + } + + return { + client_id: resolution.client_id, + client_secret: resolution.client_secret, + token_endpoint_auth_method: resolution.token_endpoint_auth_method, + }; +} + +export function getStaticOauthReadiness( + env: unknown, + integration: Pick, +): StaticOauthReadiness { + const candidates = getStaticOauthEnvCandidates(integration); + + if (candidates.length === 0) { + return 'not_required'; + } + + const resolution = resolveStaticOauthIntegration(env, integration); + + return resolution.status === 'configured' ? 'ready' : resolution.status; } diff --git a/apps/web/src/trpc/commands/mcp-connections/index.ts b/apps/web/src/trpc/commands/mcp-connections/index.ts index 8a2a09d62..89fa8a46f 100644 --- a/apps/web/src/trpc/commands/mcp-connections/index.ts +++ b/apps/web/src/trpc/commands/mcp-connections/index.ts @@ -33,9 +33,10 @@ import { getValidAccessToken } from '@roomote/sdk/server'; import type { UserAuthSuccess } from '@/types'; import { - getStaticOauthEnvCandidates, - resolveStaticOauthClientInformation, + getStaticOauthReadiness, + type StaticOauthReadiness, } from '@/lib/server/mcp-static-oauth'; +import { Env } from '@/lib/server/env'; import { MCP_TOOL_CATALOG_REQUIRES_PERSONAL_CONNECTION } from '@/lib/mcp-tool-errors'; import type { SaveAsanaConnectionInput, @@ -71,23 +72,28 @@ function assertAdmin(auth: UserAuthSuccess) { } } -function getMissingStaticOauthEnvNames( - integration: Pick, -): string[] { - const candidates = getStaticOauthEnvCandidates(integration); - - if (candidates.length === 0) { - return []; +function getStaticOauthSetupError( + integration: Pick, + readiness: StaticOauthReadiness, +): string | null { + if (readiness === 'ready' || readiness === 'not_required') { + return null; } - if (resolveStaticOauthClientInformation(process.env, integration)) { - return []; + if (readiness === 'partial') { + return `${integration.name} OAuth setup is incomplete on this Roomote deployment. Ask the team managing this deployment to finish configuring it before connecting.`; } - return [ - integration.oauthClientEnv?.clientIdEnv, - integration.oauthClientEnv?.clientSecretEnv, - ].filter((envName): envName is string => Boolean(envName)); + return `${integration.name} isn't available on this Roomote deployment yet. Ask the team managing this deployment to configure OAuth before connecting.`; +} + +function assertStaticOauthReady(integration: McpIntegration): void { + const readiness = getStaticOauthReadiness(Env, integration); + const error = getStaticOauthSetupError(integration, readiness); + + if (error) { + throw new Error(error); + } } const ALL_DEPLOYMENT_CONTROLLED_APP_IDS = new Set([ @@ -515,6 +521,19 @@ export async function getDeploymentMcpEnablementsCommand( }); } +/** + * Return public-safe OAuth setup status for integrations that require a + * deployment-configured client. Credential names and values never leave the + * server. + */ +export async function getMcpOauthReadinessCommand(_auth: UserAuthSuccess) { + return MCP_INTEGRATIONS.flatMap((integration) => { + const status = getStaticOauthReadiness(Env, integration); + + return status === 'not_required' ? [] : [{ mcpId: integration.id, status }]; + }); +} + /** * Admin: enable or disable a curated MCP for the deployment */ @@ -560,13 +579,7 @@ export async function setDeploymentMcpEnabledCommand( integration?.oauthClientEnv && !isDeploymentScopedMcpIntegration(input.mcpId) ) { - const missingEnvNames = getMissingStaticOauthEnvNames(integration); - - if (missingEnvNames.length > 0) { - throw new Error( - `${integration.name} isn't available on this Roomote deployment yet. Ask the team managing this Roomote deployment to finish the required OAuth setup before enabling it.`, - ); - } + assertStaticOauthReady(integration); } const [result] = await db @@ -1249,12 +1262,7 @@ export async function connectMcpCommand( ); } - const missingOauthEnvNames = getMissingStaticOauthEnvNames(integration); - if (missingOauthEnvNames.length > 0) { - throw new Error( - `${integration.name} isn't available on this Roomote deployment yet. Ask the team managing this Roomote deployment to finish the required OAuth setup before connecting it.`, - ); - } + assertStaticOauthReady(integration); if ( input.redirectTo && diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index b9cadafc9..03635dbdc 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -184,6 +184,7 @@ import { } from '../commands/sandbox-session'; import { getDeploymentMcpEnablementsCommand, + getMcpOauthReadinessCommand, setDeploymentMcpEnabledCommand, getUserMcpConnectionsCommand, getAsanaConnectionCommand, @@ -1470,6 +1471,10 @@ export const appRouter = createRouter({ getDeploymentMcpEnablementsCommand(auth), ), + oauthReadiness: protectedProcedure.query(({ ctx: { auth } }) => + getMcpOauthReadinessCommand(auth), + ), + setDeploymentEnabled: protectedProcedure .input(z.object({ mcpId: z.string(), enabled: z.boolean() })) .mutation(({ ctx: { auth }, input }) =>