From 08692a4df70b044e20c3ec6753eba584bd8ec53d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 30 Sep 2026 17:36:30 -0700 Subject: [PATCH 1/2] fix(integrations): complete account connections in place --- apps/desktop/e2e/source-connect.spec.ts | 153 +++++++++++++++++- .../credential-groups/enrollment-redirect.ts | 4 +- .../credential-groups/oauth-callback.test.ts | 30 ++++ .../api/credential-groups/oauth-callback.ts | 10 +- .../app/api/mcp/oauth/callback/route.test.ts | 27 +++- apps/sim/app/api/mcp/oauth/callback/route.ts | 30 +++- .../[id]/connected-accounts/connect/route.ts | 1 + .../[credentialId]/reconnect/route.ts | 2 +- .../complete/completion-handoff.tsx | 19 ++- .../app/credential-groups/complete/page.tsx | 33 +++- .../indexed/github-member-integration.tsx | 15 +- .../integrations/live-member-integrations.tsx | 9 +- .../hooks/queries/organization-accounts.ts | 74 ++++++--- .../api/contracts/desktop-source-connect.ts | 6 +- .../api/contracts/organization-accounts.ts | 22 ++- .../application/organization-accounts.ts | 18 ++- .../personal-organization-accounts.ts | 30 +++- .../credential-groups/mcp-oauth-state.test.ts | 15 ++ .../lib/credential-groups/mcp-oauth-state.ts | 17 ++ apps/sim/lib/credential-groups/mcp-oauth.ts | 4 +- apps/sim/lib/credential-groups/oauth-popup.ts | 81 ++++++++++ apps/sim/lib/credential-groups/oauth-state.ts | 7 +- apps/sim/lib/credential-groups/oauth.ts | 2 +- .../self-enrollment-oauth.ts | 40 ++++- apps/sim/lib/desktop/source-browser.ts | 9 +- .../fixtures/desktop-source-connect.tsx | 45 +++++- 26 files changed, 616 insertions(+), 87 deletions(-) create mode 100644 apps/sim/lib/credential-groups/oauth-popup.ts diff --git a/apps/desktop/e2e/source-connect.spec.ts b/apps/desktop/e2e/source-connect.spec.ts index cae4b8f02be..51a80d7ce63 100644 --- a/apps/desktop/e2e/source-connect.spec.ts +++ b/apps/desktop/e2e/source-connect.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createServer } from 'node:http' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -8,6 +8,8 @@ import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { generateShortId } from '@sim/utils/id' import { build } from 'esbuild' +import postcss from 'postcss' +import loadPostcssConfig from 'postcss-load-config' const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) const SIM_DIR = fileURLToPath(new URL('../../sim/', import.meta.url)) @@ -42,6 +44,8 @@ test('source authorization returns to its desktop screen and refreshes live', as } const tickets = new Map() const attempts = new Map() + const accountAttempts = new Map() + let accountConnected = false const startSessions: string[] = [] const callbackSessions: string[] = [] const githubAttempts = new Map() @@ -50,6 +54,7 @@ test('source authorization returns to its desktop screen and refreshes live', as let nativeCredentialVisible = false let installed = false let javascript = '' + let stylesheet = '' let origin = '' let app: Awaited> | undefined let browser: Awaited> | undefined @@ -73,9 +78,64 @@ test('source authorization returns to its desktop screen and refreshes live', as for await (const chunk of request) text += chunk.toString() return JSON.parse(text) } - if (path === '/fixture.js') { - response.setHeader('content-type', 'text/javascript') - response.end(javascript) + if (path === '/fixture.js' || path === '/fixture.css') { + response.setHeader('content-type', path.endsWith('.js') ? 'text/javascript' : 'text/css') + response.end(path.endsWith('.js') ? javascript : stylesheet) + return + } + if (path === '/api/organizations/fixture-organization/connected-accounts') { + json({ + credentialGroup: null, + availableProviders: [], + availableMcpConnectors: [], + canManage: false, + indexingAvailable: true, + viewerAccounts: accountConnected + ? [ + { + credentialId: 'fixture-account', + displayName: 'Fixture account', + providerId: 'google-drive', + groupId: 'fixture-group', + optionId: 'fixture-option', + status: 'active', + }, + ] + : [], + }) + return + } + if ( + path === '/api/organizations/fixture-organization/connected-accounts/connect' || + path === '/api/users/me/organization-accounts/fixture-account/reconnect' + ) { + const completionId = + request.method === 'POST' && path.endsWith('/connect') + ? (await body()).oauthCompletionId + : url.searchParams.get('oauthCompletionId') + if (!completionId) { + json({ error: 'Missing completion ID' }, 400) + return + } + accountAttempts.set(completionId, session) + json({ + invitationLink: `${origin}/credential-groups/enroll/fixture-account-invitation`, + authorizationUrl: `${origin}/account-provider?completionId=${completionId}`, + }) + return + } + if (path === '/account-callback') { + const completionId = url.searchParams.get('completionId') ?? '' + if (accountAttempts.get(completionId) !== session) { + json({ error: 'Wrong attempt' }, 403) + return + } + accountAttempts.delete(completionId) + const denied = url.searchParams.has('error') + if (!denied) accountConnected = true + redirect( + `/credential-groups/complete?completionId=${completionId}&organizationId=fixture-organization${denied ? '&oauth=denied' : ''}` + ) return } if (path === '/api/auth/get-session') { @@ -217,6 +277,14 @@ test('source authorization returns to its desktop screen and refreshes live', as return } response.setHeader('content-type', 'text/html') + if (path === '/account-provider') { + response.setHeader('Cross-Origin-Opener-Policy', 'same-origin') + const completionId = url.searchParams.get('completionId') ?? '' + response.end( + `Authorize accountDeny account` + ) + return + } if (path === '/github-provider') { response.end( `Authorize GitHub` @@ -239,10 +307,18 @@ test('source authorization returns to its desktop screen and refreshes live', as 'set-cookie', 'better-auth.session_token=desktop-fixture; HttpOnly; SameSite=Lax; Path=/' ) - response.end('
') + response.end( + '
' + ) }) try { await check('launch the production source hook and native bridge', async () => { + const config = await loadPostcssConfig({}, SIM_DIR) + const cssPath = join(SIM_DIR, 'app/_styles/globals.css') + const css = await postcss(config.plugins).process( + `${readFileSync(cssPath, 'utf8')}\n@source ${JSON.stringify(FIXTURE)};`, + { from: cssPath } + ) const bundle = await build({ entryPoints: [FIXTURE], bundle: true, @@ -257,6 +333,7 @@ test('source authorization returns to its desktop screen and refreshes live', as define: { 'process.env.NODE_ENV': '"development"' }, }) javascript = bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text ?? '' + stylesheet = `${css.css}\n${bundle.outputFiles.find((file) => file.path.endsWith('.css'))?.text ?? ''}` await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const address = server.address() if (!address || typeof address === 'string') throw new Error('Missing fixture address') @@ -400,6 +477,72 @@ test('source authorization returns to its desktop screen and refreshes live', as await expect(page.getByRole('alert')).toContainText('Sign in to Sim in your browser') expect(page.url()).toBe(`${origin}/home`) }) + await check('managed accounts return through the desktop completion handoff', async () => { + await page.getByRole('button', { name: 'Connect MCP account', exact: true }).click() + await expect.poll(async () => (await opened()).length).toBe(9) + await external.goto((await opened())[8]) + await external.getByRole('link', { name: 'Authorize account' }).click() + await expect(page.getByLabel('Account authorization', { exact: true })).toHaveText('success') + await expect(page.getByLabel('Account count')).toHaveText('1') + expect(page.url()).toBe(`${origin}/home`) + await expect(page.getByLabel('Source draft')).toHaveValue('Preserved while connecting') + }) + const web = await context.newPage() + web.on('pageerror', (error) => pageErrors.push(error.message)) + await web.goto(`${origin}/o/fixture-organization/integrations?search=fixture`) + await check( + 'web authorization preserves the origin and refreshes after an isolated provider window', + async () => { + accountConnected = false + await web.reload() + await web.getByLabel('Source draft').fill('Web draft retained') + await expect(web.getByLabel('Account count')).toHaveText('0') + const popupReady = context.waitForEvent('page') + await web.getByRole('button', { name: 'Connect account', exact: true }).click() + const popup = await popupReady + await popup.getByRole('link', { name: 'Authorize account' }).click() + await expect(web.getByLabel('Account count')).toHaveText('1') + await expect(web.getByLabel('Account authorization', { exact: true })).toHaveText('success') + await expect(web.getByLabel('Source draft')).toHaveValue('Web draft retained') + expect(web.url()).toBe(`${origin}/o/fixture-organization/integrations?search=fixture`) + } + ) + await check('web denial and cancellation leave the initiating page usable', async () => { + const popupReady = context.waitForEvent('page') + await web.getByRole('button', { name: 'Connect account', exact: true }).click() + const popup = await popupReady + await popup.getByRole('link', { name: 'Deny account' }).click() + await expect(web.getByLabel('Account error')).toContainText('canceled') + await popup.close() + await expect(web.getByRole('button', { name: 'Cancel', exact: true })).toHaveCount(0) + const nextPopupReady = context.waitForEvent('page') + await web.getByRole('button', { name: 'Connect account', exact: true }).click() + const nextPopup = await nextPopupReady + await nextPopup.getByRole('link', { name: 'Authorize account' }).waitFor() + await expect(web.getByRole('button', { name: 'Cancel', exact: true })).toHaveCount(1) + await web.getByRole('button', { name: 'Cancel', exact: true }).click() + expect(pageErrors).toEqual([]) + await expect(web.getByLabel('Account error')).toContainText('canceled') + await expect(web.getByRole('button', { name: 'Connect account', exact: true })).toBeEnabled() + await expect(web.getByLabel('Account count')).toHaveText('1') + }) + await check('reconnect uses the same completion lifecycle', async () => { + const popupReady = context.waitForEvent('page') + await web.getByRole('button', { name: 'Reconnect account', exact: true }).click() + const popup = await popupReady + await popup.getByRole('link', { name: 'Authorize account' }).click() + await expect(web.getByLabel('Reconnect status')).toHaveText('success') + await expect(web.getByLabel('Source draft')).toHaveValue('Web draft retained') + }) + await check('blocked popups complete in the same tab and return to Integrations', async () => { + await web.evaluate(() => { + window.open = () => null + }) + await web.getByRole('button', { name: 'Connect account', exact: true }).click() + await web.getByRole('link', { name: 'Authorize account' }).click() + await expect(web).toHaveURL(`${origin}/o/fixture-organization/integrations`) + await expect(web.getByLabel('Account count')).toHaveText('1') + }) await page.screenshot({ path: test.info().outputPath('source-connect-desktop.png') }) } finally { mkdirSync(dirname(reportPath), { recursive: true }) diff --git a/apps/sim/app/api/credential-groups/enrollment-redirect.ts b/apps/sim/app/api/credential-groups/enrollment-redirect.ts index 755a68094b2..7868fb0a087 100644 --- a/apps/sim/app/api/credential-groups/enrollment-redirect.ts +++ b/apps/sim/app/api/credential-groups/enrollment-redirect.ts @@ -23,11 +23,13 @@ export function createCredentialGroupEnrollmentRedirect( export function createCredentialGroupCompletionRedirect( oauth?: CredentialGroupOAuthFailure, - completionId?: string + completionId?: string, + organizationId?: string ): NextResponse { const query = new URLSearchParams() if (oauth) query.set('oauth', oauth) if (completionId) query.set('completionId', completionId) + if (organizationId) query.set('organizationId', organizationId) return new NextResponse(null, { status: 303, headers: { diff --git a/apps/sim/app/api/credential-groups/oauth-callback.test.ts b/apps/sim/app/api/credential-groups/oauth-callback.test.ts index 6cdae266ed9..1f118b2b028 100644 --- a/apps/sim/app/api/credential-groups/oauth-callback.test.ts +++ b/apps/sim/app/api/credential-groups/oauth-callback.test.ts @@ -238,3 +238,33 @@ describe('GitHub installation setup OAuth return target', () => { expect(url.searchParams.get('setupId')).toBe(completionId) }) }) + +describe('Integrations OAuth completion', () => { + it.each([undefined, 'denied'])( + 'returns the originating organization on completion: %s', + async (error) => { + mocks.consumeAttempt.mockResolvedValueOnce({ + ...attempt, + returnTo: 'integrations', + organizationId: 'organization-1', + completionRedirect: true, + completionId, + }) + mocks.authenticate.mockResolvedValueOnce({ kind: 'credential_group_enrollment' }) + mocks.completeOAuth.mockResolvedValueOnce({ connectedOptionId: 'option-1' }) + const response = await handleCredentialGroupOAuthCallback({ + request: createMockRequest({ + url: 'https://sim.test/api/auth/oauth2/callback/github-repositories', + }), + provider: 'github-repositories', + query: { state: 'cg_state', code: 'code-1', ...(error ? { error } : {}) }, + limited: null, + }) + const destination = new URL(response.headers.get('location')!, 'https://sim.test') + expect(destination.pathname).toBe('/credential-groups/complete') + expect(destination.searchParams.get('completionId')).toBe(completionId) + expect(destination.searchParams.get('organizationId')).toBe('organization-1') + expect(destination.searchParams.get('oauth')).toBe(error ?? null) + } + ) +}) diff --git a/apps/sim/app/api/credential-groups/oauth-callback.ts b/apps/sim/app/api/credential-groups/oauth-callback.ts index 0d8b0240ac2..f88f8e79a20 100644 --- a/apps/sim/app/api/credential-groups/oauth-callback.ts +++ b/apps/sim/app/api/credential-groups/oauth-callback.ts @@ -84,11 +84,13 @@ export async function handleCredentialGroupOAuthCallback({ }) const installationSetup = attempt.returnTo === 'github-installation' && attempt.organizationId && attempt.completionId + const returnOrganizationId = + attempt.returnTo === 'integrations' ? attempt.organizationId : undefined const failureRedirect = (oauth: CredentialGroupOAuthFailure) => installationSetup ? setupRedirect(oauth) : attempt.completionRedirect - ? createCredentialGroupCompletionRedirect(oauth, attempt.completionId) + ? createCredentialGroupCompletionRedirect(oauth, attempt.completionId, returnOrganizationId) : createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { ...focus, oauth }) if (limited) { return failureRedirect('rate_limited') @@ -117,7 +119,11 @@ export async function handleCredentialGroupOAuthCallback({ request, }) return attempt.completionRedirect - ? createCredentialGroupCompletionRedirect(undefined, attempt.completionId) + ? createCredentialGroupCompletionRedirect( + undefined, + attempt.completionId, + returnOrganizationId + ) : createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { ...focus, connected: attempt.optionId, diff --git a/apps/sim/app/api/mcp/oauth/callback/route.test.ts b/apps/sim/app/api/mcp/oauth/callback/route.test.ts index 95c2b7e3670..d9b38f12508 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.test.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.test.ts @@ -38,7 +38,7 @@ vi.mock('@/lib/credential-groups/rate-limit', () => ({ enforcePublicCredentialGroupIpRateLimit: mockEnforceCallbackRateLimit, })) -import { GET } from './route' +import { GET } from '@/app/api/mcp/oauth/callback/route' const { mockDiscoverServerTools } = mcpServiceMockFns @@ -88,6 +88,31 @@ describe('MCP OAuth callback route', () => { mockEnforceCallbackRateLimit.mockResolvedValue(null) }) + it.each([undefined, 'denied'])( + 'finishes a direct connection without the invitation form: %s', + async (error) => { + const completionId = '00000000-0000-4000-8000-000000000002' + mockConsumeManagedAttempt.mockResolvedValueOnce({ + state: 'mcp_cg_direct', + organizationId: 'organization-1', + invitationToken: 'invitation-token', + mcpServerId: 'server-1', + completionId, + returnTo: 'integrations', + }) + const response = await GET( + new NextRequest( + `http://localhost:3000/api/mcp/oauth/callback?state=mcp_cg_direct&${error ? 'error=denied' : 'code=code-1'}` + ) + ) + const destination = new URL(response.headers.get('location')!, 'http://localhost:3000') + expect(destination.pathname).toBe('/credential-groups/complete') + expect(destination.searchParams.get('completionId')).toBe(completionId) + expect(destination.searchParams.get('organizationId')).toBe('organization-1') + expect(destination.searchParams.get('oauth')).toBe(error ?? null) + } + ) + it('performs the token exchange through the SSRF-guarded mcpAuthGuarded wrapper', async () => { const request = new NextRequest( 'http://localhost:3000/api/mcp/oauth/callback?state=state-1&code=auth-code-1' diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts index 6b7655ec2b0..4b97c7ba00e 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.ts @@ -17,6 +17,7 @@ import { isCredentialGroupMcpOAuthState, } from '@/lib/credential-groups/mcp-oauth-state' import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version' +import type { CredentialGroupOAuthFailure } from '@/lib/credential-groups/oauth-completion' import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' import { assertSafeOauthServerUrl, @@ -30,7 +31,10 @@ import { SimMcpOauthProvider, } from '@/lib/mcp/oauth' import { mcpService } from '@/lib/mcp/service' -import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect' +import { + createCredentialGroupCompletionRedirect, + createCredentialGroupEnrollmentRedirect, +} from '@/app/api/credential-groups/enrollment-redirect' const logger = createLogger('McpOauthCallbackAPI') const timedStep = makeTimedStep(logger) @@ -98,13 +102,17 @@ async function completeManagedMcpCallback(params: { if (!attempt) { return htmlClose('Invalid or expired authorization state.', false, 'invalid_state') } - if (params.error) { - return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'denied' }) - } + const failureRedirect = (oauth: CredentialGroupOAuthFailure) => + attempt.completionId + ? createCredentialGroupCompletionRedirect( + oauth, + attempt.completionId, + attempt.returnTo === 'integrations' ? attempt.organizationId : undefined + ) + : createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth }) + if (params.error) return failureRedirect('denied') if (!params.code) { - return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { - oauth: 'failed', - }) + return failureRedirect('failed') } try { const principal = await credentialGroupOAuthAttemptPrincipal(attempt) @@ -113,13 +121,19 @@ async function completeManagedMcpCallback(params: { input: { attempt, code: params.code }, request: params.request, }) + if (attempt.completionId) + return createCredentialGroupCompletionRedirect( + undefined, + attempt.completionId, + attempt.returnTo === 'integrations' ? attempt.organizationId : undefined + ) return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { mcp: 'connected', mcpServerId: result.mcpServerId, }) } catch (error) { logger.error('Managed MCP OAuth callback failed', error) - return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'failed' }) + return failureRedirect('failed') } } diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/connect/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/connect/route.ts index bb97cde1d08..51aa2a4a346 100644 --- a/apps/sim/app/api/organizations/[id]/connected-accounts/connect/route.ts +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/connect/route.ts @@ -21,6 +21,7 @@ export const POST = defineInternalJsonRoute({ mapInput: ({ params, body }) => ({ organizationId: params.id, ...body, + ...(body.oauthCompletionId ? { returnTo: 'integrations' as const } : {}), }), useCase: startOrganizationAccountConnection, }) diff --git a/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts index c9d66c778e7..c057cc04f08 100644 --- a/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts +++ b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts @@ -13,6 +13,6 @@ export const POST = defineInternalJsonRoute({ operation: reconnectPersonalOrganizationAccount.operation, rateLimit: internalRateLimits.none({ reason: 'Current-user connected account management' }), errorPolicy: internalOrchestrationErrorPolicy, - mapInput: ({ params }) => params, + mapInput: ({ params, query }) => ({ ...params, ...query }), useCase: reconnectPersonalOrganizationAccount, }) diff --git a/apps/sim/app/credential-groups/complete/completion-handoff.tsx b/apps/sim/app/credential-groups/complete/completion-handoff.tsx index f4baa4dcd7c..a20659c828b 100644 --- a/apps/sim/app/credential-groups/complete/completion-handoff.tsx +++ b/apps/sim/app/credential-groups/complete/completion-handoff.tsx @@ -10,12 +10,14 @@ import { finishDesktopSourceBrowser } from '@/lib/desktop/source-browser' interface CredentialGroupCompletionHandoffProps { completionId: string failure?: CredentialGroupOAuthFailure + returnHref?: string } /** Notifies the originating tab even when provider navigation has removed window.opener. */ export function CredentialGroupCompletionHandoff({ completionId, failure, + returnHref, }: CredentialGroupCompletionHandoffProps) { const started = useRef(false) useEffect(() => { @@ -24,13 +26,18 @@ export function CredentialGroupCompletionHandoff({ void finishDesktopSourceBrowser({ kind: 'completion', id: completionId, error: failure }).then( (returned) => { if (returned) return - const channel = new BroadcastChannel(credentialGroupOAuthCompletionChannel(completionId)) - channel.postMessage(failure ?? 'connected') - channel.close() - /** Keep the authorization failure visible while the initiating chat shows its retry action. */ - if (!failure) window.close() + if (typeof BroadcastChannel !== 'undefined') { + const channel = new BroadcastChannel(credentialGroupOAuthCompletionChannel(completionId)) + channel.postMessage(failure ?? 'connected') + channel.close() + } + /** Keep failures visible when the initiating window is no longer available. */ + if (!failure) { + window.close() + if (returnHref && !window.closed) window.location.replace(returnHref) + } } ) - }, [completionId, failure]) + }, [completionId, failure, returnHref]) return null } diff --git a/apps/sim/app/credential-groups/complete/page.tsx b/apps/sim/app/credential-groups/complete/page.tsx index 07edac4b434..e9a8243794b 100644 --- a/apps/sim/app/credential-groups/complete/page.tsx +++ b/apps/sim/app/credential-groups/complete/page.tsx @@ -5,7 +5,7 @@ import { CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES, isCredentialGroupOAuthFailure, } from '@/lib/credential-groups/oauth-completion' -import { APP_ENTRY_PATH } from '@/lib/navigation/paths' +import { APP_ENTRY_PATH, organizationRoutes } from '@/lib/navigation/paths' import { AuthHeader, AuthShell } from '@/app/(auth)/components' import { CredentialGroupCompletionHandoff } from '@/app/credential-groups/complete/completion-handoff' @@ -17,24 +17,43 @@ export const metadata: Metadata = { export default async function CredentialGroupCompletePage({ searchParams, }: { - searchParams: Promise<{ oauth?: string | string[]; completionId?: string | string[] }> + searchParams: Promise<{ + oauth?: string | string[] + completionId?: string | string[] + organizationId?: string | string[] + }> }) { - const { oauth, completionId } = await searchParams + const { oauth, completionId, organizationId } = await searchParams const failure = oauth === undefined ? undefined : isCredentialGroupOAuthFailure(oauth) ? oauth : 'failed' + const returnHref = + typeof organizationId === 'string' && organizationId.length > 0 && organizationId.length <= 128 + ? organizationRoutes(encodeURIComponent(organizationId)).integrations + : undefined const error = failure ? CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES[failure] : undefined return ( {typeof completionId === 'string' && isValidUuid(completionId) && ( - + )} - {error && ( + {(error || returnHref) && (
- Open Sim + + {returnHref ? 'Return to Integrations' : 'Open Sim'} +
)}
diff --git a/apps/sim/app/o/[organizationId]/integrations/indexed/github-member-integration.tsx b/apps/sim/app/o/[organizationId]/integrations/indexed/github-member-integration.tsx index 2f1a843ca05..9d0ab7805a1 100644 --- a/apps/sim/app/o/[organizationId]/integrations/indexed/github-member-integration.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/indexed/github-member-integration.tsx @@ -1,7 +1,6 @@ 'use client' import { Chip, toast } from '@sim/emcn' -import type { OrganizationAccountConnectionResponse } from '@/lib/api/contracts/organization-accounts' import { DisconnectAccountMenu } from '@/app/o/[organizationId]/integrations/disconnect-account-menu' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' @@ -40,9 +39,6 @@ export function GitHubMemberIntegration({ const loading = inventory.isPending && !inventory.data const failed = inventory.isError const meta = CONNECTOR_META_REGISTRY.github - const navigate = (result: OrganizationAccountConnectionResponse | null) => { - if (result) window.location.assign(result.authorizationUrl ?? result.invitationLink) - } const onError = (error: Error) => toast.error(error.message) const description = account ? `${accounts.map((entry) => entry.displayName).join(', ')} · ${account.status === 'needs_reauth' ? 'Reconnect required' : 'Connected'}` @@ -75,9 +71,7 @@ export function GitHubMemberIntegration({ - reconnect.mutate(account.credentialId, { onSuccess: navigate, onError }) - } + onClick={() => reconnect.mutate(account.credentialId, { onError })} > Reconnect @@ -85,12 +79,7 @@ export function GitHubMemberIntegration({ - connect.mutate( - { organizationId, optionId: option.id }, - { onSuccess: navigate, onError } - ) - } + onClick={() => connect.mutate({ organizationId, optionId: option.id }, { onError })} > Connect diff --git a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx index 86d9c1d59dd..356ca0689f2 100644 --- a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx @@ -1,7 +1,6 @@ 'use client' import { Chip, toast } from '@sim/emcn' -import type { OrganizationAccountConnectionResponse } from '@/lib/api/contracts/organization-accounts' import { LIVE_SEARCH_SCOPE_FIELDS } from '@/lib/sim-search/live/policy-schema' import { liveSearchProviderForCredential } from '@/lib/sim-search/live/provider-catalog' import { @@ -36,8 +35,6 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt const secrets = useOrganizationSecretSource(organizationId) const connect = useConnectOrganizationAccount() const reconnect = useReconnectPersonalOrganizationAccount() - const navigate = (result: OrganizationAccountConnectionResponse | null) => - result && window.location.assign(result.authorizationUrl ?? result.invitationLink) const onError = (error: Error) => toast.error(error.message) const error = inventory.error ?? policies.error ?? secrets.error if (error) @@ -180,9 +177,7 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt - reconnect.mutate(account.credentialId, { onSuccess: navigate, onError }) - } + onClick={() => reconnect.mutate(account.credentialId, { onError })} > Reconnect{accounts.length > 1 ? ` ${account.displayName}` : ''} @@ -197,7 +192,7 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt organizationId, ...(server ? { mcpServerId: server.id } : { optionId: option!.id }), }, - { onSuccess: navigate, onError } + { onError } ) } > diff --git a/apps/sim/hooks/queries/organization-accounts.ts b/apps/sim/hooks/queries/organization-accounts.ts index c85b68572a5..e64918f009b 100644 --- a/apps/sim/hooks/queries/organization-accounts.ts +++ b/apps/sim/hooks/queries/organization-accounts.ts @@ -1,5 +1,7 @@ 'use client' +import { useEffect, useRef } from 'react' +import { generateId } from '@sim/utils/id' import { isServer, useInfiniteQuery, @@ -10,6 +12,7 @@ import { import { useRouter } from 'next/navigation' import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' +import type { DesktopSourceRequest } from '@/lib/api/contracts/desktop-source-connect' import { type AddOrganizationAccountMcpProviderBody, addOrganizationAccountMcpProviderContract, @@ -39,6 +42,7 @@ import { updateOrganizationAccountsContract, updateOrganizationAccountWorkspaceAccessContract, } from '@/lib/api/contracts/organization-accounts' +import { connectCredentialGroupInPopup } from '@/lib/credential-groups/oauth-popup' import { isDesktopApp } from '@/lib/desktop' import { connectDesktopSource } from '@/lib/desktop/source-connect' import { personalCredentialKeys } from '@/hooks/queries/personal-credentials' @@ -50,20 +54,57 @@ import { slackSearchKeys } from '@/hooks/queries/utils/slack-search-keys' export const ORGANIZATION_ACCOUNTS_STALE_TIME = 30_000 -export function useReconnectPersonalOrganizationAccount() { +function useAccountConnectionMutation( + requestFor: ( + variables: Variables + ) => Extract +) { const client = useQueryClient() + const pending = useRef(null) + useEffect(() => () => pending.current?.abort(), []) return useMutation({ - mutationFn: async (credentialId: string) => { + mutationFn: async (variables: Variables) => { + pending.current?.abort() + const controller = new AbortController() + pending.current = controller + const completionId = generateId() + const input = requestFor(variables) + const request = + input.kind === 'organization-account' + ? { ...input, body: { ...input.body, oauthCompletionId: completionId } } + : { ...input, completionId } if (isDesktopApp()) { - await connectDesktopSource({ kind: 'reconnect-account', credentialId }) - return null + await connectDesktopSource(request, controller.signal) + } else { + await connectCredentialGroupInPopup( + completionId, + (signal) => + request.kind === 'organization-account' + ? requestJson(startOrganizationAccountConnectionContract, { + params: { id: request.organizationId }, + body: request.body, + signal, + }) + : requestJson(reconnectPersonalOrganizationAccountContract, { + params: { credentialId: request.credentialId }, + query: { oauthCompletionId: completionId }, + signal, + }), + controller.signal + ) } - return requestJson(reconnectPersonalOrganizationAccountContract, { params: { credentialId } }) }, onSettled: () => refreshAccounts(client), }) } +export function useReconnectPersonalOrganizationAccount() { + return useAccountConnectionMutation((credentialId: string) => ({ + kind: 'reconnect-account', + credentialId, + })) +} + /** Disconnects an owned grant; indexing and source setup do not gate this operation. */ export function useDisconnectPersonalOrganizationAccount(organizationId: string) { const queryClient = useQueryClient() @@ -213,23 +254,16 @@ async function refreshAccounts(client: ReturnType) { } export function useConnectOrganizationAccount() { - const client = useQueryClient() - return useMutation({ - mutationFn: async ({ + return useAccountConnectionMutation( + ({ organizationId, ...body - }: { organizationId: string } & StartOrganizationAccountConnectionBody) => { - if (isDesktopApp()) { - await connectDesktopSource({ kind: 'organization-account', organizationId, body }) - return null - } - return requestJson(startOrganizationAccountConnectionContract, { - params: { id: organizationId }, - body, - }) - }, - onSettled: () => refreshAccounts(client), - }) + }: { organizationId: string } & StartOrganizationAccountConnectionBody) => ({ + kind: 'organization-account', + organizationId, + body, + }) + ) } export function useWorkspaceOrganizationAccounts(workspaceId?: string, enabled = true) { diff --git a/apps/sim/lib/api/contracts/desktop-source-connect.ts b/apps/sim/lib/api/contracts/desktop-source-connect.ts index 6597a22ddf1..b18e749bbf2 100644 --- a/apps/sim/lib/api/contracts/desktop-source-connect.ts +++ b/apps/sim/lib/api/contracts/desktop-source-connect.ts @@ -17,7 +17,11 @@ export const desktopSourceRequestSchema = z.discriminatedUnion('kind', [ organizationId: organizationIdSchema, body: startOrganizationAccountConnectionBodySchema, }), - z.object({ kind: z.literal('reconnect-account'), credentialId: z.string().min(1).max(128) }), + z.object({ + kind: z.literal('reconnect-account'), + credentialId: z.string().min(1).max(128), + completionId: z.string().uuid().optional(), + }), z.object({ kind: z.literal('personal-search'), body: connectPersonalSearchIntegrationBodySchema, diff --git a/apps/sim/lib/api/contracts/organization-accounts.ts b/apps/sim/lib/api/contracts/organization-accounts.ts index 376098f9f30..8fd59a09d5b 100644 --- a/apps/sim/lib/api/contracts/organization-accounts.ts +++ b/apps/sim/lib/api/contracts/organization-accounts.ts @@ -102,8 +102,18 @@ export type OrganizationAccountConnectionResponse = z.output< > export const startOrganizationAccountConnectionBodySchema = z.union([ - z.object({ optionId: z.string().min(1).max(128) }).strict(), - z.object({ mcpServerId: z.string().min(1).max(128) }).strict(), + z + .object({ + optionId: z.string().min(1).max(128), + oauthCompletionId: z.string().uuid().optional(), + }) + .strict(), + z + .object({ + mcpServerId: z.string().min(1).max(128), + oauthCompletionId: z.string().uuid().optional(), + }) + .strict(), ]) export type StartOrganizationAccountConnectionBody = z.input< typeof startOrganizationAccountConnectionBodySchema @@ -372,10 +382,18 @@ export const listPersonalOrganizationAccountsContract = defineRouteContract({ }), }, }) +export const reconnectPersonalOrganizationAccountQuerySchema = z.object({ + oauthCompletionId: z.string().uuid().optional(), +}) +export type ReconnectPersonalOrganizationAccountQuery = z.input< + typeof reconnectPersonalOrganizationAccountQuerySchema +> + export const reconnectPersonalOrganizationAccountContract = defineRouteContract({ method: 'POST', path: '/api/users/me/organization-accounts/[credentialId]/reconnect', params: z.object({ credentialId: z.string().min(1).max(128) }), + query: reconnectPersonalOrganizationAccountQuerySchema, response: { mode: 'json', schema: organizationAccountConnectionResponseSchema }, }) export const disconnectPersonalOrganizationAccountContract = defineRouteContract({ diff --git a/apps/sim/lib/credential-groups/application/organization-accounts.ts b/apps/sim/lib/credential-groups/application/organization-accounts.ts index bd51cf4cf16..0d437db28d8 100644 --- a/apps/sim/lib/credential-groups/application/organization-accounts.ts +++ b/apps/sim/lib/credential-groups/application/organization-accounts.ts @@ -29,7 +29,10 @@ import { } from '@/lib/credential-groups/provider-availability' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' import { createViewerCredentialGroupEnrollment } from '@/lib/credential-groups/self-enrollment' -import { startViewerCredentialGroupOAuth } from '@/lib/credential-groups/self-enrollment-oauth' +import { + startViewerCredentialGroupMcpOAuth, + startViewerCredentialGroupOAuth, +} from '@/lib/credential-groups/self-enrollment-oauth' import { ensureWorkspaceAccountsGroup, getOrganizationAccountsGroup, @@ -247,7 +250,7 @@ export const startOrganizationAccountConnection = defineOrganizationAccountsUseC }: { input: OrganizationAccountsInput & StartOrganizationAccountConnectionBody & { - oauthCompletionId?: string + returnTo?: 'integrations' connectionIntent?: CredentialGroupConnectionIntent } context: OrganizationMembershipContext @@ -258,6 +261,16 @@ export const startOrganizationAccountConnection = defineOrganizationAccountsUseC if ('mcpServerId' in input) { if (!group.mcpServers.some((server) => server.id === input.mcpServerId && server.enabled)) throw new OrchestrationError('not_found', 'This account provider is no longer available') + if (input.oauthCompletionId) { + return startViewerCredentialGroupMcpOAuth({ + organizationId: context.organizationId, + userId: context.userId, + credentialGroupId: group.id, + mcpServerId: input.mcpServerId, + completionId: input.oauthCompletionId, + returnTo: input.returnTo, + }) + } const { invitationLink } = await createViewerCredentialGroupEnrollment({ organizationId: context.organizationId, userId: context.userId, @@ -280,6 +293,7 @@ export const startOrganizationAccountConnection = defineOrganizationAccountsUseC credentialGroupId: group.id, optionId: input.optionId, completionId: input.oauthCompletionId, + returnTo: input.returnTo, connectionIntent: input.connectionIntent, }) } diff --git a/apps/sim/lib/credential-groups/application/personal-organization-accounts.ts b/apps/sim/lib/credential-groups/application/personal-organization-accounts.ts index afd6096403a..e600968903c 100644 --- a/apps/sim/lib/credential-groups/application/personal-organization-accounts.ts +++ b/apps/sim/lib/credential-groups/application/personal-organization-accounts.ts @@ -18,6 +18,10 @@ import { createCredentialGroupOAuthStartUrl } from '@/lib/credential-groups/enro import { lockCredentialGroupEnrollmentLifecycle } from '@/lib/credential-groups/enrollments' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' import { createViewerCredentialGroupEnrollment } from '@/lib/credential-groups/self-enrollment' +import { + startViewerCredentialGroupMcpOAuth, + startViewerCredentialGroupOAuth, +} from '@/lib/credential-groups/self-enrollment-oauth' import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' import type { DbOrTx } from '@/lib/db/types' import { evictMcpServerConnections } from '@/lib/mcp/connection-pool' @@ -70,6 +74,7 @@ function ownAccounts( enrollmentStatus: credentialGroupEnrollment.status, optionId: credential.credentialGroupOptionId, mcpProvider: mcpServers.managedConnectorId, + mcpServerId: credential.mcpServerId, }) .from(credential) .innerJoin( @@ -127,7 +132,7 @@ export const reconnectPersonalOrganizationAccount = defineAuthorizedCredentialUs input, }: { principal: SessionPrincipal - input: { credentialId: string } + input: { credentialId: string; oauthCompletionId?: string } }) { const [account] = await ownAccounts(principal.userId, input) if (!account) throw new OrchestrationError('not_found', 'Connected account not found') @@ -143,6 +148,29 @@ export const reconnectPersonalOrganizationAccount = defineAuthorizedCredentialUs })) ) throw new OrchestrationError('forbidden', 'Organization connected accounts are unavailable') + if (input.oauthCompletionId) { + const connection = { + organizationId: account.organizationId, + userId: principal.userId, + credentialGroupId: account.groupId, + completionId: input.oauthCompletionId, + returnTo: 'integrations' as const, + } + if (account.type === 'managed_oauth' && account.optionId) { + return startViewerCredentialGroupOAuth({ + ...connection, + optionId: account.optionId, + connectionIntent: { kind: 'reconnect', credentialId: account.credentialId }, + }) + } + if (account.type === 'managed_mcp' && account.mcpServerId) { + return startViewerCredentialGroupMcpOAuth({ + ...connection, + mcpServerId: account.mcpServerId, + }) + } + throw new OrchestrationError('not_found', 'This account provider is no longer available') + } const { invitationLink } = await createViewerCredentialGroupEnrollment({ organizationId: account.organizationId, credentialGroupId: account.groupId, diff --git a/apps/sim/lib/credential-groups/mcp-oauth-state.test.ts b/apps/sim/lib/credential-groups/mcp-oauth-state.test.ts index 78e555a6b04..406602722c4 100644 --- a/apps/sim/lib/credential-groups/mcp-oauth-state.test.ts +++ b/apps/sim/lib/credential-groups/mcp-oauth-state.test.ts @@ -92,6 +92,21 @@ describe('Credential Group MCP OAuth state', () => { await expect(consumeCredentialGroupMcpOAuthAttempt(state)).resolves.toBeNull() }) + it('preserves the direct completion destination through one-time state consumption', async () => { + const completionId = '00000000-0000-4000-8000-000000000002' + await createCredentialGroupMcpOAuthAttempt({ + ...ATTEMPT, + state: 'mcp_cg_direct', + completionId, + returnTo: 'integrations', + }) + await expect(consumeCredentialGroupMcpOAuthAttempt('mcp_cg_direct')).resolves.toMatchObject({ + completionId, + returnTo: 'integrations', + }) + await expect(consumeCredentialGroupMcpOAuthAttempt('mcp_cg_direct')).resolves.toBeNull() + }) + it('fails closed when Redis is unavailable', async () => { vi.mocked(getRedisClient).mockReturnValue(null) diff --git a/apps/sim/lib/credential-groups/mcp-oauth-state.ts b/apps/sim/lib/credential-groups/mcp-oauth-state.ts index 63dd0b4d2e3..33f2c3513fb 100644 --- a/apps/sim/lib/credential-groups/mcp-oauth-state.ts +++ b/apps/sim/lib/credential-groups/mcp-oauth-state.ts @@ -1,4 +1,5 @@ import { sha256Hex } from '@sim/security/hash' +import { isValidUuid } from '@sim/utils/id' import { getRedisClient } from '@/lib/core/config/redis' import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' @@ -27,6 +28,8 @@ return #keys ` interface StoredCredentialGroupMcpOAuthAttempt { + completionId?: string + returnTo?: 'integrations' oauthConfigVersion: number configurationFingerprint?: string userId: string @@ -43,6 +46,8 @@ interface StoredCredentialGroupMcpOAuthAttempt { } export interface CredentialGroupMcpOAuthAttempt { + completionId?: string + returnTo?: 'integrations' oauthConfigVersion: number configurationFingerprint?: string userId: string @@ -83,6 +88,9 @@ function isStoredAttempt(value: unknown): value is StoredCredentialGroupMcpOAuth (candidate.configurationFingerprint === undefined || (typeof candidate.configurationFingerprint === 'string' && /^[a-f0-9]{64}$/.test(candidate.configurationFingerprint))) && + (candidate.completionId === undefined || + (typeof candidate.completionId === 'string' && isValidUuid(candidate.completionId))) && + (candidate.returnTo === undefined || candidate.returnTo === 'integrations') && typeof candidate.userId === 'string' && candidate.userId.length > 0 && ((typeof candidate.workspaceId === 'string' && @@ -108,6 +116,8 @@ export function isCredentialGroupMcpOAuthState(state: string): boolean { } export async function createCredentialGroupMcpOAuthAttempt(params: { + completionId?: string + returnTo?: 'integrations' oauthConfigVersion: number configurationFingerprint?: string userId: string @@ -121,6 +131,9 @@ export async function createCredentialGroupMcpOAuthAttempt(params: { codeVerifier: string invitationToken: string }): Promise { + if (params.completionId !== undefined && !isValidUuid(params.completionId)) { + throw new Error('OAuth completion requires a valid correlation ID') + } if (!isCredentialGroupMcpOAuthState(params.state)) { throw new Error('Managed MCP OAuth state has an invalid prefix') } @@ -131,6 +144,8 @@ export async function createCredentialGroupMcpOAuthAttempt(params: { ]) const attempt: StoredCredentialGroupMcpOAuthAttempt = { version: MCP_OAUTH_ATTEMPT_VERSION, + completionId: params.completionId, + returnTo: params.returnTo, oauthConfigVersion: params.oauthConfigVersion, configurationFingerprint: params.configurationFingerprint, userId: params.userId, @@ -173,6 +188,8 @@ export async function consumeCredentialGroupMcpOAuthAttempt( ]) return { state, + completionId: parsed.completionId, + returnTo: parsed.returnTo, oauthConfigVersion: parsed.oauthConfigVersion, configurationFingerprint: parsed.configurationFingerprint, userId: parsed.userId, diff --git a/apps/sim/lib/credential-groups/mcp-oauth.ts b/apps/sim/lib/credential-groups/mcp-oauth.ts index 2ab6715cfe2..073f40a7388 100644 --- a/apps/sim/lib/credential-groups/mcp-oauth.ts +++ b/apps/sim/lib/credential-groups/mcp-oauth.ts @@ -18,7 +18,8 @@ import { mcpService } from '@/lib/mcp/service' export async function startCredentialGroupMcpOAuth( context: CredentialGroupMcpOAuthContext, - invitationToken: string + invitationToken: string, + completion: { completionId?: string; returnTo?: 'integrations' } = {} ): Promise { assertSafeOauthServerUrl(context.server.url) return withMcpOauthRefreshLock(context.server.id, async () => { @@ -55,6 +56,7 @@ export async function startCredentialGroupMcpOAuth( credentialGroupId: context.credentialGroupId, mcpServerId: context.server.id, invitationToken, + ...completion, }) return error.authorizationUrl } diff --git a/apps/sim/lib/credential-groups/oauth-popup.ts b/apps/sim/lib/credential-groups/oauth-popup.ts new file mode 100644 index 00000000000..3fc2f765b2b --- /dev/null +++ b/apps/sim/lib/credential-groups/oauth-popup.ts @@ -0,0 +1,81 @@ +import { toast } from '@sim/emcn' +import { toError } from '@sim/utils/errors' +import type { OrganizationAccountConnectionResponse } from '@/lib/api/contracts/organization-accounts' +import { + CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES, + credentialGroupOAuthCompletionChannel, + isCredentialGroupOAuthFailure, +} from '@/lib/credential-groups/oauth-completion' + +const CONNECTION_TIMEOUT_MS = 10 * 60_000 + +/** Keeps the initiating surface open; provider window isolation is not a cancellation signal. */ +export async function connectCredentialGroupInPopup( + completionId: string, + start: (signal: AbortSignal) => Promise, + signal: AbortSignal +): Promise { + signal.throwIfAborted() + const popup = + typeof BroadcastChannel === 'undefined' ? null : window.open('about:blank', '_blank') + if (!popup) { + const result = await start(signal) + signal.throwIfAborted() + window.location.assign(result.authorizationUrl ?? result.invitationLink) + return + } + popup.opener = null + return new Promise((resolve, reject) => { + const controller = new AbortController() + const channel = new BroadcastChannel(credentialGroupOAuthCompletionChannel(completionId)) + let settled = false + const finish = (error?: Error) => { + if (settled) return + settled = true + controller.abort() + clearTimeout(timer) + channel.close() + signal.removeEventListener('abort', abort) + toast.dismiss(notice) + if (error) reject(error) + else resolve() + } + const abort = () => { + finish(new Error('Connection canceled. You can try again.')) + popup.close() + } + const timer = setTimeout(() => { + finish(new Error(CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES.expired)) + }, CONNECTION_TIMEOUT_MS) + const notice = toast({ + message: 'Continue connecting in the authorization window', + duration: 0, + persistAcrossRoutes: true, + action: { label: 'Cancel', onClick: abort }, + onUserDismiss: abort, + }) + channel.onmessage = ({ data }: MessageEvent) => { + if (data === 'connected') finish() + else if (isCredentialGroupOAuthFailure(data)) + finish(new Error(CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES[data])) + } + signal.addEventListener('abort', abort, { once: true }) + if (signal.aborted) { + abort() + return + } + void start(controller.signal) + .then((result) => { + if (settled) return + if (popup.closed) { + abort() + return + } + popup.location.replace(result.authorizationUrl ?? result.invitationLink) + }) + .catch((error) => { + finish(toError(error)) + popup.close() + }) + }) +} diff --git a/apps/sim/lib/credential-groups/oauth-state.ts b/apps/sim/lib/credential-groups/oauth-state.ts index 97abfbb9663..c99fc24d93b 100644 --- a/apps/sim/lib/credential-groups/oauth-state.ts +++ b/apps/sim/lib/credential-groups/oauth-state.ts @@ -44,7 +44,7 @@ interface StoredCredentialGroupOAuthAttempt { completionRedirect?: boolean connectionIntent?: CredentialGroupConnectionIntent completionId?: string - returnTo?: 'search' | 'accounts' | 'github-installation' + returnTo?: 'search' | 'accounts' | 'integrations' | 'github-installation' nonceHash: string encryptedCodeVerifier?: string encryptedInvitationToken: string @@ -69,7 +69,7 @@ export interface CredentialGroupOAuthAttempt { completionRedirect?: boolean connectionIntent?: CredentialGroupConnectionIntent completionId?: string - returnTo?: 'search' | 'accounts' | 'github-installation' + returnTo?: 'search' | 'accounts' | 'integrations' | 'github-installation' codeVerifier?: string invitationToken: string createdAt: number @@ -91,7 +91,7 @@ interface CreateCredentialGroupOAuthAttemptParams { completionRedirect?: boolean connectionIntent?: CredentialGroupConnectionIntent completionId?: string - returnTo?: 'search' | 'accounts' | 'github-installation' + returnTo?: 'search' | 'accounts' | 'integrations' | 'github-installation' codeVerifier?: string invitationToken: string } @@ -148,6 +148,7 @@ function isStoredAttempt(value: unknown): value is StoredCredentialGroupOAuthAtt (candidate.returnTo === undefined || candidate.returnTo === 'search' || candidate.returnTo === 'accounts' || + candidate.returnTo === 'integrations' || (candidate.returnTo === 'github-installation' && candidate.provider === 'github-repositories' && typeof candidate.organizationId === 'string' && diff --git a/apps/sim/lib/credential-groups/oauth.ts b/apps/sim/lib/credential-groups/oauth.ts index 6f5b50dd512..e820cf93e0c 100644 --- a/apps/sim/lib/credential-groups/oauth.ts +++ b/apps/sim/lib/credential-groups/oauth.ts @@ -117,7 +117,7 @@ export async function startCredentialGroupOAuth( completionRedirect?: boolean connectionIntent?: CredentialGroupConnectionIntent completionId?: string - returnTo?: 'search' | 'accounts' | 'github-installation' + returnTo?: 'search' | 'accounts' | 'integrations' | 'github-installation' } = {} ): Promise { if (!context.credentialOwnerId) throw new CredentialGroupInvitationUnavailableError() diff --git a/apps/sim/lib/credential-groups/self-enrollment-oauth.ts b/apps/sim/lib/credential-groups/self-enrollment-oauth.ts index 26bbe43c16f..79e474798c0 100644 --- a/apps/sim/lib/credential-groups/self-enrollment-oauth.ts +++ b/apps/sim/lib/credential-groups/self-enrollment-oauth.ts @@ -1,5 +1,9 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' -import { getCredentialGroupOAuthContextForEnrollment } from '@/lib/credential-groups/enrollments' +import { + getCredentialGroupMcpOAuthContextForEnrollment, + getCredentialGroupOAuthContextForEnrollment, +} from '@/lib/credential-groups/enrollments' +import { startCredentialGroupMcpOAuth } from '@/lib/credential-groups/mcp-oauth' import { startCredentialGroupOAuth } from '@/lib/credential-groups/oauth' import type { CredentialGroupConnectionIntent } from '@/lib/credential-groups/oauth-intent' import { createViewerCredentialGroupEnrollment } from '@/lib/credential-groups/self-enrollment' @@ -12,6 +16,7 @@ export async function startViewerCredentialGroupOAuth(input: { credentialGroupId: string optionId: string completionId: string + returnTo?: 'integrations' connectionIntent?: CredentialGroupConnectionIntent }): Promise<{ invitationLink: string; authorizationUrl: string }> { const { enrollment, invitationLink } = await createViewerCredentialGroupEnrollment(input) @@ -32,9 +37,40 @@ export async function startViewerCredentialGroupOAuth(input: { throw new OrchestrationError('forbidden', 'This account connection is no longer available') const authorizationUrl = await startCredentialGroupOAuth(oauth, token, { completionRedirect: true, - returnTo: 'search', + returnTo: input.returnTo ?? 'search', completionId: input.completionId, connectionIntent: input.connectionIntent, }) return { invitationLink, authorizationUrl } } + +/** Starts one managed MCP account without turning the connection into an invitation submission. */ +export async function startViewerCredentialGroupMcpOAuth(input: { + userId: string + organizationId: string + credentialGroupId: string + mcpServerId: string + completionId: string + returnTo?: 'integrations' +}): Promise<{ invitationLink: string; authorizationUrl: string }> { + const { enrollment, invitationLink } = await createViewerCredentialGroupEnrollment(input) + const token = new URL(invitationLink).pathname.split('/').at(-1) + if (!token) throw new Error('Account enrollment did not return an invitation token') + const oauth = await getCredentialGroupMcpOAuthContextForEnrollment( + { + organizationId: input.organizationId, + credentialGroupId: input.credentialGroupId, + enrollmentId: enrollment.id, + email: enrollment.email, + userId: input.userId, + }, + input.mcpServerId + ) + if (!oauth) + throw new OrchestrationError('forbidden', 'This account connection is no longer available') + const authorizationUrl = await startCredentialGroupMcpOAuth(oauth, token, { + completionId: input.completionId, + returnTo: input.returnTo, + }) + return { invitationLink, authorizationUrl } +} diff --git a/apps/sim/lib/desktop/source-browser.ts b/apps/sim/lib/desktop/source-browser.ts index efe9a127b83..3494632bc43 100644 --- a/apps/sim/lib/desktop/source-browser.ts +++ b/apps/sim/lib/desktop/source-browser.ts @@ -85,10 +85,17 @@ async function startRequest( }) : await requestJson(reconnectPersonalOrganizationAccountContract, { params: { credentialId: request.credentialId }, + query: { oauthCompletionId: request.completionId }, }) + const completionId = + request.kind === 'organization-account' + ? request.body.oauthCompletionId + : request.completionId return { url: result.authorizationUrl ?? result.invitationLink, - match: enrollmentMatch(result.invitationLink), + match: completionId + ? { kind: 'completion', id: completionId } + : enrollmentMatch(result.invitationLink), } } case 'personal-search': { diff --git a/apps/sim/scripts/fixtures/desktop-source-connect.tsx b/apps/sim/scripts/fixtures/desktop-source-connect.tsx index 52028aded79..6eda9807670 100644 --- a/apps/sim/scripts/fixtures/desktop-source-connect.tsx +++ b/apps/sim/scripts/fixtures/desktop-source-connect.tsx @@ -7,6 +7,11 @@ import { CredentialGroupCompletionHandoff } from '@/app/credential-groups/comple import { SlackCompletion } from '@/app/credential-groups/slack-complete/slack-completion' import { SourceCompletion } from '@/app/desktop/connect/source-completion' import { useMemberEnrollment } from '@/app/o/[organizationId]/integrations/indexed/use-member-enrollment' +import { + useConnectOrganizationAccount, + useOrganizationAccounts, + useReconnectPersonalOrganizationAccount, +} from '@/hooks/queries/organization-accounts' import { useSlackSearchInstallations, useStartSlackSearchOAuth } from '@/hooks/queries/slack-search' import { useGitHubInstallationSetup } from '@/hooks/use-github-installation-setup' @@ -14,6 +19,9 @@ const NO_CONNECTIONS = new Set() const MEMBERSHIP_KEYS: readonly (readonly string[])[] = [] function SourceConnectFixture() { + const accountConnection = useConnectOrganizationAccount() + const reconnect = useReconnectPersonalOrganizationAccount() + const accounts = useOrganizationAccounts('fixture-organization') const enrollment = useMemberEnrollment({ membershipQueryKeys: MEMBERSHIP_KEYS, connectedConnectorIds: NO_CONNECTIONS, @@ -26,7 +34,36 @@ function SourceConnectFixture() { const connection = useStartSlackSearchOAuth() const inventory = useSlackSearchInstallations('fixture-organization') return ( -
+
+ + + + {accountConnection.status} + {accountConnection.error?.message} + {reconnect.status} + {accounts.data?.viewerAccounts?.length ?? 0}