diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml index d8c2ddafde0..14ebd277e2d 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -14,6 +14,13 @@ on: - 'apps/sim/app/layout.tsx' - 'apps/sim/hooks/use-desktop-update-state.ts' - 'apps/sim/lib/desktop/**' + - 'apps/sim/app/desktop/connect/**' + - 'apps/sim/app/credential-groups/**' + - 'apps/sim/hooks/queries/slack-search.ts' + - 'apps/sim/hooks/use-github-installation-setup.ts' + - 'apps/sim/app/o/**/integrations/indexed/use-member-enrollment.ts' + - 'apps/sim/lib/api/contracts/desktop-source-connect.ts' + - 'apps/sim/scripts/fixtures/desktop-source-connect.tsx' - 'apps/sim/app/workspace/**/browser-session/**' - 'apps/sim/app/_styles/**' - 'apps/sim/lib/postcss/**' @@ -61,6 +68,10 @@ jobs: working-directory: apps/desktop run: bun run build + - name: Install system-browser fixture + working-directory: apps/desktop + run: bunx playwright install chromium + - name: Run Playwright _electron smoke suite working-directory: apps/desktop run: bunx playwright test @@ -98,6 +109,10 @@ jobs: working-directory: apps/desktop run: bun run build + - name: Install system-browser fixture + working-directory: apps/desktop + run: bunx playwright install chromium + - name: Run Playwright _electron smoke suite working-directory: apps/desktop run: bunx playwright test diff --git a/apps/desktop/e2e/fixtures/browser-buffer.ts b/apps/desktop/e2e/fixtures/browser-buffer.ts new file mode 100644 index 00000000000..e776657825d --- /dev/null +++ b/apps/desktop/e2e/fixtures/browser-buffer.ts @@ -0,0 +1,2 @@ +/** Matches Next's browser Buffer polyfill when bundling application code with esbuild. */ +export { Buffer } from 'buffer' diff --git a/apps/desktop/e2e/source-connect.spec.ts b/apps/desktop/e2e/source-connect.spec.ts new file mode 100644 index 00000000000..27d701ff0cd --- /dev/null +++ b/apps/desktop/e2e/source-connect.spec.ts @@ -0,0 +1,404 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, _electron as electron, expect, test } from '@playwright/test' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { generateShortId } from '@sim/utils/id' +import { build } from 'esbuild' + +const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) +const SIM_DIR = fileURLToPath(new URL('../../sim/', import.meta.url)) +const FIXTURE = fileURLToPath( + new URL('../../sim/scripts/fixtures/desktop-source-connect.tsx', import.meta.url) +) + +/** Real renderer, preload, main process, loopback, and a separate browser cookie jar. */ +test('source authorization returns to its desktop screen and refreshes live', async () => { + const reportPath = + process.env.DESKTOP_SOURCE_CONNECT_REPORT_PATH ?? test.info().outputPath('source-connect.json') + const checks: { + name: string + status: 'passed' | 'failed' + durationMs: number + error?: string + }[] = [] + const check = async (name: string, action: () => Promise) => { + const started = Date.now() + try { + await test.step(name, action) + checks.push({ name, status: 'passed', durationMs: Date.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: Date.now() - started, + error: getErrorMessage(error), + }) + throw error + } + } + const tickets = new Map() + const attempts = new Map() + const startSessions: string[] = [] + const callbackSessions: string[] = [] + const githubAttempts = new Map() + const githubStartSessions: string[] = [] + const githubInventorySessions: string[] = [] + let nativeCredentialVisible = false + let installed = false + let javascript = '' + let origin = '' + let app: Awaited> | undefined + let browser: Awaited> | undefined + const userData = mkdtempSync(join(tmpdir(), 'sim-source-connect-e2e-')) + const server = createServer(async (request, response) => { + const url = new URL(request.url ?? '/', origin || 'http://localhost') + const path = url.pathname + const session = request.headers.cookie?.includes('browser-fixture') + ? 'browser-fixture' + : 'desktop-fixture' + const json = (value: unknown, status = 200) => { + response.writeHead(status, { 'content-type': 'application/json' }) + response.end(JSON.stringify(value)) + } + const redirect = (target: string) => { + response.writeHead(303, { location: target }) + response.end() + } + const body = async () => { + let text = '' + 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) + return + } + if (path === '/api/auth/get-session') { + json({ user: { id: 'fixture-user' }, session: { id: session } }) + return + } + if (path === '/api/desktop/source-connect') { + const { requestId, request: sourceRequest } = await body() + if (startSessions.length === 0) await sleep(5_500) + tickets.set(requestId, sourceRequest) + json({ requestId }) + return + } + if (path === '/api/desktop/source-connect/consume') { + const { requestId } = await body() + const ticket = tickets.get(requestId) + tickets.delete(requestId) + json(ticket ?? { error: 'expired' }, ticket ? 200 : 404) + return + } + if (path === '/api/knowledge/slack/oauth') { + await body() + const state = generateShortId(32) + attempts.set(state, session) + startSessions.push(session) + json({ authorizationUrl: `${origin}/provider?state=${state}` }) + return + } + if (path === '/api/knowledge/slack/oauth/callback') { + const state = url.searchParams.get('state') ?? '' + callbackSessions.push(session) + const ok = attempts.get(state) === session && url.searchParams.has('code') + attempts.delete(state) + if (ok) installed = true + redirect(`/credential-groups/slack-complete?state=${state}&ok=${ok}`) + return + } + if ( + path === + '/api/knowledge/00000000-0000-4000-8000-000000000001/connectors/fixture-connector/enroll' + ) { + if (url.searchParams.has('oauthCompletionId')) + json({ error: 'Direct account connection requires a Search source' }, 400) + else + json({ + success: true, + data: { url: `${origin}/credential-groups/enroll/fixture-invitation` }, + }) + return + } + if (path === '/api/knowledge/github/setup') { + if (request.method === 'POST') { + const { setupId } = await body() + githubStartSessions.push(session) + githubAttempts.set(setupId, { session, completed: false }) + json({ success: true, url: `${origin}/github-provider?setupId=${setupId}` }) + } else { + const attempt = githubAttempts.get(url.searchParams.get('setupId') ?? '') + if (!attempt || attempt.session !== session) json({ error: 'Wrong session' }, 403) + else + json({ + success: true, + data: attempt.completed + ? { + status: 'completed', + credential: { id: 'fixture-github-credential', displayName: 'Fixture GitHub' }, + } + : { status: 'pending' }, + }) + } + return + } + if (path === '/api/organization-credentials/oauth') { + githubInventorySessions.push(session) + await sleep(500) + json({ + credentials: nativeCredentialVisible + ? [ + { + id: 'fixture-github-credential', + name: 'Fixture GitHub', + provider: 'github-repositories', + }, + ] + : [], + }) + return + } + if (path === '/api/organization-credentials') { + json({ credentials: [] }) + return + } + if (path === '/github-callback') { + const setupId = url.searchParams.get('setupId') ?? '' + const attempt = githubAttempts.get(setupId) + if (!attempt || attempt.session !== session) { + json({ error: 'Wrong session' }, 403) + return + } + attempt.completed = true + redirect(`/credential-groups/complete?completionId=${setupId}`) + return + } + if (path === '/api/knowledge/slack') { + json({ + sharedAppAvailable: true, + bots: [], + installations: installed + ? [ + { + id: 'fixture-install', + credentialId: 'fixture-credential', + appId: 'fixture-app', + teamId: 'fixture-team', + teamName: 'Fixture', + appKind: 'shared', + enabled: true, + needsValidation: false, + lastOutcome: null, + lastEventAt: null, + }, + ] + : [], + }) + return + } + if (path === '/desktop/connect/complete') { + const params = new URLSearchParams({ state: url.searchParams.get('state') ?? '' }) + if (url.searchParams.has('error')) params.set('error', url.searchParams.get('error')!) + if (url.searchParams.has('credentialId')) + params.set('credentialId', url.searchParams.get('credentialId')!) + redirect(`http://127.0.0.1:${url.searchParams.get('port')}/connect/callback?${params}`) + return + } + if (path.startsWith('/api/')) { + json({}) + return + } + response.setHeader('content-type', 'text/html') + if (path === '/github-provider') { + response.end( + `Authorize GitHub` + ) + return + } + if (path === '/provider') { + const state = url.searchParams.get('state') ?? '' + response.end( + `AuthorizeCancel` + ) + return + } + if (path === '/desktop/done') { + response.end('

Returned

') + return + } + if (path === '/' || path === '/home') + response.setHeader( + 'set-cookie', + 'better-auth.session_token=desktop-fixture; HttpOnly; SameSite=Lax; Path=/' + ) + response.end('
') + }) + try { + await check('launch the production source hook and native bridge', async () => { + const bundle = await build({ + entryPoints: [FIXTURE], + bundle: true, + write: false, + outfile: test.info().outputPath('fixture.js'), + format: 'iife', + platform: 'browser', + tsconfig: join(SIM_DIR, 'tsconfig.json'), + external: ['node:async_hooks'], + inject: [fileURLToPath(new URL('./fixtures/browser-buffer.ts', import.meta.url))], + banner: { js: 'var process={env:{NODE_ENV:"development"},browser:true};' }, + define: { 'process.env.NODE_ENV': '"development"' }, + }) + javascript = bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.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') + origin = `http://127.0.0.1:${address.port}` + app = await electron.launch({ + args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'], + cwd: DESKTOP_DIR, + env: { ...process.env, SIM_DESKTOP_ORIGIN: origin, SIM_DESKTOP_USER_DATA: userData }, + }) + await app.evaluate(({ shell }) => { + const global = globalThis as typeof globalThis & { openedUrls: string[] } + global.openedUrls = [] + shell.openExternal = async (url) => { + global.openedUrls.push(url) + } + }) + browser = await chromium.launch() + }) + if (!app || !browser) throw new Error('Missing apps') + const shell = app + const page = await app.firstWindow() + const pageErrors: string[] = [] + page.on('pageerror', (error) => pageErrors.push(error.message)) + await page.reload() + await expect.poll(() => pageErrors).toEqual([]) + const context = await browser.newContext() + await context.addCookies([ + { + name: 'better-auth.session_token', + value: 'browser-fixture', + url: origin, + httpOnly: true, + sameSite: 'Lax', + }, + ]) + const external = await context.newPage() + const opened = () => + shell.evaluate(() => (globalThis as typeof globalThis & { openedUrls: string[] }).openedUrls) + await check( + 'separate browser consent completes under its initiating session and refreshes desktop', + async () => { + await page.getByLabel('Source draft').fill('Preserved while connecting') + await page.getByRole('button', { name: 'Connect Slack' }).click() + await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(1) + expect(page.url()).toBe(`${origin}/home`) + await expect(page.getByLabel('Connection')).toHaveText('pending') + await external.goto((await opened())[0]) + await external.getByRole('link', { name: 'Authorize', exact: true }).click() + await expect(page.getByLabel('Connection')).toHaveText('success') + await expect(page.getByLabel('Accounts')).toHaveText('1') + await expect(page.getByLabel('Source draft')).toHaveValue('Preserved while connecting') + expect(startSessions).toEqual(['browser-fixture']) + expect(callbackSessions).toEqual(['browser-fixture']) + await expect(external).toHaveURL(`${origin}/desktop/done?kind=connect`) + } + ) + await check( + 'denied authorization returns an actionable error without navigating desktop', + async () => { + await page.getByRole('button', { name: 'Connect Slack' }).click() + await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(2) + await external.goto((await opened())[1]) + await external.getByRole('link', { name: 'Cancel', exact: true }).click() + await expect(page.getByLabel('Connection')).toHaveText('error') + await expect(page.getByRole('alert')).toContainText('Try connecting again') + expect(page.url()).toBe(`${origin}/home`) + await expect(page.getByLabel('Accounts')).toHaveText('1') + } + ) + await check( + 'native cancellation rejects a stale callback without disrupting the next request', + async () => { + await page.getByRole('button', { name: 'Connect Slack' }).click() + await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(3) + await external.goto((await opened())[2]) + await external.getByRole('link', { name: 'Authorize', exact: true }).waitFor() + const staleCallback = await external + .getByRole('link', { name: 'Authorize', exact: true }) + .getAttribute('href') + await expect(page.getByRole('button', { name: 'Cancel', exact: true })).toHaveCount(1) + await page.getByRole('button', { name: 'Cancel', exact: true }).click() + await expect(page.getByLabel('Connection')).toHaveText('error') + const canceled = new URL((await opened())[2]) + const probe = `http://127.0.0.1:${canceled.searchParams.get('port')}/connect/callback?state=${'x'.repeat(32)}` + await expect + .poll(() => + fetch(probe).then( + () => false, + () => true + ) + ) + .toBe(true) + await page.getByRole('button', { name: 'Connect Slack' }).click() + await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(4) + await external.goto(`${origin}${staleCallback}`) + await expect(page.getByLabel('Connection')).toHaveText('pending') + await external.goto((await opened())[3]) + await external.getByRole('link', { name: 'Authorize', exact: true }).click() + await expect(page.getByLabel('Connection')).toHaveText('success') + await expect(page.getByLabel('Source draft')).toHaveValue('Preserved while connecting') + } + ) + await check( + 'GitHub setup stays in the browser session and verifies the returned credential in desktop', + async () => { + await page.getByRole('button', { name: 'Connect GitHub' }).click() + await expect.poll(async () => (await opened()).length).toBe(5) + await external.goto((await opened())[4]) + await external.getByRole('link', { name: 'Authorize GitHub' }).click() + await expect(page.getByLabel('GitHub error')).toContainText('not available') + await expect(page.getByLabel('GitHub credential')).toHaveText('') + nativeCredentialVisible = true + await page.getByRole('button', { name: 'Connect GitHub' }).click() + await expect.poll(async () => (await opened()).length).toBe(6) + await external.goto((await opened())[5]) + await external.getByRole('link', { name: 'Authorize GitHub' }).click() + await expect.poll(() => githubInventorySessions.length).toBe(2) + await expect(page.getByLabel('GitHub pending')).toHaveText('true') + await expect(page.getByLabel('GitHub credential')).toHaveText('fixture-github-credential') + expect(githubStartSessions).toEqual(['browser-fixture', 'browser-fixture']) + expect(githubInventorySessions).toEqual(['desktop-fixture', 'desktop-fixture']) + await expect(page.getByLabel('GitHub pending')).toHaveText('false') + expect(page.url()).toBe(`${origin}/home`) + } + ) + await check('ordinary knowledge-base enrollment preserves its invitation step', async () => { + await page.getByRole('button', { name: 'Connect invited source' }).click() + await expect.poll(async () => (await opened()).length).toBe(7) + await external.goto((await opened())[6]) + await external.getByRole('link', { name: 'Authorize invited source' }).click() + await expect(page.getByLabel('Enrollment pending')).toHaveText('false') + await expect(page.getByLabel('Enrollment error')).toHaveText('') + expect(page.url()).toBe(`${origin}/home`) + }) + await page.screenshot({ path: test.info().outputPath('source-connect-desktop.png') }) + } finally { + mkdirSync(dirname(reportPath), { recursive: true }) + writeFileSync(reportPath, JSON.stringify({ checks }, null, 2)) + await browser?.close() + await app?.close() + await new Promise((resolve) => { + server.close(() => resolve()) + server.closeAllConnections() + }) + rmSync(userData, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/src/main/handoff.test.ts b/apps/desktop/src/main/handoff.test.ts index 9c2890e38a6..b55193ce83b 100644 --- a/apps/desktop/src/main/handoff.test.ts +++ b/apps/desktop/src/main/handoff.test.ts @@ -162,6 +162,24 @@ describe('createHandoffManager', () => { }) describe('connect handoff account pinning', () => { + it('keeps a source request correlated across the browser and native completion', async () => { + const deps = makeDeps() + const manager = createHandoffManager(deps, makeCallbacks()) + try { + const requestId = manager.prepareSourceConnect() + expect(await manager.beginConnect('source', { sourceRequestId: requestId })).toBe(true) + const landing = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]) + expect(landing.searchParams.get('sourceRequestId')).toBe(requestId) + expect(landing.searchParams.get('user')).toBe('user-1') + expect(manager.consumeConnect(landing.searchParams.get('state')!)).toEqual({ + sourceRequestId: requestId, + }) + expect(manager.consumeConnect(landing.searchParams.get('state')!)).toBeNull() + } finally { + manager.clear() + } + }) + it('pins the connect flow to the account the app is signed in as', async () => { // The OAuth flow runs in the browser under the BROWSER's session, which is // a different row from the app's — without this the credential would attach diff --git a/apps/desktop/src/main/handoff.ts b/apps/desktop/src/main/handoff.ts index c9295941377..caeb3a4b612 100644 --- a/apps/desktop/src/main/handoff.ts +++ b/apps/desktop/src/main/handoff.ts @@ -45,6 +45,7 @@ export interface HandoffCallback { export interface ConnectHandoffCallback { state: string error?: string + credentialId?: string } export interface HandoffCallbacks { @@ -66,6 +67,7 @@ export interface HandoffManagerDeps { /** Optional scope a chip-initiated connect carries into /desktop/connect. */ export interface ConnectScope { + sourceRequestId?: string workspaceId?: string credentialId?: string draftId?: string @@ -77,6 +79,8 @@ export interface HandoffManager { beginConnect(providerId: string, scope?: ConnectScope): Promise consume(state: string, kind: HandoffKind): boolean consumeConnect(state: string): ConnectScope | null + prepareSourceConnect(): string + cancelSourceConnect(requestId: string): boolean clear(): void } @@ -94,6 +98,8 @@ export function createHandoffManager( callbacks: HandoffCallbacks ): HandoffManager { const now = deps.now ?? Date.now + let flowRevision = 0 + let preparedSource: { requestId: string; expiresAt: number } | null = null let loopbackServer: Server | null = null let loopbackTimer: NodeJS.Timeout | undefined let pending: { @@ -139,12 +145,22 @@ export function createHandoffManager( parse: (url) => { const state = url.searchParams.get('state') ?? '' const error = url.searchParams.get('error') - if (!STATE_PATTERN.test(state) || (error !== null && !ERROR_SLUG_PATTERN.test(error))) { + const credentialId = url.searchParams.get('credentialId') + if ( + !STATE_PATTERN.test(state) || + (error !== null && !ERROR_SLUG_PATTERN.test(error)) || + (credentialId !== null && !/^[A-Za-z0-9_-]{1,128}$/.test(credentialId)) + ) { return null } return { state, - dispatch: () => callbacks.onConnect({ state, ...(error !== null ? { error } : {}) }), + dispatch: () => + callbacks.onConnect({ + state, + ...(error !== null ? { error } : {}), + ...(credentialId ? { credentialId } : {}), + }), } }, }, @@ -210,7 +226,11 @@ export function createHandoffManager( }) } catch (error) { logger.error('Could not start the loopback server', { error }) - loopbackServer = null + if (loopbackServer === server) loopbackServer = null + return undefined + } + if (loopbackServer !== server) { + server.close() return undefined } loopbackTimer = setTimeout(stopLoopback, HANDOFF_TTL_MS) @@ -219,6 +239,8 @@ export function createHandoffManager( } const clear = () => { + flowRevision++ + preparedSource = null stopLoopback() pending = null } @@ -241,21 +263,34 @@ export function createHandoffManager( params: Record, connectScope?: ConnectScope ): Promise => { + if (pending?.connectScope?.sourceRequestId) + callbacks.onConnect({ state: pending.state, error: 'superseded' }) + const revision = ++flowRevision const state = generateShortId(STATE_LENGTH) // startLoopback() already tore down any prior server; if this bind fails, // clear the now-orphaned pending so a superseded flow can't linger as a // dangling entry pointing at a server that no longer exists. const port = await startLoopback() + if (revision !== flowRevision) return false if (!port) { clear() return false } + preparedSource = null pending = { state, createdAt: now(), kind, ...(connectScope ? { connectScope: { ...connectScope } } : {}), } + if (connectScope?.sourceRequestId) { + clearTimeout(loopbackTimer) + loopbackTimer = setTimeout(() => { + if (pending?.state !== state) return + callbacks.onConnect({ state, error: 'expired' }) + clear() + }, 10 * 60_000) + } const landing = new URL(landingPath, deps.origin()) for (const [key, value] of Object.entries(params)) { landing.searchParams.set(key, value) @@ -264,7 +299,7 @@ export function createHandoffManager( landing.searchParams.set('port', String(port)) deps.events.record(kind === 'login' ? 'handoff_started' : 'connect_handoff_started') const opened = await deps.openExternal(landing.toString()) - if (!opened) { + if (!opened && pending?.state === state) { clear() } return opened @@ -285,7 +320,18 @@ export function createHandoffManager( // of quietly attaching the credential to the wrong account. Omitted when // unknown (offline, signed out): the page then falls back to its normal // login redirect rather than blocking a connect on a failed probe. + if ( + scope.sourceRequestId && + (preparedSource?.requestId !== scope.sourceRequestId || preparedSource.expiresAt <= now()) + ) + return false + const revision = ++flowRevision const userId = await deps.currentUserId() + if ( + revision !== flowRevision || + (scope.sourceRequestId && (!userId || preparedSource?.requestId !== scope.sourceRequestId)) + ) + return false return beginFlow( 'connect', '/desktop/connect', @@ -295,6 +341,7 @@ export function createHandoffManager( ...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}), ...(scope.credentialId ? { credentialId: scope.credentialId } : {}), ...(scope.draftId ? { draftId: scope.draftId } : {}), + ...(scope.sourceRequestId ? { sourceRequestId: scope.sourceRequestId } : {}), }, scope ) @@ -306,6 +353,24 @@ export function createHandoffManager( const consumed = consumePending(state, 'connect') return consumed ? { ...(consumed.connectScope ?? {}) } : null }, + prepareSourceConnect() { + if (pending?.connectScope?.sourceRequestId) + callbacks.onConnect({ state: pending.state, error: 'superseded' }) + clear() + const requestId = generateShortId(32) + preparedSource = { requestId, expiresAt: now() + 10 * 60_000 } + return requestId + }, + cancelSourceConnect(requestId: string) { + if (preparedSource?.requestId === requestId) { + clear() + return true + } + if (pending?.connectScope?.sourceRequestId !== requestId) return false + callbacks.onConnect({ state: pending.state, error: 'cancelled' }) + clear() + return true + }, clear, } } @@ -469,6 +534,8 @@ export function createAuthFlow(deps: AuthFlowDeps): AuthFlow { export interface ConnectHandoffResult { ok: boolean error?: string + sourceRequestId?: string + credentialId?: string /** Exact Mothership chat attempt, or null for ordinary integration flows. */ chatAttemptId: string | null } @@ -512,15 +579,25 @@ export function createConnectFlow(deps: ConnectFlowDeps): ConnectFlow { if (callback.error === undefined) { deps.events.record('connect_handoff_ok') deps.focusMainWindow() - deps.notifyRenderer({ ok: true, chatAttemptId: scope.chatAttemptId ?? null }) + deps.notifyRenderer({ + ok: true, + chatAttemptId: scope.chatAttemptId ?? null, + ...(scope.sourceRequestId + ? { + sourceRequestId: scope.sourceRequestId, + ...(callback.credentialId ? { credentialId: callback.credentialId } : {}), + } + : {}), + }) return } deps.events.record('connect_handoff_error', { error: callback.error }) - deps.focusMainWindow() + if (!['cancelled', 'superseded', 'expired'].includes(callback.error)) deps.focusMainWindow() deps.notifyRenderer({ ok: false, error: callback.error, chatAttemptId: scope.chatAttemptId ?? null, + ...(scope.sourceRequestId ? { sourceRequestId: scope.sourceRequestId } : {}), }) }, } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index b3a4618d62b..9b53f1815f3 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -830,6 +830,8 @@ function main(): void { }, }, beginOAuthConnect: (providerId, scope) => connectFlow.beginConnectHandoff(providerId, scope), + prepareSourceConnect: () => handoff.prepareSourceConnect(), + cancelSourceConnect: (requestId) => handoff.cancelSourceConnect(requestId), updates: { getState: () => updater?.getState() ?? { status: 'idle' }, check: () => updater?.check(), diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index c4885d5d70b..20769df0376 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -294,6 +294,8 @@ describe('registerIpcHandlers', () => { isLocalPageUrl, retryLoad: vi.fn(), beginOAuthConnect: vi.fn(async () => true), + prepareSourceConnect: vi.fn(() => 's'.repeat(32)), + cancelSourceConnect: vi.fn(() => true), localFilesystem: new LocalFilesystemService({ chooseDirectory: vi.fn(async () => null), }), diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 4b2fb02f087..b7a16b1d278 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -155,6 +155,7 @@ function isDesktopToolCallId(raw: unknown): raw is string { } export interface OAuthConnectScope { + sourceRequestId?: string workspaceId?: string credentialId?: string draftId?: string @@ -361,6 +362,8 @@ export interface IpcDeps { ) => boolean } beginOAuthConnect: (providerId: string, scope: OAuthConnectScope) => Promise + prepareSourceConnect: () => string + cancelSourceConnect: (requestId: string) => boolean updates: { getState: () => DesktopUpdateState check: () => void @@ -717,6 +720,34 @@ export function registerIpcHandlers(deps: IpcDeps): void { return deps.beginOAuthConnect(providerId, parsedScope) }, }, + 'desktop:source-connect-prepare': { + kind: 'invoke', + gate: 'app-origin', + requiresAccountData: true, + needsUserActivation: true, + denied: null, + handler: () => deps.prepareSourceConnect(), + }, + 'desktop:source-connect': { + kind: 'invoke', + gate: 'app-origin', + requiresAccountData: true, + denied: false, + handler: (requestId) => + typeof requestId === 'string' && /^[A-Za-z0-9_-]{32}$/.test(requestId) + ? deps.beginOAuthConnect('source', { sourceRequestId: requestId }) + : false, + }, + 'desktop:source-connect-cancel': { + kind: 'invoke', + gate: 'app-origin', + requiresAccountData: true, + denied: false, + handler: (requestId) => + typeof requestId === 'string' && /^[A-Za-z0-9_-]{32}$/.test(requestId) + ? deps.cancelSourceConnect(requestId) + : false, + }, 'desktop:local-files': { kind: 'invoke', gate: 'app-origin', diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 50fe5d3ff25..00fb6180a9b 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -127,6 +127,12 @@ const api: SimDesktopApi = { : {}), beginOAuthConnect: (providerId: string, scope?: DesktopOAuthConnectScope): Promise => ipcRenderer.invoke('desktop:oauth-connect', providerId, scope), + prepareSourceConnect: (): Promise => + ipcRenderer.invoke('desktop:source-connect-prepare'), + beginSourceConnect: (requestId: string): Promise => + ipcRenderer.invoke('desktop:source-connect', requestId), + cancelSourceConnect: (requestId: string): Promise => + ipcRenderer.invoke('desktop:source-connect-cancel', requestId), onOAuthConnectComplete: (callback: (result: DesktopOAuthConnectResult) => void): (() => void) => { const listener = (_event: unknown, result: DesktopOAuthConnectResult) => callback(result) ipcRenderer.on('desktop:oauth-connect-complete', listener) diff --git a/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.ts b/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.ts index 441e0978439..06bc2743e34 100644 --- a/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.ts +++ b/apps/sim/app/api/credential-groups/slack-managed-users/callback/route.ts @@ -6,26 +6,12 @@ import { slackCredentialGroupConfigurationCallbackContract } from '@/lib/api/con import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { completeSlackCredentialGroupConfiguration } from '@/lib/credential-groups/application/slack-managed-users' import { SlackManagedUsersError } from '@/lib/credential-groups/slack-managed-users' const logger = createLogger('SlackCredentialGroupConfigurationCallbackAPI') -const CHANNEL_NAME = 'slack-managed-users' - -function escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -function jsonLiteral(value: unknown): string { - return JSON.stringify(value).replace(//g, '\\u003e') -} - function closePopup(params: { ok: boolean message: string @@ -34,24 +20,16 @@ function closePopup(params: { slackBotCredentialId?: string reason: string }): NextResponse { - const title = params.ok ? 'Slack configured' : 'Slack setup failed' - const payload = { - type: CHANNEL_NAME, - ok: params.ok, - state: params.state, - credentialGroupId: params.credentialGroupId, - slackBotCredentialId: params.slackBotCredentialId, - reason: params.reason, + const url = new URL('/credential-groups/slack-complete', getBaseUrl()) + url.searchParams.set('mode', 'managed') + url.searchParams.set('ok', String(params.ok)) + for (const key of ['state', 'credentialGroupId', 'slackBotCredentialId', 'reason'] as const) { + const value = params[key] + if (value) url.searchParams.set(key, value) } - const body = `${title}

${escapeHtml(params.message)}

` - return new NextResponse(body, { - headers: { - 'Cache-Control': 'no-store, max-age=0', - 'Content-Type': 'text/html; charset=utf-8', - }, + return NextResponse.redirect(url, { + status: 303, + headers: { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }, }) } diff --git a/apps/sim/app/api/desktop/source-connect/consume/route.ts b/apps/sim/app/api/desktop/source-connect/consume/route.ts new file mode 100644 index 00000000000..daa8d44b060 --- /dev/null +++ b/apps/sim/app/api/desktop/source-connect/consume/route.ts @@ -0,0 +1,23 @@ +import { + consumeDesktopSourceRequestContract, + desktopSourceRequestSchema, +} from '@/lib/api/contracts/desktop-source-connect' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { consumeDesktopSourceRequest } from '@/lib/desktop/application/source-requests' + +export const POST = defineInternalJsonRoute({ + contract: consumeDesktopSourceRequestContract, + auth: internalSessionAuth, + operation: consumeDesktopSourceRequest.operation, + rateLimit: internalRateLimits.user({ bucketName: 'desktop-source-connect' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ body }) => body, + useCase: consumeDesktopSourceRequest, + present: ({ payload }) => desktopSourceRequestSchema.parse(JSON.parse(payload)), + staticResponseHeaders: { 'Cache-Control': 'no-store' }, +}) diff --git a/apps/sim/app/api/desktop/source-connect/route.ts b/apps/sim/app/api/desktop/source-connect/route.ts new file mode 100644 index 00000000000..518b0f4e88f --- /dev/null +++ b/apps/sim/app/api/desktop/source-connect/route.ts @@ -0,0 +1,19 @@ +import { createDesktopSourceRequestContract } from '@/lib/api/contracts/desktop-source-connect' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { createDesktopSourceRequest } from '@/lib/desktop/application/source-requests' + +export const POST = defineInternalJsonRoute({ + contract: createDesktopSourceRequestContract, + auth: internalSessionAuth, + operation: createDesktopSourceRequest.operation, + rateLimit: internalRateLimits.user({ bucketName: 'desktop-source-connect' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ body }) => ({ requestId: body.requestId, payload: JSON.stringify(body.request) }), + useCase: createDesktopSourceRequest, + staticResponseHeaders: { 'Cache-Control': 'no-store' }, +}) diff --git a/apps/sim/app/api/knowledge/slack/oauth/callback/route.test.ts b/apps/sim/app/api/knowledge/slack/oauth/callback/route.test.ts index eabe6069a0a..055aa4daaf0 100644 --- a/apps/sim/app/api/knowledge/slack/oauth/callback/route.test.ts +++ b/apps/sim/app/api/knowledge/slack/oauth/callback/route.test.ts @@ -63,7 +63,7 @@ describe('Slack OAuth callback', () => { const response = await GET(request('state=state&code=code')) expect(response.status).toBe(303) expect(response.headers.get('location')).toBe( - 'https://www.sim.ai/o/org1/settings/search-slack?slackSetup=complete' + 'https://www.sim.ai/credential-groups/slack-complete?state=state&ok=true&organizationId=org1' ) expect(m.complete).toHaveBeenCalledWith( expect.objectContaining({ @@ -75,12 +75,20 @@ describe('Slack OAuth callback', () => { }) it('never falls back to public install on an invalid nonempty state', async () => { m.complete.mockRejectedValueOnce(new OrchestrationError('validation', 'Expired state')) - expect((await GET(request('state=expired&code=code'))).status).toBe(400) + const response = await GET(request('state=expired&code=code')) + expect(response.status).toBe(303) + expect(response.headers.get('location')).toBe( + 'https://www.sim.ai/credential-groups/slack-complete?state=expired&ok=false' + ) expect(m.authenticate).not.toHaveBeenCalled() }) it('still requires a Sim session for an org-bound state', async () => { authMockFns.mockGetSession.mockResolvedValue(null) - expect((await GET(request('state=state&code=code'))).status).toBe(401) + const response = await GET(request('state=state&code=code')) + expect(response.status).toBe(303) + expect(response.headers.get('location')).toBe( + 'https://www.sim.ai/credential-groups/slack-complete?state=state&ok=false' + ) expect(m.authenticate).not.toHaveBeenCalled() expect(m.complete).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/knowledge/slack/oauth/callback/route.ts b/apps/sim/app/api/knowledge/slack/oauth/callback/route.ts index b6155d6f5c3..24b5a5bc9e0 100644 --- a/apps/sim/app/api/knowledge/slack/oauth/callback/route.ts +++ b/apps/sim/app/api/knowledge/slack/oauth/callback/route.ts @@ -1,3 +1,5 @@ +import { createLogger } from '@sim/logger' +import { describeError } from '@sim/utils/errors' import { NextResponse } from 'next/server' import { slackSearchOAuthCallbackContract } from '@/lib/api/contracts/knowledge/slack' import { parseRequest } from '@/lib/api/server' @@ -12,12 +14,14 @@ import { enforceIpRateLimit } from '@/lib/core/rate-limiter' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { completeSlackSearchSetup } from '@/lib/knowledge/application/slack-search/setup' -import { organizationRoutes } from '@/lib/navigation/paths' import { slackSearchInstallPath } from '@/lib/slack-search/install-link' import { authenticateSlackPublicInstallation } from '@/lib/slack-search/public-install-auth' +const logger = createLogger('SlackSearchOAuthCallback') + /** OAuth is a redirect protocol; protected configuration remains in the application use case. */ export const GET = withRouteHandler(async (request) => { + let callbackState: string | undefined try { const limited = await enforceIpRateLimit('slack-search-oauth-callback', request) if (limited) return limited @@ -31,6 +35,7 @@ export const GET = withRouteHandler(async (request) => { ) if (!parsed.success) return parsed.response const { state, code, error } = parsed.data.query + callbackState = state if (!state) { if (error || !code) throw new OrchestrationError( @@ -53,13 +58,30 @@ export const GET = withRouteHandler(async (request) => { input: { state, code, error }, request, }) - const url = new URL( - organizationRoutes(result.organizationId).settingsSection('search-slack'), - getBaseUrl() - ) - url.searchParams.set('slackSetup', 'complete') - return NextResponse.redirect(url, 303) + const url = new URL('/credential-groups/slack-complete', getBaseUrl()) + url.searchParams.set('state', state) + url.searchParams.set('ok', 'true') + url.searchParams.set('organizationId', result.organizationId) + return NextResponse.redirect(url, { + status: 303, + headers: { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }, + }) } catch (error) { + if (callbackState) { + const projected = internalOrchestrationErrorPolicy.project(error) + if ( + !(error instanceof InternalUnauthenticatedError) && + (!projected || projected.status >= 500) + ) + logger.error('Slack authorization callback failed', { error: describeError(error) }) + const url = new URL('/credential-groups/slack-complete', getBaseUrl()) + url.searchParams.set('state', callbackState) + url.searchParams.set('ok', 'false') + return NextResponse.redirect(url, { + status: 303, + headers: { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }, + }) + } if (error instanceof InternalUnauthenticatedError) return NextResponse.json( { error: 'Sign in to Sim and restart Slack setup.' }, diff --git a/apps/sim/app/credential-groups/complete/completion-handoff.test.tsx b/apps/sim/app/credential-groups/complete/completion-handoff.test.tsx index d9e0ca16172..70cebeb3767 100644 --- a/apps/sim/app/credential-groups/complete/completion-handoff.test.tsx +++ b/apps/sim/app/credential-groups/complete/completion-handoff.test.tsx @@ -7,7 +7,7 @@ import { CredentialGroupCompletionHandoff } from '@/app/credential-groups/comple describe('credential group OAuth completion', () => { it.each([undefined, 'failed', 'denied', 'configuration_changed'] as const)( 'publishes %s to only its initiating tab and keeps failures visible', - (failure) => { + async (failure) => { const postMessage = vi.fn() const closeChannel = vi.fn() const names: string[] = [] @@ -27,7 +27,7 @@ describe('credential group OAuth completion', () => { const root = createRoot(container) const completionId = '550e8400-e29b-41d4-a716-446655440000' try { - act(() => + await act(async () => root.render( ) diff --git a/apps/sim/app/credential-groups/complete/completion-handoff.tsx b/apps/sim/app/credential-groups/complete/completion-handoff.tsx index 8f24c0108d4..cb61d3e275e 100644 --- a/apps/sim/app/credential-groups/complete/completion-handoff.tsx +++ b/apps/sim/app/credential-groups/complete/completion-handoff.tsx @@ -1,10 +1,11 @@ 'use client' -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { type CredentialGroupOAuthFailure, credentialGroupOAuthCompletionChannel, } from '@/lib/credential-groups/oauth-completion' +import { finishDesktopSourceBrowser } from '@/lib/desktop/source-browser' interface CredentialGroupCompletionHandoffProps { completionId: string @@ -16,12 +17,20 @@ export function CredentialGroupCompletionHandoff({ completionId, failure, }: CredentialGroupCompletionHandoffProps) { + const started = useRef(false) useEffect(() => { - 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 (started.current) return + started.current = true + 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() + } + ) }, [completionId, failure]) return null } diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index 8c031917faa..23695eb6b85 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -20,6 +20,7 @@ import { AuthHeader, SupportFooter } from '@/app/(auth)/components' import { LogoShell } from '@/app/(landing)/components/logo-shell' import { OAuthConnectLink } from '@/app/credential-groups/enroll/[token]/oauth-reconnect-link' import { CredentialGroupOAuthToast } from '@/app/credential-groups/enroll/[token]/oauth-toast' +import { SourceCompletion } from '@/app/desktop/connect/source-completion' import { RESOURCE_LIST_STACK, SettingsResourceRow, @@ -53,6 +54,7 @@ function PageShell({ children }: PageShellProps) { } interface UnavailableInvitationProps { + token?: string rateLimited?: boolean message?: string recoveryHref?: string @@ -60,6 +62,7 @@ interface UnavailableInvitationProps { } function UnavailableInvitation({ + token, rateLimited = false, message, recoveryHref = APP_ENTRY_PATH, @@ -67,6 +70,7 @@ function UnavailableInvitation({ }: UnavailableInvitationProps) { return ( + {token && }
+ + if (limited) return - const { token } = await params - if (!token || token.length > 128) return + if (!token || token.length > 128) return const resolvedSearchParams = await searchParams const callback = new URLSearchParams() for (const key of ['returnTo', 'optionId']) { @@ -151,13 +158,14 @@ export default async function CredentialGroupEnrollmentPage({ if (!session.user.emailVerified) return ( ) const principal = await authenticateCredentialGroupEnrollment(token) - if (!principal) return + if (!principal) return const returnToSearch = resolvedSearchParams.returnTo === 'search' const returnToAccounts = resolvedSearchParams.returnTo === 'accounts' const focused = returnToSearch || returnToAccounts @@ -176,9 +184,9 @@ export default async function CredentialGroupEnrollmentPage({ return { enrollment: null } throw error }) - if (!enrollmentResult) return + if (!enrollmentResult) return if ('enrollmentError' in enrollmentResult) - return + return const { enrollment } = enrollmentResult const canReturnToSearch = returnToSearch && @@ -190,7 +198,13 @@ export default async function CredentialGroupEnrollmentPage({ : 'Open knowledge bases' : 'Open Sim' if (!enrollment) - return + return ( + + ) const oauthStatus = getSearchParam(resolvedSearchParams, 'oauth') const connectedOptionId = getSearchParam(resolvedSearchParams, 'connected') @@ -207,7 +221,13 @@ export default async function CredentialGroupEnrollmentPage({ ? activeOptions.find((option) => option.id === focusedOptionId) : undefined if (focused && !focusedOption) - return + return ( + + ) const visibleOptions = focusedOption ? [focusedOption] : activeOptions const focusedConnected = focusedOption?.connections[0]?.status === 'connected' && @@ -235,6 +255,15 @@ export default async function CredentialGroupEnrollmentPage({ : null return ( + {(oauthMessage || + connectedOption?.connections.some((connection) => connection.status === 'connected') || + connectedMcpServer?.connection?.status === 'connected') && ( + + )} {notification && ( diff --git a/apps/sim/app/credential-groups/slack-complete/page.tsx b/apps/sim/app/credential-groups/slack-complete/page.tsx new file mode 100644 index 00000000000..3389f1abfaf --- /dev/null +++ b/apps/sim/app/credential-groups/slack-complete/page.tsx @@ -0,0 +1,51 @@ +import { ChipLink } from '@sim/emcn' +import type { Metadata } from 'next' +import { APP_ENTRY_PATH, organizationRoutes } from '@/lib/navigation/paths' +import { SlackCompletion } from '@/app/credential-groups/slack-complete/slack-completion' +import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell' + +export const metadata: Metadata = { + title: 'Slack connection', + robots: { index: false, follow: false }, +} +interface SlackCompletePageProps { + searchParams: Promise> +} + +export default async function SlackCompletePage({ searchParams }: SlackCompletePageProps) { + const params = await searchParams + const scalar = (key: string) => + typeof params[key] === 'string' && params[key].length <= 512 ? params[key] : undefined + const ok = params.ok === 'true' + const mode = params.mode === 'managed' ? 'managed' : 'search' + const organizationId = scalar('organizationId') + return ( + + + + Return to Sim + + + ) +} diff --git a/apps/sim/app/credential-groups/slack-complete/slack-completion.tsx b/apps/sim/app/credential-groups/slack-complete/slack-completion.tsx new file mode 100644 index 00000000000..87f8ec9bc0f --- /dev/null +++ b/apps/sim/app/credential-groups/slack-complete/slack-completion.tsx @@ -0,0 +1,61 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { finishDesktopSourceBrowser } from '@/lib/desktop/source-browser' +import { organizationRoutes } from '@/lib/navigation/paths' + +interface SlackCompletionProps { + organizationId?: string + mode: 'managed' | 'search' + ok: boolean + state?: string + reason?: string + credentialGroupId?: string + slackBotCredentialId?: string +} + +export function SlackCompletion({ + organizationId, + mode, + ok, + state, + reason, + credentialGroupId, + slackBotCredentialId, +}: SlackCompletionProps) { + const started = useRef(false) + useEffect(() => { + if (started.current || !state) return + started.current = true + void finishDesktopSourceBrowser({ + kind: mode === 'managed' ? 'slack-managed-users' : 'slack-search', + id: state, + ...(ok ? {} : { error: reason ?? 'failed' }), + }).then((returned) => { + if (returned) return + if (mode === 'search') { + if (ok && organizationId) { + const url = new URL( + organizationRoutes(organizationId).settingsSection('search-slack'), + window.location.origin + ) + url.searchParams.set('slackSetup', 'complete') + window.location.replace(url.href) + } + return + } + const channel = new BroadcastChannel('slack-managed-users') + channel.postMessage({ + type: 'slack-managed-users', + ok, + state, + reason, + credentialGroupId, + slackBotCredentialId, + }) + channel.close() + if (ok) window.close() + }) + }, [organizationId, mode, ok, state, reason, credentialGroupId, slackBotCredentialId]) + return null +} diff --git a/apps/sim/app/desktop/connect/complete/page.tsx b/apps/sim/app/desktop/connect/complete/page.tsx index b1369324867..55b6cbb6277 100644 --- a/apps/sim/app/desktop/connect/complete/page.tsx +++ b/apps/sim/app/desktop/connect/complete/page.tsx @@ -2,7 +2,11 @@ import type { Metadata } from 'next' import { redirect } from 'next/navigation' import { isValidHandoffState, parseLoopbackPort } from '@/app/desktop/auth/validation' import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell' -import { buildConnectLoopbackUrl, sanitizeOAuthErrorSlug } from '@/app/desktop/connect/validation' +import { + buildConnectLoopbackUrl, + isValidOpaqueId, + sanitizeOAuthErrorSlug, +} from '@/app/desktop/connect/validation' export const metadata: Metadata = { title: 'Returning to Sim', @@ -44,5 +48,12 @@ export default async function ConnectCompletePage({ searchParams }: ConnectCompl // failure must never read as success — take the first code. const rawError = Array.isArray(params.error) ? params.error[0] : params.error const error = sanitizeOAuthErrorSlug(rawError) - redirect(buildConnectLoopbackUrl(state, port, error ?? undefined)) + redirect( + buildConnectLoopbackUrl( + state, + port, + error ?? undefined, + isValidOpaqueId(params.credentialId) ? params.credentialId : undefined + ) + ) } diff --git a/apps/sim/app/desktop/connect/page.tsx b/apps/sim/app/desktop/connect/page.tsx index 2a7edb3aa9f..510e1d5b35e 100644 --- a/apps/sim/app/desktop/connect/page.tsx +++ b/apps/sim/app/desktop/connect/page.tsx @@ -6,6 +6,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { isValidHandoffState, parseLoopbackPort } from '@/app/desktop/auth/validation' import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell' import { ConnectLauncher } from '@/app/desktop/connect/connect-launcher' +import { SourceConnectLauncher } from '@/app/desktop/connect/source-connect-launcher' import { SwitchAccount } from '@/app/desktop/connect/switch-account' import { buildConnectCompletePath, @@ -63,12 +64,26 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec const credentialId = isValidOpaqueId(params.credentialId) ? params.credentialId : undefined const draftId = isValidOpaqueId(params.draftId) ? params.draftId : undefined const expectedUserId = isValidOpaqueId(params.user) ? params.user : undefined + const sourceRequestId = + typeof params.sourceRequestId === 'string' && /^[A-Za-z0-9_-]{32}$/.test(params.sourceRequestId) + ? params.sourceRequestId + : undefined + const invalidSource = + params.sourceRequestId !== undefined && + (!sourceRequestId || + providerId !== 'source' || + !expectedUserId || + workspaceId || + credentialId || + draftId) const hasInvalidDraftId = params.draftId !== undefined && draftId === undefined if ( !isValidOAuthProviderId(providerId) || !isValidHandoffState(state) || port === null || hasInvalidDraftId || + invalidSource || + (providerId === 'source' && !sourceRequestId) || (workspaceId !== undefined && draftId !== undefined) ) { return @@ -89,6 +104,7 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec credentialId, draftId, user: expectedUserId, + sourceRequestId, }) )}` ) @@ -111,6 +127,7 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec credentialId, draftId, user: expectedUserId, + sourceRequestId, })} /> @@ -122,6 +139,9 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec // draft — including reconnect rebinding when a credentialId rides along. // Modal-initiated connects have no workspaceId here (the desktop app already // created the draft) and use the plain link flow below. + if (sourceRequestId) + return + if (workspaceId) { const authorize = new URL('/api/auth/oauth2/authorize', getBaseUrl()) authorize.searchParams.set('providerId', providerId) diff --git a/apps/sim/app/desktop/connect/source-completion.tsx b/apps/sim/app/desktop/connect/source-completion.tsx new file mode 100644 index 00000000000..b16ab6e6eb5 --- /dev/null +++ b/apps/sim/app/desktop/connect/source-completion.tsx @@ -0,0 +1,17 @@ +'use client' + +import { useEffect } from 'react' +import { + type DesktopSourceCompletion, + finishDesktopSourceBrowser, +} from '@/lib/desktop/source-browser' + +interface SourceCompletionProps extends DesktopSourceCompletion {} + +/** Mounted by terminal pages after their existing server-side authorization checks. */ +export function SourceCompletion({ kind, id, error }: SourceCompletionProps) { + useEffect(() => { + void finishDesktopSourceBrowser({ kind, id, error }) + }, [kind, id, error]) + return null +} diff --git a/apps/sim/app/desktop/connect/source-connect-launcher.tsx b/apps/sim/app/desktop/connect/source-connect-launcher.tsx new file mode 100644 index 00000000000..e95a1dd549a --- /dev/null +++ b/apps/sim/app/desktop/connect/source-connect-launcher.tsx @@ -0,0 +1,47 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { Chip } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { startDesktopSourceBrowser } from '@/lib/desktop/source-browser' +import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell' +import { buildConnectCompletePath } from '@/app/desktop/connect/validation' + +interface SourceConnectLauncherProps { + requestId: string + state: string + port: number +} + +export function SourceConnectLauncher({ requestId, state, port }: SourceConnectLauncherProps) { + const started = useRef(false) + const [error, setError] = useState(null) + useEffect(() => { + if (started.current) return + started.current = true + void startDesktopSourceBrowser(requestId, state, port).catch((failure) => { + setError(getErrorMessage(failure, 'Could not start this connection. Try again from Sim.')) + }) + }, [requestId, state, port]) + return ( + + {error && ( + + window.location.replace( + `${buildConnectCompletePath(state, port)}&error=connection_failed` + ) + } + > + Return to Sim + + )} + + ) +} diff --git a/apps/sim/app/desktop/connect/validation.ts b/apps/sim/app/desktop/connect/validation.ts index 4a70467707e..891283719ed 100644 --- a/apps/sim/app/desktop/connect/validation.ts +++ b/apps/sim/app/desktop/connect/validation.ts @@ -36,6 +36,7 @@ export function isValidOpaqueId(value: unknown): value is string { /** Optional connect scope forwarded from the desktop app's credential chips. */ export interface ConnectScope { + sourceRequestId?: string workspaceId?: string credentialId?: string draftId?: string @@ -54,6 +55,7 @@ export function buildDesktopConnectPath( scope: ConnectScope = {} ): string { const params = new URLSearchParams({ provider: providerId, state, port: String(port) }) + if (scope.sourceRequestId) params.set('sourceRequestId', scope.sourceRequestId) if (scope.workspaceId) params.set('workspaceId', scope.workspaceId) if (scope.credentialId) params.set('credentialId', scope.credentialId) if (scope.draftId) params.set('draftId', scope.draftId) @@ -76,8 +78,14 @@ export function buildConnectCompletePath(state: string, port: number, draftId?: * §7.3 — the `127.0.0.1` IP literal, mirroring the login handoff). A present * `error` marks the flow failed; the app surfaces it as a toast. */ -export function buildConnectLoopbackUrl(state: string, port: number, error?: string): string { +export function buildConnectLoopbackUrl( + state: string, + port: number, + error?: string, + credentialId?: string +): string { const params = new URLSearchParams({ state }) + if (credentialId) params.set('credentialId', credentialId) if (error) { params.set('error', error) } diff --git a/apps/sim/app/knowledge/github/setup/setup.tsx b/apps/sim/app/knowledge/github/setup/setup.tsx index 57d40e49160..f47cda034a1 100644 --- a/apps/sim/app/knowledge/github/setup/setup.tsx +++ b/apps/sim/app/knowledge/github/setup/setup.tsx @@ -44,7 +44,13 @@ export function GitHubSetup({ scope }: GitHubSetupProps) { ? 'This connection attempt expired. Close this window and connect GitHub again from Sim.' : null) - if (failure) return + if (failure) + return ( + <> + + + + ) if (result?.status === 'completed') { return ( <> 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 907d6b5e1eb..2f1a843ca05 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 @@ -40,8 +40,9 @@ export function GitHubMemberIntegration({ const loading = inventory.isPending && !inventory.data const failed = inventory.isError const meta = CONNECTOR_META_REGISTRY.github - const navigate = ({ authorizationUrl, invitationLink }: OrganizationAccountConnectionResponse) => - window.location.assign(authorizationUrl ?? invitationLink) + 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'}` diff --git a/apps/sim/app/o/[organizationId]/integrations/indexed/use-member-enrollment.ts b/apps/sim/app/o/[organizationId]/integrations/indexed/use-member-enrollment.ts index e6d0376bf55..76faa926cc8 100644 --- a/apps/sim/app/o/[organizationId]/integrations/indexed/use-member-enrollment.ts +++ b/apps/sim/app/o/[organizationId]/integrations/indexed/use-member-enrollment.ts @@ -3,7 +3,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { type QueryKey, useQueryClient } from '@tanstack/react-query' +import { type QueryKey, useMutation, useQueryClient } from '@tanstack/react-query' +import type { DesktopSourceRequest } from '@/lib/api/contracts/desktop-source-connect' import { type ResourceScope, resourceScopeFields, @@ -14,6 +15,8 @@ import { credentialGroupOAuthCompletionChannel, isCredentialGroupOAuthFailure, } from '@/lib/credential-groups/oauth-completion' +import { isDesktopApp } from '@/lib/desktop' +import { connectDesktopSource } from '@/lib/desktop/source-connect' import type { SearchConnector } from '@/lib/sim-search/connectors' import { useConnectSimSearchConnector, @@ -96,6 +99,22 @@ export function useMemberEnrollment({ >() ) const queryClient = useQueryClient() + const nativeAbort = useRef(null) + useEffect(() => () => nativeAbort.current?.abort(), []) + const nativeConnection = useMutation({ + mutationFn: async (request: DesktopSourceRequest) => { + nativeAbort.current?.abort() + const controller = new AbortController() + nativeAbort.current = controller + return connectDesktopSource(request, controller.signal) + }, + onSettled: () => + Promise.all( + membershipQueryKeys.map((queryKey) => queryClient.invalidateQueries({ queryKey })) + ), + onError: (error) => onConnectionError?.(error.message), + onSuccess: () => setSetupConnector(null), + }) const enrollment = useStartConnectorMemberEnrollment() const sourceConnection = useConnectSimSearchConnector() const [awaitingSince, setAwaitingSince] = useState>( @@ -265,7 +284,15 @@ export function useMemberEnrollment({ }) } - const connect = (knowledgeBaseId: string, connectorId: string) => + const connect = (knowledgeBaseId: string, connectorId: string) => { + if (isDesktopApp()) { + nativeConnection.mutate({ + kind: 'member-enrollment', + params: { id: knowledgeBaseId, connectorId }, + ...(directOAuth ? { completionId: generateId() } : {}), + }) + return + } openEnrollment(`connector:${connectorId}`, ({ onSuccess, onError, oauthCompletionId }) => { enrollment.mutate( { knowledgeBaseId, connectorId, ...(oauthCompletionId ? { oauthCompletionId } : {}) }, @@ -279,6 +306,7 @@ export function useMemberEnrollment({ } ) }) + } /** * Connects a Sim Search source: its per-member connector exists afterwards, @@ -292,6 +320,14 @@ export function useMemberEnrollment({ ) => { const scope = typeof owner === 'string' ? { kind: 'workspace' as const, workspaceId: owner } : owner + if (isDesktopApp()) { + nativeConnection.mutate({ + kind: 'search-source', + body: { ...resourceScopeFields(scope), connectorType, sourceConfig }, + ...(directOAuth ? { completionId: generateId() } : {}), + }) + return + } const configKey = JSON.stringify( Object.entries(sourceConfig ?? {}).sort(([left], [right]) => left.localeCompare(right)) ) @@ -335,9 +371,12 @@ export function useMemberEnrollment({ } const isAwaiting = (connectorId: string) => - awaitingSince.has(connectorId) && - (Boolean(awaitingSince.get(connectorId)?.oauthCompletionId) || - !connectedConnectorIds.has(connectorId)) + (nativeConnection.isPending && + nativeConnection.variables?.kind === 'member-enrollment' && + nativeConnection.variables.params.connectorId === connectorId) || + (awaitingSince.has(connectorId) && + (Boolean(awaitingSince.get(connectorId)?.oauthCompletionId) || + !connectedConnectorIds.has(connectorId))) /** * Whether a Sim Search source is awaited by the connect that created its @@ -345,6 +384,9 @@ export function useMemberEnrollment({ * the source cannot be looked up by connector id yet. */ const isAwaitingSource = (connectorType: string) => + (nativeConnection.isPending && + nativeConnection.variables?.kind === 'search-source' && + nativeConnection.variables.body.connectorType === connectorType) || [...awaitingSince].some( ([id, awaiting]) => awaiting.connectorType === connectorType && @@ -359,10 +401,16 @@ export function useMemberEnrollment({ connectSource, connectSearchSource, setupConnector, - closeSetup: () => setSetupConnector(null), + closeSetup: () => { + nativeAbort.current?.abort() + nativeAbort.current = null + setSetupConnector(null) + }, isAwaiting, isAwaitingSource, - isPending: enrollment.isPending || sourceConnection.isPending, - error: popupBlocked ? POPUP_BLOCKED_MESSAGE : (oauthError ?? latest.error?.message ?? null), + isPending: nativeConnection.isPending || enrollment.isPending || sourceConnection.isPending, + error: popupBlocked + ? POPUP_BLOCKED_MESSAGE + : (nativeConnection.error?.message ?? oauthError ?? latest.error?.message ?? null), } } 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 ea564bebf92..653e1af06df 100644 --- a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx @@ -36,8 +36,8 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt const secrets = useOrganizationSecretSource(organizationId) const connect = useConnectOrganizationAccount() const reconnect = useReconnectPersonalOrganizationAccount() - const navigate = (result: OrganizationAccountConnectionResponse) => - window.location.assign(result.authorizationUrl ?? result.invitationLink) + 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) diff --git a/apps/sim/components/integrations/slack-search-setup-wizard.tsx b/apps/sim/components/integrations/slack-search-setup-wizard.tsx index bc2f5ed4756..20b89fe9734 100644 --- a/apps/sim/components/integrations/slack-search-setup-wizard.tsx +++ b/apps/sim/components/integrations/slack-search-setup-wizard.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { ChipLink, ChipModal, @@ -12,6 +12,7 @@ import { } from '@sim/emcn' import { SlackIcon } from '@/components/icons' import { SlackAppManifest } from '@/components/integrations/slack-app-manifest' +import { isDesktopApp } from '@/lib/desktop' import { SLACK_SEARCH_DEFAULT_DESCRIPTION, SLACK_SEARCH_DEFAULT_NAME, @@ -40,6 +41,12 @@ export function SlackSearchSetupWizard({ initialName, onClose, }: SlackSearchSetupWizardProps) { + const nativeAbort = useRef(null) + useEffect(() => () => nativeAbort.current?.abort(), []) + const close = () => { + nativeAbort.current?.abort() + onClose() + } const name = initialName ?? SLACK_SEARCH_DEFAULT_NAME const description = SLACK_SEARCH_DEFAULT_DESCRIPTION const prepare = useSlackSearchManifest(organizationId, name) @@ -62,10 +69,22 @@ export function SlackSearchSetupWizard({ ) function installShared() { + const controller = new AbortController() + nativeAbort.current = controller oauth.mutate( - { organizationId, installationId, name, description, mode: 'shared' }, { - onSuccess: ({ authorizationUrl }) => window.location.assign(authorizationUrl), + organizationId, + installationId, + name, + description, + mode: 'shared', + signal: controller.signal, + }, + { + onSuccess: (result) => { + if (result) window.location.assign(result.authorizationUrl) + else onClose() + }, } ) } @@ -99,11 +118,11 @@ export function SlackSearchSetupWizard({ { - if (!open) onClose() + if (!open) close() }} srTitle='Sim Search in Slack' > - + Sim Search in Slack @@ -116,7 +135,7 @@ export function SlackSearchSetupWizard({ )} { - if (!open) onClose() + if (!open) close() }} srTitle='Install the Sim Search app' size='sm' > - + Install the Sim Search app @@ -160,7 +179,7 @@ export function SlackSearchSetupWizard({ { - if (!open) onClose() + if (!open) close() }} srTitle={title} size='md' > - + {title} @@ -294,7 +313,7 @@ export function SlackSearchSetupWizard({ {error?.message} (null) const expectedCredentialId = useRef(null) const popup = useRef(null) + const nativeAbort = useRef(null) const authorizationTimeout = useRef(null) const defaultCredentialId = initialCredentialId @@ -164,6 +167,8 @@ export function SlackManagedUsersModal({ : [...(access === 'search' ? SLACK_SEARCH_USER_SCOPES : SLACK_MANAGED_USER_SCOPES)] const reset = () => { + nativeAbort.current?.abort() + nativeAbort.current = null popup.current?.close() popup.current = null if (authorizationTimeout.current !== null) window.clearTimeout(authorizationTimeout.current) @@ -250,6 +255,7 @@ export function SlackManagedUsersModal({ useEffect( () => () => { + nativeAbort.current?.abort() if (authorizationTimeout.current !== null) window.clearTimeout(authorizationTimeout.current) popup.current?.close() popup.current = null @@ -287,6 +293,50 @@ export function SlackManagedUsersModal({ ) return + if (isDesktopApp()) { + const controller = new AbortController() + nativeAbort.current = controller + setPending(true) + try { + await connectDesktopSource( + { + kind: 'slack-managed-users', + owner: resourceScopeFields(scope), + credentialGroupId, + body: { + ...(organizationSetup + ? { appId: selectedApp?.appId, teamId: selectedApp?.teamId } + : { + slackBotCredentialId: selectedBot?.id, + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + }), + requiredScopes, + }, + }, + controller.signal + ) + controller.signal.throwIfAborted() + if (scope.kind === 'organization') + await queryClient.invalidateQueries({ + queryKey: organizationAccountsKeys.detail(scope.organizationId), + }) + else await queryClient.invalidateQueries({ queryKey: credentialGroupKeys.all }) + controller.signal.throwIfAborted() + toast.success('Slack configured') + onOpenChange(false) + reset() + } catch (failure) { + if (!controller.signal.aborted) + toast.error(getErrorMessage(failure, 'Could not connect Slack')) + } finally { + if (nativeAbort.current === controller) { + nativeAbort.current = null + setPending(false) + } + } + return + } const opened = window.open('about:blank', 'slack-managed-users', 'width=720,height=760') if (!opened) { toast.error('Allow popups to verify the Slack app') diff --git a/apps/sim/hooks/queries/kb/connectors.test.ts b/apps/sim/hooks/queries/kb/connectors.test.ts index ce1d2406d97..e6bfc7d9c97 100644 --- a/apps/sim/hooks/queries/kb/connectors.test.ts +++ b/apps/sim/hooks/queries/kb/connectors.test.ts @@ -2,11 +2,13 @@ import { apiClientRequestMock, apiClientRequestMockFns, } from '@sim/testing/mocks/api-client-request.mock' +import { emcnMock } from '@sim/testing/mocks/emcn.mock' import { reactQueryMock, reactQueryMockFns } from '@sim/testing/mocks/react-query.mock' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' vi.mock('react', () => ({ useEffect: vi.fn() })) +vi.mock('@sim/emcn', () => emcnMock) vi.mock('@tanstack/react-query', () => reactQueryMock) diff --git a/apps/sim/hooks/queries/organization-accounts.ts b/apps/sim/hooks/queries/organization-accounts.ts index 661f886ba5b..c85b68572a5 100644 --- a/apps/sim/hooks/queries/organization-accounts.ts +++ b/apps/sim/hooks/queries/organization-accounts.ts @@ -39,6 +39,8 @@ import { updateOrganizationAccountsContract, updateOrganizationAccountWorkspaceAccessContract, } from '@/lib/api/contracts/organization-accounts' +import { isDesktopApp } from '@/lib/desktop' +import { connectDesktopSource } from '@/lib/desktop/source-connect' import { personalCredentialKeys } from '@/hooks/queries/personal-credentials' import { mcpKeys } from '@/hooks/queries/utils/mcp-keys' import { resetOrganizationSearchAccess } from '@/hooks/queries/utils/reset-organization-search-access' @@ -49,9 +51,16 @@ import { slackSearchKeys } from '@/hooks/queries/utils/slack-search-keys' export const ORGANIZATION_ACCOUNTS_STALE_TIME = 30_000 export function useReconnectPersonalOrganizationAccount() { + const client = useQueryClient() return useMutation({ - mutationFn: (credentialId: string) => - requestJson(reconnectPersonalOrganizationAccountContract, { params: { credentialId } }), + mutationFn: async (credentialId: string) => { + if (isDesktopApp()) { + await connectDesktopSource({ kind: 'reconnect-account', credentialId }) + return null + } + return requestJson(reconnectPersonalOrganizationAccountContract, { params: { credentialId } }) + }, + onSettled: () => refreshAccounts(client), }) } @@ -194,16 +203,32 @@ export function useUpdateOrganizationAccounts() { }) } +async function refreshAccounts(client: ReturnType) { + await Promise.all([ + client.invalidateQueries({ queryKey: organizationAccountsKeys.all }), + client.invalidateQueries({ queryKey: personalCredentialKeys.lists() }), + client.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }), + invalidateSelectorQueries(client), + ]) +} + export function useConnectOrganizationAccount() { + const client = useQueryClient() return useMutation({ - mutationFn: ({ + mutationFn: async ({ organizationId, ...body - }: { organizationId: string } & StartOrganizationAccountConnectionBody) => - requestJson(startOrganizationAccountConnectionContract, { + }: { organizationId: string } & StartOrganizationAccountConnectionBody) => { + if (isDesktopApp()) { + await connectDesktopSource({ kind: 'organization-account', organizationId, body }) + return null + } + return requestJson(startOrganizationAccountConnectionContract, { params: { id: organizationId }, body, - }), + }) + }, + onSettled: () => refreshAccounts(client), }) } diff --git a/apps/sim/hooks/queries/personal-search-integrations.ts b/apps/sim/hooks/queries/personal-search-integrations.ts index 85eeb11f040..1f718b464b6 100644 --- a/apps/sim/hooks/queries/personal-search-integrations.ts +++ b/apps/sim/hooks/queries/personal-search-integrations.ts @@ -8,6 +8,8 @@ import { listPersonalSearchIntegrationsContract, type PersonalSearchIntegrationsQuery, } from '@/lib/api/contracts/knowledge/personal-integrations' +import { isDesktopApp } from '@/lib/desktop' +import { connectDesktopSource } from '@/lib/desktop/source-connect' import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' @@ -35,8 +37,16 @@ export function usePersonalSearchIntegrations( export function useConnectPersonalSearchIntegration() { const client = useQueryClient() return useMutation({ - mutationFn: async (body: ConnectPersonalSearchIntegrationBody) => - (await requestJson(connectPersonalSearchIntegrationContract, { body })).data, + mutationFn: async ({ + signal, + ...body + }: ConnectPersonalSearchIntegrationBody & { signal?: AbortSignal }) => { + if (isDesktopApp()) { + await connectDesktopSource({ kind: 'personal-search', body }, signal) + return null + } + return (await requestJson(connectPersonalSearchIntegrationContract, { body, signal })).data + }, onSettled: (_data, _error, body) => Promise.all([ client.invalidateQueries({ queryKey: personalSearchIntegrationKeys.lists() }), diff --git a/apps/sim/hooks/queries/slack-search.ts b/apps/sim/hooks/queries/slack-search.ts index 98eb62e41d8..030c529a924 100644 --- a/apps/sim/hooks/queries/slack-search.ts +++ b/apps/sim/hooks/queries/slack-search.ts @@ -13,6 +13,8 @@ import { type StartSlackSearchOAuthBody, startSlackSearchOAuthContract, } from '@/lib/api/contracts/knowledge/slack' +import { isDesktopApp } from '@/lib/desktop' +import { connectDesktopSource } from '@/lib/desktop/source-connect' import { SLACK_SEARCH_DEFAULT_DESCRIPTION, SLACK_SEARCH_DEFAULT_NAME, @@ -36,9 +38,28 @@ export function useSlackSearchManifest(organizationId: string, name = SLACK_SEAR } export function useStartSlackSearchOAuth() { + const client = useQueryClient() return useMutation({ - mutationFn: (body: StartSlackSearchOAuthBody) => - requestJson(startSlackSearchOAuthContract, { body }), + mutationFn: async ({ + signal, + ...body + }: StartSlackSearchOAuthBody & { signal?: AbortSignal }) => { + if (isDesktopApp()) { + await connectDesktopSource({ kind: 'slack-search', body }, signal) + return null + } + return requestJson(startSlackSearchOAuthContract, { body }) + }, + onSettled: (_data, _error, input) => + Promise.all([ + client.invalidateQueries({ queryKey: slackSearchKeys.list(input.organizationId) }), + client.invalidateQueries({ + queryKey: slackSearchKeys.organizationManifests(input.organizationId), + }), + client.invalidateQueries({ + queryKey: organizationAccountsKeys.detail(input.organizationId), + }), + ]), }) } diff --git a/apps/sim/hooks/use-github-installation-setup.ts b/apps/sim/hooks/use-github-installation-setup.ts index 3ace14a876a..a44e3baa625 100644 --- a/apps/sim/hooks/use-github-installation-setup.ts +++ b/apps/sim/hooks/use-github-installation-setup.ts @@ -3,12 +3,14 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { useQueryClient } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import type { StartGitHubSearchSetupBody } from '@/lib/api/contracts/knowledge/github-setup' import { credentialGroupOAuthCompletionChannel, isCredentialGroupOAuthFailure, } from '@/lib/credential-groups/oauth-completion' +import { isDesktopApp } from '@/lib/desktop' +import { connectDesktopSource } from '@/lib/desktop/source-connect' import { resolveGitHubSetupUrl } from '@/lib/knowledge/github-setup-navigation' import { isGitHubSetupTerminalError, @@ -16,7 +18,7 @@ import { useGitHubSearchSetup, useStartGitHubSearchSetup, } from '@/hooks/queries/github-search-setup' -import { oauthCredentialKeys } from '@/hooks/queries/oauth/oauth-credentials' +import { fetchOAuthCredentials, oauthCredentialKeys } from '@/hooks/queries/oauth/oauth-credentials' import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' interface GitHubInstallationSetupProps { @@ -30,6 +32,38 @@ export function useGitHubInstallationSetup({ onConnected, }: GitHubInstallationSetupProps) { const active = useRef<{ setupId: string; tab: Window } | null>(null) + const nativeAbort = useRef(null) + const client = useQueryClient() + const nativeConnection = useMutation({ + mutationFn: async ({ + body, + signal, + }: { + body: StartGitHubSearchSetupBody + signal: AbortSignal + }) => { + const result = await connectDesktopSource({ kind: 'github-setup', body }, signal) + const credentials = await fetchOAuthCredentials( + { providerId: 'github-repositories', organizationId: body.organizationId }, + signal + ) + signal.throwIfAborted() + if ( + !result.credentialId || + !credentials.some((credential) => credential.id === result.credentialId) + ) + throw new Error('GitHub is not available for this source. Try connecting again.') + await Promise.all([ + client.invalidateQueries({ queryKey: oauthCredentialKeys.lists() }), + client.invalidateQueries({ + queryKey: organizationAccountsKeys.detail(body.organizationId), + }), + ]) + signal.throwIfAborted() + return result.credentialId + }, + }) + const { mutateAsync: startNative, isPending: nativePending } = nativeConnection const checking = useRef(null) const callback = useRef(onConnected) const [setupId, setSetupId] = useState() @@ -40,7 +74,6 @@ export function useGitHubInstallationSetup({ setSetupId(undefined) setError(null) } - const client = useQueryClient() const { mutateAsync: start, isPending: isStarting } = useStartGitHubSearchSetup() const { mutateAsync: cancelSetup } = useCancelGitHubSearchSetup() const scope = organizationId && setupId ? { organizationId, setupId } : undefined @@ -53,6 +86,7 @@ export function useGitHubInstallationSetup({ useEffect(() => { return () => { + nativeAbort.current?.abort() const attempt = active.current active.current = null attempt?.tab.close() @@ -175,6 +209,25 @@ export function useGitHubInstallationSetup({ const connect = useCallback( async (intent?: StartGitHubSearchSetupBody['intent']) => { if (!organizationId) return + if (isDesktopApp()) { + if (nativeAbort.current) return + const controller = new AbortController() + nativeAbort.current = controller + setError(null) + try { + const credentialId = await startNative({ + body: { organizationId, setupId: generateId(), ...(intent ? { intent } : {}) }, + signal: controller.signal, + }) + callback.current(credentialId) + } catch (failure) { + if (!controller.signal.aborted) + setError(getErrorMessage(failure, 'Could not connect GitHub')) + } finally { + if (nativeAbort.current === controller) nativeAbort.current = null + } + return + } if (active.current) { active.current.tab.focus() return @@ -203,10 +256,12 @@ export function useGitHubInstallationSetup({ void cancelSetup({ organizationId, setupId: id }).catch(() => undefined) } }, - [organizationId, start, cancelSetup] + [organizationId, start, cancelSetup, startNative] ) const cancel = useCallback(() => { + nativeAbort.current?.abort() + nativeAbort.current = null const attempt = active.current if (!attempt || !organizationId) return active.current = null @@ -221,7 +276,7 @@ export function useGitHubInstallationSetup({ cancel, checkConnection, isChecking: isStarting, - pending: isStarting || Boolean(setupId), + pending: nativePending || isStarting || Boolean(setupId), error, } } diff --git a/apps/sim/hooks/use-oauth-return.ts b/apps/sim/hooks/use-oauth-return.ts index 61c6c626a93..8509d0d33be 100644 --- a/apps/sim/hooks/use-oauth-return.ts +++ b/apps/sim/hooks/use-oauth-return.ts @@ -510,6 +510,7 @@ export function useDesktopOAuthConnectListener() { if (!bridge?.onOAuthConnectComplete) return return bridge.onOAuthConnectComplete((result) => { + if (result.sourceRequestId) return void queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections(), }) diff --git a/apps/sim/hooks/use-search-integration-connection.ts b/apps/sim/hooks/use-search-integration-connection.ts index 3f0cd7873cd..63f974d1b8d 100644 --- a/apps/sim/hooks/use-search-integration-connection.ts +++ b/apps/sim/hooks/use-search-integration-connection.ts @@ -9,6 +9,7 @@ import { credentialGroupOAuthCompletionChannel, isCredentialGroupOAuthFailure, } from '@/lib/credential-groups/oauth-completion' +import { isDesktopApp } from '@/lib/desktop' import { readSearchConnectionAttempt, SEARCH_CONNECTION_ATTEMPT_EVENT, @@ -51,6 +52,8 @@ export function useSearchIntegrationConnection({ const [localError, setLocalError] = useState(null) const popup = useRef(null) const starting = useRef(false) + const nativeAbort = useRef(null) + useEffect(() => () => nativeAbort.current?.abort(), []) const callback = useRef(onConnected) useEffect(() => { callback.current = onConnected @@ -156,19 +159,25 @@ export function useSearchIntegrationConnection({ popup.current.focus() return } - const tab = window.open('about:blank', '_blank', 'width=600,height=700') - if (!tab) { + const desktop = isDesktopApp() + const tab = desktop ? null : window.open('about:blank', '_blank', 'width=600,height=700') + if (!desktop && !tab) { setLocalError('Allow pop-ups for this site to connect your account.') return } - tab.opener = null + if (tab) tab.opener = null popup.current = tab starting.current = true + const controller = new AbortController() + nativeAbort.current = controller setLocalError(null) let next: SearchConnectionAttempt | undefined try { - const fresh = await refetch() - if (!fresh.isSuccess) throw fresh.error + if (!desktop) { + const fresh = await refetch() + if (!fresh.isSuccess) throw fresh.error + } + controller.signal.throwIfAborted() next = { completionId: generateId(), requestedAt: Date.now(), @@ -182,7 +191,9 @@ export function useSearchIntegrationConnection({ target: connectorId ? { ...target, connectorId } : target, sourceConfig, oauthCompletionId: next.completionId, + signal: controller.signal, }) + if (!result || !tab) return true const url = new URL(result.url) if ( url.protocol !== 'https:' && @@ -193,10 +204,12 @@ export function useSearchIntegrationConnection({ tab.location.href = url.href return true } catch (error) { - tab.close() + tab?.close() const message = getErrorMessage(error, 'Could not start the connection') - if (next) writeSearchConnectionAttempt(key, { ...next, status: 'failed', error: message }) - setLocalError(message) + const current = readSearchConnectionAttempt(key) + if (next && current?.completionId === next.completionId && current.status === 'pending') + writeSearchConnectionAttempt(key, { ...current, status: 'failed', error: message }) + if (!controller.signal.aborted) setLocalError(message) return false } finally { starting.current = false @@ -205,6 +218,7 @@ export function useSearchIntegrationConnection({ [isPending, connected, pending, refetch, mutateAsync, organizationId, target, connectorId, key] ) const cancel = useCallback(() => { + nativeAbort.current?.abort() popup.current?.close() if (attempt?.status === 'pending') writeSearchConnectionAttempt(key, { diff --git a/apps/sim/lib/api/contracts/desktop-source-connect.ts b/apps/sim/lib/api/contracts/desktop-source-connect.ts new file mode 100644 index 00000000000..6597a22ddf1 --- /dev/null +++ b/apps/sim/lib/api/contracts/desktop-source-connect.ts @@ -0,0 +1,64 @@ +import { z } from 'zod' +import { startSlackCredentialGroupConfigurationBodySchema } from '@/lib/api/contracts/credential-groups' +import { connectSimSearchConnectorBodySchema } from '@/lib/api/contracts/knowledge/connectors' +import { startGitHubSearchSetupBodySchema } from '@/lib/api/contracts/knowledge/github-setup' +import { connectPersonalSearchIntegrationBodySchema } from '@/lib/api/contracts/knowledge/personal-integrations' +import { knowledgeConnectorParamsSchema } from '@/lib/api/contracts/knowledge/shared' +import { startSlackSearchOAuthBodySchema } from '@/lib/api/contracts/knowledge/slack' +import { startOrganizationAccountConnectionBodySchema } from '@/lib/api/contracts/organization-accounts' +import { organizationIdSchema, resourceOwnerSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const desktopSourceRequestSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('slack-search'), body: startSlackSearchOAuthBodySchema }), + z.object({ kind: z.literal('github-setup'), body: startGitHubSearchSetupBodySchema }), + z.object({ + kind: z.literal('organization-account'), + organizationId: organizationIdSchema, + body: startOrganizationAccountConnectionBodySchema, + }), + z.object({ kind: z.literal('reconnect-account'), credentialId: z.string().min(1).max(128) }), + z.object({ + kind: z.literal('personal-search'), + body: connectPersonalSearchIntegrationBodySchema, + }), + z.object({ + kind: z.literal('member-enrollment'), + params: knowledgeConnectorParamsSchema, + completionId: z.string().uuid().optional(), + }), + z.object({ + kind: z.literal('search-source'), + body: connectSimSearchConnectorBodySchema, + completionId: z.string().uuid().optional(), + }), + z.object({ + kind: z.literal('slack-managed-users'), + owner: resourceOwnerSchema, + credentialGroupId: z.string().min(1).max(128), + body: startSlackCredentialGroupConfigurationBodySchema, + }), +]) +export type DesktopSourceRequest = z.input +export const desktopSourceRequestIdSchema = z.object({ + requestId: z.string().regex(/^[A-Za-z0-9_-]{32}$/), +}) +export type DesktopSourceRequestId = z.output + +export const createDesktopSourceRequestBodySchema = desktopSourceRequestIdSchema.extend({ + request: desktopSourceRequestSchema, +}) +export type CreateDesktopSourceRequestBody = z.input + +export const createDesktopSourceRequestContract = defineRouteContract({ + method: 'POST', + path: '/api/desktop/source-connect', + body: createDesktopSourceRequestBodySchema, + response: { mode: 'json', schema: desktopSourceRequestIdSchema }, +}) +export const consumeDesktopSourceRequestContract = defineRouteContract({ + method: 'POST', + path: '/api/desktop/source-connect/consume', + body: desktopSourceRequestIdSchema, + response: { mode: 'json', schema: desktopSourceRequestSchema }, +}) diff --git a/apps/sim/lib/desktop/application/source-requests.ts b/apps/sim/lib/desktop/application/source-requests.ts new file mode 100644 index 00000000000..42f5e6709eb --- /dev/null +++ b/apps/sim/lib/desktop/application/source-requests.ts @@ -0,0 +1,85 @@ +import { sha256Hex } from '@sim/security/hash' +import { defineOperation } from '@/lib/core/application' +import { getRedisClient } from '@/lib/core/config/redis' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' + +const REQUEST_TTL_SECONDS = 600 +const MAX_REQUEST_BYTES = 32_768 +const CONSUME = ` +local value = redis.call('GET', KEYS[1]) +if not value then return nil end +local request = cjson.decode(value) +if request.userId ~= ARGV[1] then return nil end +redis.call('DEL', KEYS[1]) +return request.encrypted +` + +function redis() { + const client = getRedisClient() + if (!client) throw new Error('Desktop connections require Redis') + return client +} + +function requestKey(requestId: string) { + return `desktop:source-request:${sha256Hex(requestId)}` +} + +/** Transports an intent only; the browser must still call the source's authorized operation. */ +export const createDesktopSourceRequest = defineAuthorizedCredentialUserUseCase({ + // permission-group-exempt: Transporting caller intent grants no source access; the target operation authorizes it. + operation: defineOperation({ + id: 'desktop.source_requests.create', + principalKinds: ['session'], + capability: 'none', + }), + async execute({ + principal, + input, + }: { + principal: { userId: string } + input: { requestId: string; payload: string } + }) { + if (Buffer.byteLength(input.payload, 'utf8') > MAX_REQUEST_BYTES) + throw new OrchestrationError('validation', 'Connection request is too large') + const { requestId } = input + if (!/^[A-Za-z0-9_-]{32}$/.test(requestId)) + throw new OrchestrationError('validation', 'Invalid connection request') + const { encrypted } = await encryptSecret(input.payload) + const saved = await redis().set( + requestKey(requestId), + JSON.stringify({ userId: principal.userId, encrypted }), + 'EX', + REQUEST_TTL_SECONDS, + 'NX' + ) + if (saved !== 'OK') throw new Error('Could not prepare the connection request') + return { requestId } + }, +}) + +/** The same account may redeem once in its browser session; other accounts cannot consume it. */ +export const consumeDesktopSourceRequest = defineAuthorizedCredentialUserUseCase({ + // permission-group-exempt: Only the owner's intent is returned; source authorization remains at the target operation. + operation: defineOperation({ + id: 'desktop.source_requests.consume', + principalKinds: ['session'], + capability: 'none', + }), + async execute({ + principal, + input, + }: { + principal: { userId: string } + input: { requestId: string } + }) { + const encrypted = await redis().eval(CONSUME, 1, requestKey(input.requestId), principal.userId) + if (typeof encrypted !== 'string') + throw new OrchestrationError( + 'not_found', + 'Connection request expired. Start again from the desktop app.' + ) + return { payload: (await decryptSecret(encrypted)).decrypted } + }, +}) diff --git a/apps/sim/lib/desktop/source-browser.ts b/apps/sim/lib/desktop/source-browser.ts new file mode 100644 index 00000000000..4f787a69f7f --- /dev/null +++ b/apps/sim/lib/desktop/source-browser.ts @@ -0,0 +1,195 @@ +import { z } from 'zod' +import { requestJson } from '@/lib/api/client/request' +import { startSlackCredentialGroupConfigurationContract } from '@/lib/api/contracts/credential-groups' +import { + consumeDesktopSourceRequestContract, + type DesktopSourceRequest, +} from '@/lib/api/contracts/desktop-source-connect' +import { + connectSimSearchConnectorContract, + startKnowledgeConnectorMemberEnrollmentContract, +} from '@/lib/api/contracts/knowledge/connectors' +import { + gitHubSearchSetupScopeSchema, + readGitHubSearchSetupContract, + startGitHubSearchSetupContract, +} from '@/lib/api/contracts/knowledge/github-setup' +import { connectPersonalSearchIntegrationContract } from '@/lib/api/contracts/knowledge/personal-integrations' +import { startSlackSearchOAuthContract } from '@/lib/api/contracts/knowledge/slack' +import { + reconnectPersonalOrganizationAccountContract, + startOrganizationAccountConnectionContract, + startOrganizationSlackConfigurationContract, +} from '@/lib/api/contracts/organization-accounts' +import { buildConnectCompletePath } from '@/app/desktop/connect/validation' + +const STORAGE_KEY = 'sim:desktop-source-connect' +const contextSchema = z.object({ + state: z.string().regex(/^[A-Za-z0-9_-]{16,256}$/), + port: z.number().int().min(1024).max(65535), + expiresAt: z.number(), + match: z.object({ + kind: z.enum(['completion', 'enrollment', 'slack-search', 'slack-managed-users']), + id: z.string().min(1).max(512), + }), + github: gitHubSearchSetupScopeSchema.optional(), +}) +type SourceContext = z.output +export type DesktopSourceCompletion = SourceContext['match'] & { error?: string } + +function readContext(): SourceContext | null { + try { + const raw = sessionStorage.getItem(STORAGE_KEY) + const parsed = contextSchema.safeParse(raw ? JSON.parse(raw) : null) + if (parsed.success && parsed.data.expiresAt > Date.now()) return parsed.data + sessionStorage.removeItem(STORAGE_KEY) + } catch { + /* Storage may be disabled in the system browser. */ + } + return null +} + +function enrollmentMatch(invitationLink: string): SourceContext['match'] { + const url = new URL(invitationLink, window.location.origin) + const token = url.pathname.match(/^\/credential-groups\/enroll\/([^/]+)$/)?.[1] + if (url.origin !== window.location.origin || !token) + throw new Error('Invalid account connection link') + return { kind: 'enrollment', id: decodeURIComponent(token) } +} + +async function startRequest( + request: DesktopSourceRequest +): Promise<{ url: string; match: SourceContext['match']; github?: SourceContext['github'] }> { + switch (request.kind) { + case 'slack-search': { + const result = await requestJson(startSlackSearchOAuthContract, { body: request.body }) + const state = new URL(result.authorizationUrl).searchParams.get('state') + if (!state) throw new Error('Invalid Slack authorization link') + return { url: result.authorizationUrl, match: { kind: 'slack-search', id: state } } + } + case 'github-setup': { + const result = await requestJson(startGitHubSearchSetupContract, { body: request.body }) + return { + url: result.url, + match: { kind: 'completion', id: request.body.setupId }, + github: { organizationId: request.body.organizationId, setupId: request.body.setupId }, + } + } + case 'organization-account': + case 'reconnect-account': { + const result = + request.kind === 'organization-account' + ? await requestJson(startOrganizationAccountConnectionContract, { + params: { id: request.organizationId }, + body: request.body, + }) + : await requestJson(reconnectPersonalOrganizationAccountContract, { + params: { credentialId: request.credentialId }, + }) + return { + url: result.authorizationUrl ?? result.invitationLink, + match: enrollmentMatch(result.invitationLink), + } + } + case 'personal-search': { + const result = await requestJson(connectPersonalSearchIntegrationContract, { + body: request.body, + }) + if (!request.body.oauthCompletionId) throw new Error('Missing connection attempt') + return { + url: result.data.url, + match: { kind: 'completion', id: request.body.oauthCompletionId }, + } + } + case 'member-enrollment': { + const result = await requestJson(startKnowledgeConnectorMemberEnrollmentContract, { + params: request.params, + query: { oauthCompletionId: request.completionId }, + }) + return { + url: result.data.url, + match: request.completionId + ? { kind: 'completion', id: request.completionId } + : enrollmentMatch(result.data.url), + } + } + case 'search-source': { + const result = await requestJson(connectSimSearchConnectorContract, { + body: { ...request.body, oauthCompletionId: request.completionId }, + }) + return { + url: result.data.url, + match: request.completionId + ? { kind: 'completion', id: request.completionId } + : enrollmentMatch(result.data.url), + } + } + case 'slack-managed-users': { + const { owner, body, credentialGroupId } = request + const result = owner.organizationId + ? await requestJson(startOrganizationSlackConfigurationContract, { + params: { id: owner.organizationId, groupId: credentialGroupId }, + body: { appId: body.appId!, teamId: body.teamId!, requiredScopes: body.requiredScopes }, + }) + : await requestJson(startSlackCredentialGroupConfigurationContract, { + params: { id: owner.workspaceId!, groupId: credentialGroupId }, + body, + }) + return { + url: result.authorizationUrl, + match: { kind: 'slack-managed-users', id: result.state }, + } + } + } +} + +/** Stores only correlation metadata in this tab; secrets stay in the encrypted one-use request. */ +export async function startDesktopSourceBrowser( + requestId: string, + state: string, + port: number +): Promise { + sessionStorage.removeItem(STORAGE_KEY) + // Storage must work before an authorization attempt is created. + sessionStorage.setItem(STORAGE_KEY, '{}') + const request = await requestJson(consumeDesktopSourceRequestContract, { body: { requestId } }) + const result = await startRequest(request) + const url = new URL(result.url, window.location.origin) + if ( + url.protocol !== 'https:' && + !(url.protocol === 'http:' && url.origin === window.location.origin) + ) + throw new Error('Invalid authorization link') + const context: SourceContext = { + state, + port, + expiresAt: Date.now() + 10 * 60_000, + match: result.match, + ...(result.github ? { github: result.github } : {}), + } + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(context)) + window.location.replace(url.href) +} + +/** Returns only the matching attempt to desktop; native callers refetch authorized server state. */ +export async function finishDesktopSourceBrowser( + completion: DesktopSourceCompletion +): Promise { + const context = readContext() + if (!context || context.match.kind !== completion.kind || context.match.id !== completion.id) + return false + sessionStorage.removeItem(STORAGE_KEY) + const url = new URL(buildConnectCompletePath(context.state, context.port), window.location.origin) + if (completion.error) url.searchParams.set('error', 'connection_failed') + else if (context.github) { + try { + const result = await requestJson(readGitHubSearchSetupContract, { query: context.github }) + if (result.data.status !== 'completed') throw new Error('GitHub setup is incomplete') + url.searchParams.set('credentialId', result.data.credential.id) + } catch { + url.searchParams.set('error', 'connection_failed') + } + } + window.location.replace(url.href) + return true +} diff --git a/apps/sim/lib/desktop/source-connect.ts b/apps/sim/lib/desktop/source-connect.ts new file mode 100644 index 00000000000..9d245b4039d --- /dev/null +++ b/apps/sim/lib/desktop/source-connect.ts @@ -0,0 +1,85 @@ +import type { DesktopOAuthConnectResult } from '@sim/desktop-bridge' +import { toast } from '@sim/emcn' +import { toError } from '@sim/utils/errors' +import { requestJson } from '@/lib/api/client/request' +import { + createDesktopSourceRequestContract, + type DesktopSourceRequest, +} from '@/lib/api/contracts/desktop-source-connect' +import { getDesktopBridge } from '@/lib/desktop' + +const CONNECTION_TIMEOUT_MS = 10 * 60_000 + +/** Runs source authorization in the system browser without navigating the desktop renderer. */ +export async function connectDesktopSource( + request: DesktopSourceRequest, + signal?: AbortSignal +): Promise { + const bridge = getDesktopBridge() + if (!bridge?.prepareSourceConnect || !bridge.beginSourceConnect || !bridge.cancelSourceConnect) { + throw new Error('Update the Sim desktop app to connect this account.') + } + signal?.throwIfAborted() + const requestId = await bridge.prepareSourceConnect() + if (!requestId) throw new Error('Could not start the connection. Try connecting again.') + return new Promise((resolve, reject) => { + const controller = new AbortController() + let settled = false + const finish = (result?: DesktopOAuthConnectResult, error?: Error) => { + if (settled) return + settled = true + controller.abort() + clearTimeout(timer) + unsubscribe() + toast.dismiss(notice) + signal?.removeEventListener('abort', abort) + if (error) reject(error) + else if (result?.ok) resolve(result) + else + reject( + new Error( + result?.error === 'cancelled' || result?.error === 'superseded' + ? 'Connection canceled. You can try again.' + : 'Connection did not complete. Try connecting again.' + ) + ) + } + const abort = () => { + void bridge.cancelSourceConnect?.(requestId).catch(() => undefined) + finish(undefined, new Error('Connection canceled. You can try again.')) + } + const unsubscribe = bridge.onOAuthConnectComplete((result) => { + if (result.sourceRequestId === requestId) finish(result) + }) + const timer = setTimeout(() => { + void bridge.cancelSourceConnect?.(requestId).catch(() => undefined) + finish(undefined, new Error('Connection timed out. Try connecting again.')) + }, CONNECTION_TIMEOUT_MS) + const notice = toast({ + message: 'Continue connecting in your browser', + duration: 0, + persistAcrossRoutes: true, + action: { label: 'Cancel', onClick: abort }, + onUserDismiss: abort, + }) + signal?.addEventListener('abort', abort, { once: true }) + if (signal?.aborted) { + abort() + return + } + void requestJson(createDesktopSourceRequestContract, { + body: { requestId, request }, + signal: controller.signal, + }) + .then(async () => { + if (settled) return + const opened = await bridge.beginSourceConnect!(requestId) + if (!opened) + finish(undefined, new Error('Could not open the browser. Try connecting again.')) + }) + .catch((error) => { + void bridge.cancelSourceConnect?.(requestId).catch(() => undefined) + finish(undefined, toError(error)) + }) + }) +} diff --git a/apps/sim/lib/desktop/source-request.integration.ts b/apps/sim/lib/desktop/source-request.integration.ts new file mode 100644 index 00000000000..437cecdeb47 --- /dev/null +++ b/apps/sim/lib/desktop/source-request.integration.ts @@ -0,0 +1,87 @@ +import { sha256Hex } from '@sim/security/hash' +import { sleep } from '@sim/utils/helpers' +import { generateId, generateShortId } from '@sim/utils/id' +import { afterAll, describe, expect, it, vi } from 'vitest' + +const { redisUrl } = await vi.hoisted(async () => { + const { readTestRedisUrl } = await import('@sim/db/testing/test-infrastructure') + const redisUrl = readTestRedisUrl() + if (redisUrl) process.env.REDIS_URL = redisUrl + return { redisUrl } +}) + +import { closeRedisConnection, getRedisClient } from '@/lib/core/config/redis' + +afterAll(() => closeRedisConnection()) + +import { + consumeDesktopSourceRequest, + createDesktopSourceRequest, +} from '@/lib/desktop/application/source-requests' + +/** Real Redis proves cross-session ownership and atomic consumption, without provider credentials. */ +describe.skipIf(!redisUrl)('desktop source request transport', () => { + it('allows the same user in another session, without letting another user consume the request', async () => { + const userId = generateId() + const created = await createDesktopSourceRequest.execute({ + principal: { kind: 'session', userId, sessionId: 'desktop-fixture' }, + input: { requestId: generateShortId(32), payload: '{"kind":"fixture"}' }, + }) + await expect( + consumeDesktopSourceRequest.execute({ + principal: { kind: 'session', userId: generateId(), sessionId: 'foreign-fixture' }, + input: created, + }) + ).rejects.toThrow('Connection request expired') + const results = await Promise.allSettled( + Array.from({ length: 4 }, () => + consumeDesktopSourceRequest.execute({ + principal: { kind: 'session', userId, sessionId: 'browser-fixture' }, + input: created, + }) + ) + ) + expect(results.filter((result) => result.status === 'fulfilled')).toEqual([ + { status: 'fulfilled', value: { payload: '{"kind":"fixture"}' } }, + ]) + expect(results.filter((result) => result.status === 'rejected')).toHaveLength(3) + }) + + it('encrypts transport secrets and expires abandoned requests', async () => { + const principal = { + kind: 'session' as const, + userId: generateId(), + sessionId: 'desktop-fixture', + } + const created = await createDesktopSourceRequest.execute({ + principal, + input: { requestId: generateShortId(32), payload: 'fixture-secret-never-in-plaintext' }, + }) + const redis = getRedisClient()! + const key = `desktop:source-request:${sha256Hex(created.requestId)}` + const stored = await redis.get(key) + expect(stored).not.toContain('fixture-secret-never-in-plaintext') + expect(await redis.ttl(key)).toBeGreaterThan(0) + expect(await redis.ttl(key)).toBeLessThanOrEqual(600) + await redis.expire(key, 1) + await sleep(1100) + await expect( + consumeDesktopSourceRequest.execute({ principal, input: created }) + ).rejects.toThrow('Connection request expired') + }) + + it('rejects non-session callers and oversized requests', async () => { + await expect( + createDesktopSourceRequest.execute({ + principal: { kind: 'workspace_api_key', workspaceId: generateId(), keyId: generateId() }, + input: { requestId: generateShortId(32), payload: '{}' }, + }) + ).rejects.toThrow('Session authentication required') + await expect( + createDesktopSourceRequest.execute({ + principal: { kind: 'session', userId: generateId(), sessionId: 'desktop-fixture' }, + input: { requestId: generateShortId(32), payload: 'x'.repeat(32769) }, + }) + ).rejects.toThrow('Connection request is too large') + }) +}) diff --git a/apps/sim/scripts/fixtures/desktop-source-connect.tsx b/apps/sim/scripts/fixtures/desktop-source-connect.tsx new file mode 100644 index 00000000000..875ca8c3742 --- /dev/null +++ b/apps/sim/scripts/fixtures/desktop-source-connect.tsx @@ -0,0 +1,120 @@ +import { StrictMode, useEffect, useRef, useState } from 'react' +import { ToastProvider } from '@sim/emcn' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot } from 'react-dom/client' +import { startDesktopSourceBrowser } from '@/lib/desktop/source-browser' +import { CredentialGroupCompletionHandoff } from '@/app/credential-groups/complete/completion-handoff' +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 { useSlackSearchInstallations, useStartSlackSearchOAuth } from '@/hooks/queries/slack-search' +import { useGitHubInstallationSetup } from '@/hooks/use-github-installation-setup' + +const NO_CONNECTIONS = new Set() +const MEMBERSHIP_KEYS: readonly (readonly string[])[] = [] + +function SourceConnectFixture() { + const enrollment = useMemberEnrollment({ + membershipQueryKeys: MEMBERSHIP_KEYS, + connectedConnectorIds: NO_CONNECTIONS, + }) + const [githubCredential, setGithubCredential] = useState('') + const github = useGitHubInstallationSetup({ + organizationId: 'fixture-organization', + onConnected: setGithubCredential, + }) + const connection = useStartSlackSearchOAuth() + const inventory = useSlackSearchInstallations('fixture-organization') + return ( +
+ + + + {String(github.pending)} + {githubCredential} + {github.error} + + {String(enrollment.isPending)} + {enrollment.error} + {connection.status} + {inventory.data?.installations.length ?? 0} + {connection.error &&

{connection.error.message}

} +
+ ) +} + +function BrowserLauncher() { + const started = useRef(false) + const [error, setError] = useState('') + useEffect(() => { + if (started.current) return + started.current = true + const params = new URLSearchParams(location.search) + void startDesktopSourceBrowser( + params.get('sourceRequestId')!, + params.get('state')!, + Number(params.get('port')) + ).catch((error: Error) => setError(error.message)) + }, []) + return

{error}

+} + +const params = new URLSearchParams(location.search) +const content = + location.pathname === '/credential-groups/enroll/fixture-invitation' ? ( + params.has('connected') ? ( + + ) : ( + + Authorize invited source + + ) + ) : location.pathname === '/credential-groups/complete' ? ( + + ) : location.pathname === '/desktop/connect' ? ( + + ) : location.pathname === '/credential-groups/slack-complete' ? ( + + ) : ( + + ) +const root = document.getElementById('root') +if (!root) throw new Error('Missing fixture root') +createRoot(root).render( + + + {content} + + +) diff --git a/packages/desktop-bridge/src/index.ts b/packages/desktop-bridge/src/index.ts index ca0eef82909..efc6853efc6 100644 --- a/packages/desktop-bridge/src/index.ts +++ b/packages/desktop-bridge/src/index.ts @@ -668,6 +668,10 @@ export type LocalFilesystemResponse = /** Outcome of an OAuth connect handoff, pushed when the browser flow finishes. */ export interface DesktopOAuthConnectResult { ok: boolean + /** Source request correlated by the shell, never taken from the browser callback. */ + sourceRequestId?: string + /** A GitHub setup selection; consumers verify current access before using it. */ + credentialId?: string /** OAuth error slug forwarded from the provider callback, when the flow failed. */ error?: string /** @@ -1086,6 +1090,11 @@ export interface SimDesktopApi { * browser could not be opened. */ beginOAuthConnect(providerId: string, scope?: DesktopOAuthConnectScope): Promise + /** Starts an opaque source request in the browser without moving the desktop page. */ + prepareSourceConnect?(): Promise + beginSourceConnect?(requestId: string): Promise + /** Cancels only the matching pending source handoff. */ + cancelSourceConnect?(requestId: string): Promise /** * Subscribe to connect-handoff completions (the app is refocused just * before this fires). Returns an unsubscribe function. diff --git a/vitest.shared.ts b/vitest.shared.ts index 44f6e6e2d70..31cdcdfd1e5 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -37,6 +37,6 @@ export const integrationTestConfig = defineConfig({ testTimeout: 30_000, hookTimeout: 30_000, reporters: ['default', 'json'], - outputFile: { json: 'test-results/integration.json' }, + outputFile: { json: process.env.INTEGRATION_REPORT_PATH ?? 'test-results/integration.json' }, }, })