diff --git a/apps/sim/app/oauth/chat-complete/chat-complete-handoff.test.tsx b/apps/sim/app/oauth/chat-complete/chat-complete-handoff.test.tsx
new file mode 100644
index 00000000000..8b28bf1f473
--- /dev/null
+++ b/apps/sim/app/oauth/chat-complete/chat-complete-handoff.test.tsx
@@ -0,0 +1,135 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ createOAuthChatAttempt,
+ type OAuthChatAttempt,
+ readOAuthChatAttempt,
+} from '@/lib/credentials/oauth-chat-attempt'
+import { ChatCompleteHandoff } from '@/app/oauth/chat-complete/chat-complete-handoff'
+
+function renderAt(search: string): { root: Root } {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ window.history.replaceState({}, '', `/oauth/chat-complete${search}`)
+ const root: Root = createRoot(document.createElement('div'))
+ act(() => root.render())
+ return { root }
+}
+
+describe('ChatCompleteHandoff', () => {
+ let attempt: OAuthChatAttempt
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ window.localStorage.clear()
+ vi.spyOn(window, 'close').mockImplementation(() => {})
+ attempt = createOAuthChatAttempt({
+ workspaceId: 'workspace-1',
+ providerId: 'google-email',
+ baseProviderId: 'google',
+ displayName: 'Gmail',
+ controlId: 'message-1:0:0',
+ baselineCredentialIds: ['existing-gmail'],
+ })
+ })
+
+ it('publishes success on arrival, without requiring a new credential to appear', () => {
+ // Re-authorizing an already-linked account updates the account row rather
+ // than creating one, so no new credential lands — reaching this page is
+ // still the server telling us the flow succeeded.
+ const { root } = renderAt(`?oauthAttempt=${attempt.id}`)
+
+ expect(readOAuthChatAttempt(attempt.id)?.status).toBe('connected')
+ expect(window.close).toHaveBeenCalledOnce()
+ act(() => root.unmount())
+ })
+
+ it('publishes failure when the provider returned an error', () => {
+ const { root } = renderAt(`?oauthAttempt=${attempt.id}&error=access_denied`)
+
+ expect(readOAuthChatAttempt(attempt.id)?.status).toBe('failed')
+ act(() => root.unmount())
+ })
+
+ it('leaves an unrelated attempt untouched when no attempt is named', () => {
+ const { root } = renderAt('')
+
+ expect(readOAuthChatAttempt(attempt.id)?.status).toBe('pending')
+ expect(window.close).toHaveBeenCalledOnce()
+ act(() => root.unmount())
+ })
+
+ it('still publishes the verdict when the attempt store rejects the write', () => {
+ const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new DOMException('quota', 'QuotaExceededError')
+ })
+
+ // The verdict is lost, but the window must still be released — an
+ // unguarded throw would strand the popup open on this page.
+ expect(() => renderAt(`?oauthAttempt=${attempt.id}`)).not.toThrow()
+ expect(window.close).toHaveBeenCalledOnce()
+ setItem.mockRestore()
+ })
+
+ describe('close-refused fallback', () => {
+ const realLocation = window.location
+
+ afterEach(() => {
+ vi.useRealTimers()
+ Object.defineProperty(window, 'location', { configurable: true, value: realLocation })
+ })
+
+ /**
+ * jsdom performs no navigation and forbids redefining `location.replace`,
+ * so the whole location is swapped for a stub carrying only what the
+ * handoff reads: the current href, the origin, and the redirect sink.
+ */
+ function renderWithStubbedLocation(search: string): { calls: string[]; root: Root } {
+ const calls: string[] = []
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: {
+ href: `https://sim.test/oauth/chat-complete${search}`,
+ origin: 'https://sim.test',
+ replace: (url: string) => calls.push(url),
+ },
+ })
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ const root: Root = createRoot(document.createElement('div'))
+ act(() => root.render())
+ return { calls, root }
+ }
+
+ it('redirects to a same-origin returnTo once the close is refused', () => {
+ vi.useFakeTimers()
+ const returnTo = 'https://sim.test/workspace/workspace-1/chat/chat-1'
+
+ const { calls, root } = renderWithStubbedLocation(
+ `?oauthAttempt=${attempt.id}&returnTo=${encodeURIComponent(returnTo)}`
+ )
+ act(() => {
+ vi.advanceTimersByTime(400)
+ })
+
+ expect(calls).toEqual([returnTo])
+ act(() => root.unmount())
+ })
+
+ it('refuses a cross-origin returnTo and falls back to the workspace', () => {
+ vi.useFakeTimers()
+
+ const { calls, root } = renderWithStubbedLocation(
+ `?oauthAttempt=${attempt.id}&returnTo=${encodeURIComponent('https://evil.example/steal')}`
+ )
+ act(() => {
+ vi.advanceTimersByTime(400)
+ })
+
+ expect(calls).toEqual(['/workspace'])
+ act(() => root.unmount())
+ })
+ })
+})
diff --git a/apps/sim/app/oauth/chat-complete/chat-complete-handoff.tsx b/apps/sim/app/oauth/chat-complete/chat-complete-handoff.tsx
new file mode 100644
index 00000000000..258bf17d1e8
--- /dev/null
+++ b/apps/sim/app/oauth/chat-complete/chat-complete-handoff.tsx
@@ -0,0 +1,66 @@
+'use client'
+
+import { useEffect, useRef } from 'react'
+import {
+ OAUTH_CHAT_ATTEMPT_PARAM,
+ OAUTH_CHAT_RETURN_TO_PARAM,
+ setOAuthChatAttemptStatus,
+} from '@/lib/credentials/oauth-chat-attempt'
+
+const CLOSE_FALLBACK_DELAY_MS = 400
+
+/**
+ * The fallback redirect must never leave this origin — the target rides in a
+ * query param the user could have tampered with.
+ */
+function sanitizeReturnTo(raw: string | null): string | null {
+ if (!raw) return null
+ try {
+ const url = new URL(raw, window.location.origin)
+ return url.origin === window.location.origin ? url.toString() : null
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Behavior half of the chat OAuth return leg: publishes the verdict to the
+ * attempt record — which the chat tab's chip picks up over its storage
+ * listener — then closes the window. Renders nothing, so the page's frame
+ * paints as server markup before this hydrates.
+ *
+ * Reaching this page IS the verdict: Better Auth routes a flow here only as its
+ * success `callbackURL`, sending failures to `/oauth-error` or back here with
+ * an `error` code. That beats diffing the credential list, which calls a
+ * re-authorized account a failure — that path updates the existing account row
+ * and creates nothing for a diff to find.
+ *
+ * A window the browser refuses to close redirects to the chat instead (the
+ * popup-blocked path opens this leg in a tab, which no script may close). That
+ * URL carries no attempt id on purpose: the verdict is already published, and
+ * the destination would otherwise re-decide it by the very diff above.
+ */
+export function ChatCompleteHandoff() {
+ const ranRef = useRef(false)
+
+ useEffect(() => {
+ if (ranRef.current) return
+ ranRef.current = true
+
+ const params = new URL(window.location.href).searchParams
+ const attemptId = params.get(OAUTH_CHAT_ATTEMPT_PARAM)
+ const returnTo = sanitizeReturnTo(params.get(OAUTH_CHAT_RETURN_TO_PARAM))
+
+ if (attemptId) {
+ setOAuthChatAttemptStatus(attemptId, params.has('error') ? 'failed' : 'connected')
+ }
+
+ window.close()
+ const timer = window.setTimeout(() => {
+ window.location.replace(returnTo ?? '/workspace')
+ }, CLOSE_FALLBACK_DELAY_MS)
+ return () => window.clearTimeout(timer)
+ }, [])
+
+ return null
+}
diff --git a/apps/sim/app/oauth/chat-complete/page.tsx b/apps/sim/app/oauth/chat-complete/page.tsx
new file mode 100644
index 00000000000..3567713cbe5
--- /dev/null
+++ b/apps/sim/app/oauth/chat-complete/page.tsx
@@ -0,0 +1,28 @@
+import type { Metadata } from 'next'
+import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell'
+import { ChatCompleteHandoff } from '@/app/oauth/chat-complete/chat-complete-handoff'
+
+export const metadata: Metadata = {
+ title: 'Returning to Sim',
+ robots: { index: false },
+}
+
+/**
+ * Post-OAuth return leg for the chat credential chips. The chip rewrites the
+ * authorize URL's return param to land here, so the OAuth window finishes on
+ * this page — which signals the chat tab and closes — instead of loading a
+ * second copy of the app.
+ *
+ * Shown for a few hundred milliseconds in a popup, or briefly in the original
+ * tab when the popup was blocked, so it wears the same handoff frame as the
+ * other minimal-chrome gates (the 404, the desktop connect screens) rather
+ * than styling of its own.
+ */
+export default function ChatCompletePage() {
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx
index 69cb9c3bda9..f487ce2f2e6 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx
@@ -1,5 +1,6 @@
/**
* @vitest-environment jsdom
+ * @vitest-environment-options { "url": "https://sim.test/workspace/workspace-1/chat/chat-1" }
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
@@ -51,6 +52,7 @@ vi.mock('@/hooks/queries/environment', () => ({
}),
}))
+import { toast } from '@sim/emcn'
import {
createOAuthChatAttempt,
setOAuthChatAttemptStatus,
@@ -319,6 +321,513 @@ describe('CredentialDisplay link tag', () => {
act(() => root.unmount())
})
+ it('runs the connect in a popup so the chat tab never navigates', () => {
+ const popup = { focus: vi.fn() }
+ const openSpy = vi
+ .spyOn(window, 'open')
+ .mockReturnValue(popup as unknown as ReturnType)
+ const { container, root } = renderCredentialLink({
+ type: 'link',
+ provider: 'google-email',
+ value:
+ 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1',
+ })
+
+ const link = container.querySelector('a')
+ const defaultPrevented = !link?.dispatchEvent(
+ new MouseEvent('click', { bubbles: true, cancelable: true })
+ )
+
+ expect(defaultPrevented).toBe(true)
+ expect(popup.focus).toHaveBeenCalledOnce()
+ const openedUrl = new URL(openSpy.mock.calls[0][0] as string)
+ const callbackUrl = new URL(openedUrl.searchParams.get('callbackURL') ?? '')
+ expect(callbackUrl.pathname).toBe('/oauth/chat-complete')
+ // Named per attempt: a shared name would let a sibling row renavigate this
+ // popup and strand this attempt with no return leg.
+ const attemptId = callbackUrl.searchParams.get('oauthAttempt')
+ expect(openSpy.mock.calls[0][1]).toBe(`sim-oauth-connect-${attemptId}`)
+ openSpy.mockRestore()
+ act(() => root.unmount())
+ })
+
+ it('keeps a cross-origin connect URL on the anchor instead of a popup', () => {
+ const openSpy = vi.spyOn(window, 'open')
+ const { container, root } = renderCredentialLink({
+ type: 'link',
+ provider: 'google-email',
+ value:
+ 'https://evil.example/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fevil.example%2Fsink',
+ })
+
+ const link = container.querySelector('a')
+ link?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
+
+ // The anchor carries rel='noopener noreferrer'; window.open would not.
+ expect(openSpy).not.toHaveBeenCalled()
+ expect(link?.getAttribute('rel')).toBe('noopener noreferrer')
+ openSpy.mockRestore()
+ act(() => root.unmount())
+ })
+
+ it('announces the connection once the popup publishes its verdict', async () => {
+ const toastSuccess = vi.spyOn(toast, 'success').mockImplementation(() => '')
+ const popup = { focus: vi.fn(), closed: false }
+ const openSpy = vi
+ .spyOn(window, 'open')
+ .mockReturnValue(popup as unknown as ReturnType)
+ const { container, root } = renderCredentialLink({
+ type: 'link',
+ provider: 'google-email',
+ value:
+ 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1',
+ })
+
+ await act(async () => {
+ container
+ .querySelector('a')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
+ })
+ const attemptId = new URL(
+ new URL(openSpy.mock.calls[0][0] as string).searchParams.get('callbackURL') ?? ''
+ ).searchParams.get('oauthAttempt') as string
+
+ expect(toastSuccess).not.toHaveBeenCalled()
+ await act(async () => {
+ setOAuthChatAttemptStatus(attemptId, 'connected')
+ })
+
+ expect(toastSuccess).toHaveBeenCalledWith('Gmail connected successfully.')
+ openSpy.mockRestore()
+ toastSuccess.mockRestore()
+ act(() => root.unmount())
+ })
+
+ it('settles the row when the popup dies on a page that publishes no verdict', async () => {
+ // A denied consent lands on /oauth-error, which never writes a verdict and
+ // never self-closes. The row must not defer to it forever.
+ const popup = {
+ focus: vi.fn(),
+ closed: false,
+ location: { origin: 'https://sim.test', pathname: '/oauth-error' },
+ }
+ const openSpy = vi
+ .spyOn(window, 'open')
+ .mockReturnValue(popup as unknown as ReturnType)
+ const { container, root } = renderCredentialLink({
+ type: 'link',
+ provider: 'google-email',
+ value:
+ 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1',
+ })
+
+ await act(async () => {
+ container
+ .querySelector('a')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
+ })
+ await act(async () => {
+ window.dispatchEvent(new Event('blur'))
+ window.dispatchEvent(new Event('focus'))
+ })
+
+ expect(container.textContent).toContain('Not connected — connect Gmail')
+ openSpy.mockRestore()
+ act(() => root.unmount())
+ })
+
+ it('keeps the row retryable when the verdict lands but no credential appears', async () => {
+ // The credential-draft failure is swallowed server-side, so the flow can
+ // report success with nothing in the workspace. Label follows the verdict;
+ // the control must stay clickable so the user can try again.
+ const toastSuccess = vi.spyOn(toast, 'success').mockImplementation(() => '')
+ const popup = { focus: vi.fn(), closed: false }
+ const openSpy = vi
+ .spyOn(window, 'open')
+ .mockReturnValue(popup as unknown as ReturnType)
+ const { container, root } = renderCredentialLink({
+ type: 'link',
+ provider: 'google-email',
+ value:
+ 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1',
+ })
+
+ await act(async () => {
+ container
+ .querySelector('a')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
+ })
+ const attemptId = new URL(
+ new URL(openSpy.mock.calls[0][0] as string).searchParams.get('callbackURL') ?? ''
+ ).searchParams.get('oauthAttempt') as string
+ await act(async () => {
+ setOAuthChatAttemptStatus(attemptId, 'connected')
+ })
+
+ expect(container.textContent).toContain('Connected Gmail')
+ expect(container.querySelector('a')?.getAttribute('aria-disabled')).toBe('false')
+ openSpy.mockRestore()
+ toastSuccess.mockRestore()
+ act(() => root.unmount())
+ })
+
+ it('keeps waiting when the tab is refocused while the connect popup is still open', async () => {
+ const popup = {
+ focus: vi.fn(),
+ closed: false,
+ // Still on the provider's pages: reading location across origins throws.
+ get location(): Location {
+ throw new DOMException('cross-origin', 'SecurityError')
+ },
+ }
+ const openSpy = vi
+ .spyOn(window, 'open')
+ .mockReturnValue(popup as unknown as ReturnType)
+ const { container, root } = renderCredentialLink({
+ type: 'link',
+ provider: 'google-email',
+ value:
+ 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1',
+ })
+
+ await act(async () => {
+ container
+ .querySelector('a')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
+ })
+ await act(async () => {
+ window.dispatchEvent(new Event('blur'))
+ window.dispatchEvent(new Event('focus'))
+ })
+
+ expect(container.textContent).toContain('Waiting for Gmail connection')
+ openSpy.mockRestore()
+ act(() => root.unmount())
+ })
+
+ it('settles the row from the popup watcher when the flow ends with no event in this tab', async () => {
+ // A provider interstitial (Trello parks on one for ~3s) bounces to the
+ // workspace root without ever reaching the completion page. Neither page
+ // publishes a verdict, and a user who never leaves this tab gets no focus
+ // event either — only the watcher can end the wait.
+ vi.useFakeTimers()
+ const popup = {
+ focus: vi.fn(),
+ closed: false,
+ location: { origin: 'https://sim.test', pathname: '/api/auth/trello/callback' },
+ }
+ const openSpy = vi
+ .spyOn(window, 'open')
+ .mockReturnValue(popup as unknown as ReturnType)
+ const { container, root } = renderCredentialLink({
+ type: 'link',
+ provider: 'google-email',
+ value:
+ 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1',
+ })
+
+ await act(async () => {
+ container
+ .querySelector('a')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2000)
+ })
+
+ // The interstitial is not terminal, so the row is still deferring.
+ expect(container.textContent).toContain('Waiting for Gmail connection')
+
+ popup.location.pathname = '/workspace'
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2000)
+ })
+
+ expect(container.textContent).toContain('Not connected — connect Gmail')
+ vi.useRealTimers()
+ openSpy.mockRestore()
+ act(() => root.unmount())
+ })
+
+ it('does not fail the row when a disowned popup handle reports closed', async () => {
+ // A provider page with COOP `same-origin` disowns the window, and the
+ // disowned handle reports `closed` for a consent screen that is still
+ // running. The watcher must not read that as an ending: publishing 'failed'
+ // here would fight a live flow, and the row would invite a rival retry.
+ vi.useFakeTimers()
+ const popup = { focus: vi.fn(), closed: true }
+ const openSpy = vi
+ .spyOn(window, 'open')
+ .mockReturnValue(popup as unknown as ReturnType)
+ const { container, root } = renderCredentialLink({
+ type: 'link',
+ provider: 'google-email',
+ value:
+ 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1',
+ })
+
+ await act(async () => {
+ container
+ .querySelector('a')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(4000)
+ })
+
+ expect(container.textContent).toContain('Waiting for Gmail connection')
+ expect(container.textContent).not.toContain('Not connected')
+
+ // Indistinguishable from a popup the user simply closed, so the wait is
+ // bounded rather than indefinite: past the safety timeout the row decides
+ // from the credential list instead of waiting on a verdict that never came.
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(10 * 60 * 1000)
+ })
+
+ expect(container.textContent).toContain('Not connected — connect Gmail')
+ vi.useRealTimers()
+ openSpy.mockRestore()
+ act(() => root.unmount())
+ })
+
+ it('holds the deadline open while the popup is live, then settles once it is not', async () => {
+ // A consent screen can outlive the safety timeout. Expiring against a live
+ // window would spend the bound early and leave nothing to catch the popup
+ // dying unobservably later, so the deadline waits instead of firing.
+ vi.useFakeTimers()
+ const popup = {
+ focus: vi.fn(),
+ closed: false,
+ // Still on the provider's pages: reading location across origins throws.
+ get location(): Location {
+ throw new DOMException('cross-origin', 'SecurityError')
+ },
+ }
+ const openSpy = vi
+ .spyOn(window, 'open')
+ .mockReturnValue(popup as unknown as ReturnType)
+ const { container, root } = renderCredentialLink({
+ type: 'link',
+ provider: 'google-email',
+ value:
+ 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1',
+ })
+
+ await act(async () => {
+ container
+ .querySelector('a')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(11 * 60 * 1000)
+ })
+
+ expect(container.textContent).toContain('Waiting for Gmail connection')
+
+ // Dies unobservably well after the original deadline — the watcher releases
+ // the handle without a verdict, so only a still-armed deadline can settle.
+ popup.closed = true
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2000)
+ })
+
+ expect(container.textContent).toContain('Not connected — connect Gmail')
+ vi.useRealTimers()
+ openSpy.mockRestore()
+ act(() => root.unmount())
+ })
+
+ it('keeps the unobservable deadline across a remount', async () => {
+ // The transcript virtualizes, so a row can scroll away mid-connect and come
+ // back with no window handle and no blur behind it. The deadline is derived
+ // from the attempt's requestedAt rather than held in the effect, so the
+ // remounted row inherits the remaining time instead of waiting forever.
+ vi.useFakeTimers()
+ const popup = { focus: vi.fn(), closed: false }
+ const openSpy = vi
+ .spyOn(window, 'open')
+ .mockReturnValue(popup as unknown as ReturnType)
+ const data: CredentialItemData = {
+ type: 'link',
+ provider: 'google-email',
+ value:
+ 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1',
+ }
+ const first = renderCredentialLink(data)
+
+ await act(async () => {
+ first.container
+ .querySelector('a')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
+ })
+ popup.closed = true
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(60 * 1000)
+ })
+ act(() => first.root.unmount())
+
+ const second = renderCredentialLink(data)
+ expect(second.container.textContent).toContain('Waiting for Gmail connection')
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(10 * 60 * 1000)
+ })
+
+ expect(second.container.textContent).toContain('Not connected — connect Gmail')
+ vi.useRealTimers()
+ openSpy.mockRestore()
+ act(() => second.root.unmount())
+ })
+
+ it('still announces the connection when the watcher released the popup first', async () => {
+ // The watcher drops the window handle as soon as it stops being observable,
+ // which routinely happens before React applies the storage-driven verdict.
+ // The announcement has to survive that, so it cannot be gated on the handle.
+ vi.useFakeTimers()
+ const toastSuccess = vi.spyOn(toast, 'success').mockImplementation(() => '')
+ const popup = { focus: vi.fn(), closed: false }
+ const openSpy = vi
+ .spyOn(window, 'open')
+ .mockReturnValue(popup as unknown as ReturnType)
+ const { container, root } = renderCredentialLink({
+ type: 'link',
+ provider: 'google-email',
+ value:
+ 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1',
+ })
+
+ await act(async () => {
+ container
+ .querySelector('a')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
+ })
+ const attemptId = new URL(
+ new URL(openSpy.mock.calls[0][0] as string).searchParams.get('callbackURL') ?? ''
+ ).searchParams.get('oauthAttempt') as string
+
+ popup.closed = true
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2000)
+ })
+ await act(async () => {
+ setOAuthChatAttemptStatus(attemptId, 'connected')
+ })
+
+ expect(toastSuccess).toHaveBeenCalledWith('Gmail connected successfully.')
+ vi.useRealTimers()
+ openSpy.mockRestore()
+ toastSuccess.mockRestore()
+ act(() => root.unmount())
+ })
+
+ it('focuses the live popup instead of starting a rival attempt on a repeat click', async () => {
+ const toastSuccess = vi.spyOn(toast, 'success').mockImplementation(() => '')
+ const popup = {
+ focus: vi.fn(),
+ closed: false,
+ // Still on the provider's pages: reading location across origins throws.
+ get location(): Location {
+ throw new DOMException('cross-origin', 'SecurityError')
+ },
+ }
+ const openSpy = vi
+ .spyOn(window, 'open')
+ .mockReturnValue(popup as unknown as ReturnType)
+ const { container, root } = renderCredentialLink({
+ type: 'link',
+ provider: 'google-email',
+ value:
+ 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1',
+ })
+ const link = container.querySelector('a')
+
+ await act(async () => {
+ link?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
+ })
+ const attemptId = new URL(
+ new URL(openSpy.mock.calls[0][0] as string).searchParams.get('callbackURL') ?? ''
+ ).searchParams.get('oauthAttempt') as string
+ await act(async () => {
+ link?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
+ })
+
+ // No second window, and no second attempt to strand the first one's verdict.
+ expect(openSpy).toHaveBeenCalledOnce()
+ expect(popup.focus).toHaveBeenCalledTimes(2)
+
+ await act(async () => {
+ setOAuthChatAttemptStatus(attemptId, 'connected')
+ })
+
+ expect(container.textContent).toContain('Connected Gmail')
+ openSpy.mockRestore()
+ toastSuccess.mockRestore()
+ act(() => root.unmount())
+ })
+
+ it('locks the row once the popup verdict is corroborated by a refetched credential', async () => {
+ const toastSuccess = vi.spyOn(toast, 'success').mockImplementation(() => '')
+ const popup = { focus: vi.fn(), closed: false }
+ const openSpy = vi
+ .spyOn(window, 'open')
+ .mockReturnValue(popup as unknown as ReturnType)
+ mockRefetchWorkspaceCredentials.mockResolvedValue({
+ data: [{ id: 'new-gmail', providerId: 'google', updatedAt: '2026-08-07T10:05:00Z' }],
+ })
+ const { container, root } = renderCredentialLink({
+ type: 'link',
+ provider: 'google-email',
+ value:
+ 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1',
+ })
+
+ await act(async () => {
+ container
+ .querySelector('a')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
+ })
+ const attemptId = new URL(
+ new URL(openSpy.mock.calls[0][0] as string).searchParams.get('callbackURL') ?? ''
+ ).searchParams.get('oauthAttempt') as string
+ await act(async () => {
+ setOAuthChatAttemptStatus(attemptId, 'connected')
+ })
+
+ // The popup flow never blurs this tab, so this refetch is the only one that
+ // can corroborate the verdict and let the control lock.
+ expect(mockRefetchWorkspaceCredentials).toHaveBeenCalled()
+ expect(container.textContent).toContain('Connected Gmail')
+ expect(container.querySelector('a')?.getAttribute('aria-disabled')).toBe('true')
+ openSpy.mockRestore()
+ toastSuccess.mockRestore()
+ act(() => root.unmount())
+ })
+
+ it('navigates the tab through the same completion page when the popup is blocked', () => {
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
+ const { container, root } = renderCredentialLink({
+ type: 'link',
+ provider: 'google-email',
+ value:
+ 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1',
+ })
+
+ const link = container.querySelector('a')
+ const defaultPrevented = !link?.dispatchEvent(
+ new MouseEvent('click', { bubbles: true, cancelable: true })
+ )
+
+ expect(defaultPrevented).toBe(false)
+ const callbackUrl = new URL(
+ new URL(link?.getAttribute('href') ?? '').searchParams.get('callbackURL') ?? ''
+ )
+ expect(callbackUrl.pathname).toBe('/oauth/chat-complete')
+ expect(callbackUrl.searchParams.get('oauthAttempt')).not.toBeNull()
+ openSpy.mockRestore()
+ act(() => root.unmount())
+ })
+
it('waits for the credential baseline before opening an OAuth link', () => {
mockUseWorkspaceCredentials.mockReturnValue({
data: undefined,
@@ -606,6 +1115,76 @@ describe('CredentialDisplay link tag', () => {
expect(container.querySelector('input')).toBeNull()
act(() => root.unmount())
})
+
+ it('keeps a typed secret while a sibling row runs its OAuth connect', async () => {
+ // The card's secret drafts live in component state until its Submit, so a
+ // connect that navigates this tab away discards whatever the user already
+ // typed into a sibling row. Running the flow in a popup is what makes the
+ // mixed card safe: this tab is never unloaded, so the draft outlives the
+ // connect — including the re-render its verdict and refetch trigger.
+ const toastSuccess = vi.spyOn(toast, 'success').mockImplementation(() => '')
+ const popup = { focus: vi.fn(), closed: false }
+ const openSpy = vi
+ .spyOn(window, 'open')
+ .mockReturnValue(popup as unknown as ReturnType)
+ const container = document.createElement('div')
+ const root: Root = createRoot(container)
+ const onOptionSelect = vi.fn()
+ const data: CredentialItemData[] = [
+ {
+ type: 'link',
+ provider: 'google-email',
+ value:
+ 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1',
+ },
+ { type: 'secret_input', name: 'OPENAI_API_KEY' },
+ ]
+ act(() => {
+ root.render(
+
+ )
+ })
+
+ const secretInput = container.querySelector('input')
+ act(() => {
+ if (!secretInput) return
+ const valueSetter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ 'value'
+ )?.set
+ valueSetter?.call(secretInput, 'sk-test-key')
+ secretInput.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+
+ await act(async () => {
+ container
+ .querySelector('a')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
+ })
+ const attemptId = new URL(
+ new URL(openSpy.mock.calls[0][0] as string).searchParams.get('callbackURL') ?? ''
+ ).searchParams.get('oauthAttempt') as string
+ await act(async () => {
+ setOAuthChatAttemptStatus(attemptId, 'connected')
+ })
+
+ // The field masks while unfocused, so assert on what the draft is actually
+ // for: submitting it. A mask of the right length is not proof it survived.
+ const submitButton = Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent === 'Submit'
+ )
+ await act(async () => {
+ submitButton?.click()
+ })
+
+ expect(mockUpsertWorkspaceEnvironment).toHaveBeenCalledWith({
+ workspaceId: 'workspace-1',
+ variables: { OPENAI_API_KEY: 'sk-test-key' },
+ })
+ openSpy.mockRestore()
+ toastSuccess.mockRestore()
+ act(() => root.unmount())
+ })
})
describe('parseSpecialTags sim_key placeholder', () => {
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts
index 74c4b95ebb4..f8933527f8c 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts
@@ -1,9 +1,11 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { toast } from '@sim/emcn'
import { useParams } from 'next/navigation'
import {
addOAuthChatAttemptToAuthorizeUrl,
+ buildOAuthChatCompleteAuthorizeUrl,
clearActiveDesktopOAuthChatAttempt,
createOAuthChatAttempt,
getOAuthCredentialBaseline,
@@ -23,6 +25,63 @@ import type { OAuthProvider } from '@/lib/oauth/types'
import { parseProvider } from '@/lib/oauth/utils'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
+const OAUTH_POPUP_WINDOW_NAME = 'sim-oauth-connect'
+/** Matches the MCP OAuth popup (`hooks/mcp/use-mcp-oauth-popup.ts`) so the two consent windows open alike. */
+const OAUTH_POPUP_FEATURES = 'width=560,height=720,resizable=yes,scrollbars=yes'
+/**
+ * How often to re-examine a live popup. Matches the MCP OAuth popup's own
+ * watcher: a closed or terminal window fires no event in the opener, so
+ * polling the handle is the only way to observe it.
+ */
+const OAUTH_POPUP_POLL_INTERVAL_MS = 400
+/** Matches the MCP OAuth popup's safety timeout: bounds a flow whose outcome never becomes observable. */
+const OAUTH_POPUP_UNOBSERVABLE_TIMEOUT_MS = 10 * 60 * 1000
+
+/**
+ * Same-origin pages an OAuth flow can die on without reaching the return leg —
+ * Better Auth sends pre-state failures (usually a denied consent) to its global
+ * error page, and the custom-provider callbacks exit to the workspace root.
+ * Neither publishes a verdict, so a popup sitting on one is finished.
+ */
+const OAUTH_POPUP_TERMINAL_PATHS = new Set(['/oauth-error', '/workspace'])
+
+/**
+ * What the opener can actually prove about a popup it launched. `ended` needs
+ * positive evidence: a same-origin page we can read that publishes no verdict.
+ * A handle reporting closed is only `unobservable` — COOP disowns the window
+ * and the disowned handle reports closed for a consent screen still running.
+ */
+type PopupObservation = 'live' | 'ended' | 'unobservable'
+
+function observePopup(popup: { window: Window } | null): PopupObservation {
+ if (!popup) return 'unobservable'
+ let closed: boolean
+ try {
+ closed = popup.window.closed
+ } catch {
+ return 'unobservable'
+ }
+ if (closed) return 'unobservable'
+ try {
+ const { origin, pathname } = popup.window.location
+ if (origin === window.location.origin && OAUTH_POPUP_TERMINAL_PATHS.has(pathname)) {
+ return 'ended'
+ }
+ } catch {
+ // Reading `location` across origins throws, which is the signal we want:
+ // the popup is still on the provider's pages, so the flow is in flight.
+ }
+ return 'live'
+}
+
+/**
+ * Whether the popup still owns the flow — i.e. whether the row should keep
+ * deferring to it instead of deciding for itself.
+ */
+function isPopupStillOpen(popup: { window: Window } | null): boolean {
+ return observePopup(popup) === 'live'
+}
+
interface UseOAuthChipConnectionParams {
/** Authorize URL streamed by the agent; provider and reconnect scope are read from it. */
connectUrl?: string
@@ -43,9 +102,12 @@ export interface OAuthChipConnection {
/** True when the row should read as connected, from any signal. */
connected: boolean
/**
- * True only when *this row's* attempt completed. Workspace-wide observation
- * cannot be attributed to one row, so this — not {@link connected} — is what
- * may lock the control.
+ * True only when *this row's* attempt completed AND the credential it was
+ * supposed to produce is actually present (a reconnect, which produces no
+ * new credential, is exempt). Workspace-wide observation cannot be
+ * attributed to one row, so this — not {@link connected} — is what may lock
+ * the control, and it is deliberately stricter than the label so a connect
+ * that reported success without landing stays retryable.
*/
connectedFromAttempt: boolean
/** The workspace already holds a credential this row would connect. */
@@ -117,6 +179,9 @@ export function useOAuthChipConnection({
const [connectedFromWorkspaceChange, setConnectedFromWorkspaceChange] = useState(false)
const onConnectedRef = useRef(onConnected)
const oauthWindowWasAwayRef = useRef(false)
+ const popupRef = useRef<{ window: Window; attemptId: string } | null>(null)
+ /** Attempt this row launched in a popup, outliving the window handle so the success toast survives the watcher clearing it. */
+ const launchedAttemptIdRef = useRef(null)
const workspaceCredentialBaselineRef = useRef<{
scope: string
baseline: ReturnType
@@ -131,23 +196,27 @@ export function useOAuthChipConnection({
credentialTarget,
workspaceOAuthCredentials
)
- const connectedFromAttempt = connectionStatus === 'connected'
- const connected = connectedFromAttempt || connectedFromWorkspaceChange
+ const verdictConnected = connectionStatus === 'connected'
+ const connected = verdictConnected || connectedFromWorkspaceChange
+ // The label trusts the verdict; the lock does not. A swallowed credential-draft
+ // failure lets a flow report success with nothing in the workspace, and locking
+ // on that would strand the row saying "Connected" with no way to retry. A
+ // reconnect produces no new credential, so it has nothing to corroborate against.
+ const connectedFromAttempt =
+ verdictConnected && (reconnectCredentialId ? true : connectedFromWorkspaceChange)
useEffect(() => {
onConnectedRef.current = onConnected
}, [onConnected])
/**
- * A credential for this row can also appear without the row launching it —
- * the integrations page in another tab, or a desktop flow that never comes
- * back through the return URL. Diffing the workspace list against the
- * baseline captured for this scope surfaces that.
+ * A credential can appear without this row launching it — the integrations
+ * page in another tab, or a desktop flow that never returns through the URL.
+ * Diffing the workspace list against this scope's baseline surfaces that.
*
- * This signal is workspace-wide, so it cannot be attributed to one row:
- * sibling chips for the same provider all see the same change. It therefore
- * only ever *shows* the row as satisfied — {@link connectedFromAttempt} is
- * what locks it, so a second same-provider row stays clickable.
+ * Workspace-wide, so it cannot be attributed to one row: it only ever *shows*
+ * the row satisfied. {@link connectedFromAttempt} is what locks it, so a
+ * sibling chip for the same provider stays clickable.
*/
useEffect(() => {
if (!isFetched) return
@@ -185,12 +254,10 @@ export function useOAuthChipConnection({
])
/**
- * This row's attempt: the one named by the return URL when we came back from
- * the provider, else the last one stored for this exact row. The stored
- * lookup is what survives a reload — and what covers the transcript
- * rendering only after the return hook has already stripped the URL param.
- * Both are scoped to the row, so a sibling chip for the same provider can
- * never claim this one's result.
+ * This row's attempt: the one named by the return URL, else the last one
+ * stored for this exact row. The stored lookup survives a reload, and covers
+ * a transcript rendered after the return hook stripped the URL param. Both
+ * are row-scoped, so a sibling chip can never claim this one's result.
*/
const readRowAttempt = useCallback((): OAuthChatAttempt | null => {
const active = activeAttemptId ? readOAuthChatAttempt(activeAttemptId) : null
@@ -211,6 +278,49 @@ export function useOAuthChipConnection({
})
}, [activeAttemptId, controlId, providerId, reconnectCredentialId, workspaceId])
+ /**
+ * Refetches the workspace credentials and settles this row's pending attempt
+ * against them. Shared by the two endings the completion page cannot cover: a
+ * return to this tab, and a popup seen closed or parked on a terminal page.
+ * Idempotent — an attempt is only rewritten while still `pending`.
+ */
+ const settleFromCredentials = useCallback(async () => {
+ // Identity is captured up front so the verdict below can only land on the
+ // attempt this settle was started for. A retry during the refetch installs
+ // a replacement, and resolving that one from a run it never triggered would
+ // fail a connect whose popup is still going.
+ const targetAttemptId = readRowAttempt()?.id
+ const result = await refetchWorkspaceOAuthCredentials()
+ const credentials = result.data ?? []
+
+ // Also covers a credential connected from another tab, which this row never
+ // launched and query state alone would surface only on its next fetch.
+ const storedBaseline = workspaceCredentialBaselineRef.current
+ if (!reconnectCredentialId && storedBaseline?.scope === credentialScope) {
+ setConnectedFromWorkspaceChange(
+ hasOAuthCredentialChanged({ ...credentialTarget, ...storedBaseline.baseline }, credentials)
+ )
+ }
+
+ // Status is re-read after the await: the snapshot above goes stale the
+ // moment the popup publishes its verdict mid-refetch, and that stale
+ // 'pending' would license the very overwrite this guard exists to prevent.
+ const attempt = readRowAttempt()
+ if (!attempt || attempt.id !== targetAttemptId || attempt.status !== 'pending') return
+ // Only the callback path can prove a reconnect returned — an unrelated edit
+ // to the credential is indistinguishable from one here.
+ const attemptConnected = reconnectCredentialId
+ ? false
+ : hasOAuthCredentialChanged(attempt, credentials)
+ setOAuthChatAttemptStatus(attempt.id, attemptConnected ? 'connected' : 'failed')
+ }, [
+ credentialScope,
+ credentialTarget,
+ readRowAttempt,
+ reconnectCredentialId,
+ refetchWorkspaceOAuthCredentials,
+ ])
+
useEffect(() => {
const syncStatus = () => setConnectionStatus(readRowAttempt()?.status ?? null)
window.addEventListener(OAUTH_CHAT_ATTEMPT_EVENT, syncStatus)
@@ -226,38 +336,19 @@ export function useOAuthChipConnection({
const markAway = () => {
oauthWindowWasAwayRef.current = true
}
- const verifyAfterReturn = async () => {
+ const verifyAfterReturn = () => {
if (!oauthWindowWasAwayRef.current || document.visibilityState !== 'visible') return
+ // A live popup still owns the flow; settling now would flash "not
+ // connected" mid-connect. The away flag deliberately survives this bail —
+ // consuming it would spend the only signal a later return has to work
+ // with, and no second focus event is guaranteed to arrive.
+ if (isPopupStillOpen(popupRef.current)) return
oauthWindowWasAwayRef.current = false
- const attempt = readRowAttempt()
- const result = await refetchWorkspaceOAuthCredentials()
- const credentials = result.data ?? []
-
- // Refetching on every return closes the other-tab gap even when this row
- // did not launch the connection. Query state normally drives the effect
- // above; updating here as well makes the result immediate and deterministic.
- const storedBaseline = workspaceCredentialBaselineRef.current
- if (!reconnectCredentialId && storedBaseline?.scope === credentialScope) {
- setConnectedFromWorkspaceChange(
- hasOAuthCredentialChanged(
- { ...credentialTarget, ...storedBaseline.baseline },
- credentials
- )
- )
- }
-
- if (!attempt || attempt.status !== 'pending') return
- // A reconnect is verified by the callback path, which has proof that the
- // OAuth flow returned. A plain focus event cannot distinguish it from an
- // unrelated edit to the same credential.
- const attemptConnected = reconnectCredentialId
- ? false
- : hasOAuthCredentialChanged(attempt, credentials)
- setOAuthChatAttemptStatus(attempt.id, attemptConnected ? 'connected' : 'failed')
+ void settleFromCredentials()
}
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') markAway()
- else void verifyAfterReturn()
+ else verifyAfterReturn()
}
window.addEventListener('blur', markAway)
@@ -268,18 +359,85 @@ export function useOAuthChipConnection({
window.removeEventListener('focus', verifyAfterReturn)
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
- }, [
- credentialScope,
- credentialTarget,
- readRowAttempt,
- reconnectCredentialId,
- refetchWorkspaceOAuthCredentials,
- ])
+ }, [settleFromCredentials])
+
+ /**
+ * Watches a live popup for the endings that publish no verdict. None fire an
+ * event here, and the user need never leave this tab for a focus event
+ * either, so polling is the only way the row learns the flow is over.
+ */
+ useEffect(() => {
+ if (connectionStatus !== 'pending' || !popupRef.current) return
+ const poll = window.setInterval(() => {
+ const observation = observePopup(popupRef.current)
+ if (observation === 'live') return
+ // Stop before settling: the refetch leaves the status `pending` for its
+ // duration, so a later tick could resolve an attempt a retry replaced.
+ window.clearInterval(poll)
+ popupRef.current = null
+ // Only `ended` is proof the flow finished with nothing published. A
+ // closed-or-disowned handle is not — the deadline below bounds that.
+ if (observation === 'ended') void settleFromCredentials()
+ }, OAUTH_POPUP_POLL_INTERVAL_MS)
+ return () => window.clearInterval(poll)
+ }, [connectionStatus, settleFromCredentials])
+
+ /**
+ * Backstop for a pending attempt whose outcome never becomes observable.
+ * Bounds every pending attempt, not just one this mount launched — the
+ * transcript virtualizes, so a row can remount with no window handle at all.
+ * Dated from the attempt's `requestedAt`, so a remount inherits the time
+ * remaining rather than restarting the clock.
+ */
+ useEffect(() => {
+ if (connectionStatus !== 'pending') return
+ const attempt = readRowAttempt()
+ if (!attempt) return
+ const remaining = Math.max(
+ 0,
+ OAUTH_POPUP_UNOBSERVABLE_TIMEOUT_MS - (Date.now() - attempt.requestedAt)
+ )
+ let deadline: number
+ const settleUnlessPopupOwnsIt = () => {
+ // A popup still demonstrably running owns the flow, so the deadline waits
+ // rather than expiring: giving up here would spend the bound on a live
+ // window that can still die unobservably long afterwards.
+ if (isPopupStillOpen(popupRef.current)) {
+ deadline = window.setTimeout(settleUnlessPopupOwnsIt, OAUTH_POPUP_POLL_INTERVAL_MS)
+ return
+ }
+ void settleFromCredentials()
+ }
+ deadline = window.setTimeout(settleUnlessPopupOwnsIt, remaining)
+ return () => window.clearTimeout(deadline)
+ }, [connectionStatus, readRowAttempt, settleFromCredentials])
useEffect(() => {
if (connected) onConnectedRef.current?.()
}, [connected])
+ /**
+ * The popup publishes the verdict and closes, showing the user nothing they
+ * keep, so the launching tab surfaces the success toast. Failure
+ * deliberately stays a label (the retry text): a focus flip mid-flow can
+ * settle 'failed' prematurely while the popup is still open, and a toast on
+ * that would read as the flow failing behind the user's back.
+ */
+ useEffect(() => {
+ const launchedAttemptId = launchedAttemptIdRef.current
+ if (connectionStatus !== 'connected' || !launchedAttemptId) return
+ const attempt = readOAuthChatAttempt(launchedAttemptId)
+ launchedAttemptIdRef.current = null
+ popupRef.current = null
+ // The verdict settles the label, but the lock also waits on the credential
+ // being observable. A popup flow never blurs this tab, so nothing else
+ // refetches here and the row would read "Connected" yet stay clickable.
+ void settleFromCredentials()
+ if (attempt?.status === 'connected') {
+ toast.success(`${attempt.displayName} connected successfully.`)
+ }
+ }, [connectionStatus, settleFromCredentials])
+
/**
* Desktop app: OAuth cannot run in an embedded window — not in the app
* window (better-auth binds the flow's state to the initiating browser's
@@ -295,6 +453,20 @@ export function useOAuthChipConnection({
event.preventDefault()
return
}
+ // A second click would replace both the active attempt and the window
+ // handle, orphaning the first flow — its verdict lands on an attempt id
+ // the row no longer reads, so a successful connect waits forever. The
+ // live window is the flow; surface it instead of starting a rival one.
+ const livePopup = popupRef.current
+ if (isPopupStillOpen(livePopup)) {
+ event.preventDefault()
+ try {
+ livePopup?.window.focus?.()
+ } catch {
+ // A COOP-severed handle refuses focus, but still owns the flow.
+ }
+ return
+ }
const attempt = createOAuthChatAttempt({
workspaceId,
providerId,
@@ -327,7 +499,33 @@ export function useOAuthChipConnection({
return
}
- event.currentTarget.href = addOAuthChatAttemptToAuthorizeUrl(connectUrl, attempt.id)
+ // Web: run the whole flow in a popup so this tab never navigates — the
+ // return leg lands on the self-closing chat-complete page, whose verdict
+ // reaches this row over the storage listener. A blocked popup navigates
+ // to the same URL instead, so the flow keeps the completion page's
+ // server-backed verdict either way; only an authorize URL with no return
+ // param to rewrite falls all the way back to the plain tab return.
+ const completeUrl = buildOAuthChatCompleteAuthorizeUrl(connectUrl, attempt.id)
+ if (completeUrl) {
+ // Named per attempt: a card can render several credential rows, and a
+ // shared name would let the second click renavigate the first row's
+ // live popup, stranding that attempt with no return leg.
+ const popup = window.open(
+ completeUrl,
+ `${OAUTH_POPUP_WINDOW_NAME}-${attempt.id}`,
+ OAUTH_POPUP_FEATURES
+ )
+ if (popup) {
+ event.preventDefault()
+ popup.focus?.()
+ popupRef.current = { window: popup, attemptId: attempt.id }
+ launchedAttemptIdRef.current = attempt.id
+ return
+ }
+ }
+
+ event.currentTarget.href =
+ completeUrl ?? addOAuthChatAttemptToAuthorizeUrl(connectUrl, attempt.id)
},
[
baseProviderId,
diff --git a/apps/sim/lib/credentials/oauth-chat-attempt.test.ts b/apps/sim/lib/credentials/oauth-chat-attempt.test.ts
index 3a20b017316..99ae6e78bb1 100644
--- a/apps/sim/lib/credentials/oauth-chat-attempt.test.ts
+++ b/apps/sim/lib/credentials/oauth-chat-attempt.test.ts
@@ -1,15 +1,19 @@
/**
* @vitest-environment jsdom
+ * @vitest-environment-options { "url": "https://sim.test/workspace/workspace-1/chat/chat-1" }
*/
import { beforeEach, describe, expect, it } from 'vitest'
import {
addOAuthChatAttemptToAuthorizeUrl,
+ buildOAuthChatCompleteAuthorizeUrl,
createOAuthChatAttempt,
getOAuthCredentialBaseline,
hasOAuthCredentialChanged,
OAUTH_CHAT_ATTEMPT_EVENT,
OAUTH_CHAT_ATTEMPT_PARAM,
+ OAUTH_CHAT_COMPLETE_PATH,
+ OAUTH_CHAT_RETURN_TO_PARAM,
readLatestOAuthChatAttempt,
readOAuthChatAttempt,
resolveActiveDesktopOAuthChatAttempt,
@@ -47,6 +51,64 @@ describe('OAuth chat attempts', () => {
}
)
+ it('routes the return through the chat-complete page', () => {
+ const authorizeUrl = buildOAuthChatCompleteAuthorizeUrl(
+ 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1',
+ 'attempt-1'
+ )
+
+ const callbackUrl = new URL(new URL(authorizeUrl ?? '').searchParams.get('callbackURL') ?? '')
+ expect(callbackUrl.pathname).toBe(OAUTH_CHAT_COMPLETE_PATH)
+ expect(callbackUrl.searchParams.get(OAUTH_CHAT_ATTEMPT_PARAM)).toBe('attempt-1')
+ // Anchored on the server-generated return URL's origin, not this tab's —
+ // a proxied deployment's two origins need not agree.
+ expect(callbackUrl.origin).toBe('https://sim.test')
+ })
+
+ it('refuses a cross-origin authorize URL so the attempt id never leaves the app', () => {
+ expect(
+ buildOAuthChatCompleteAuthorizeUrl(
+ 'https://evil.example/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fevil.example%2Fsink',
+ 'attempt-1'
+ )
+ ).toBeNull()
+ })
+
+ it('leaves the attempt off the close-fallback target so it is not re-decided', () => {
+ const authorizeUrl = buildOAuthChatCompleteAuthorizeUrl(
+ 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&callbackURL=https%3A%2F%2Fsim.test%2Fworkspace%2Fworkspace-1%2Fchat%2Fchat-1',
+ 'attempt-1'
+ )
+
+ const callbackUrl = new URL(new URL(authorizeUrl ?? '').searchParams.get('callbackURL') ?? '')
+ const returnTo = new URL(callbackUrl.searchParams.get(OAUTH_CHAT_RETURN_TO_PARAM) ?? '')
+ expect(returnTo.pathname).toBe('/workspace/workspace-1/chat/chat-1')
+ expect(returnTo.searchParams.get(OAUTH_CHAT_ATTEMPT_PARAM)).toBeNull()
+ })
+
+ it.each(['instagram', 'shopify', 'trello'])(
+ 'routes the %s return through the chat-complete page',
+ (provider) => {
+ const authorizeUrl = buildOAuthChatCompleteAuthorizeUrl(
+ `https://sim.test/api/auth/${provider}/authorize?returnUrl=${encodeURIComponent('https://sim.test/workspace/workspace-1/chat/chat-1')}`,
+ 'attempt-2'
+ )
+
+ const returnUrl = new URL(new URL(authorizeUrl ?? '').searchParams.get('returnUrl') ?? '')
+ expect(returnUrl.pathname).toBe(OAUTH_CHAT_COMPLETE_PATH)
+ expect(returnUrl.searchParams.get(OAUTH_CHAT_ATTEMPT_PARAM)).toBe('attempt-2')
+ }
+ )
+
+ it('declines a chat-complete URL when there is no return param to rewrite', () => {
+ expect(
+ buildOAuthChatCompleteAuthorizeUrl(
+ 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email',
+ 'attempt-3'
+ )
+ ).toBeNull()
+ })
+
it('publishes and persists server-verified completion', () => {
let publishedStatus: string | undefined
window.addEventListener(
diff --git a/apps/sim/lib/credentials/oauth-chat-attempt.ts b/apps/sim/lib/credentials/oauth-chat-attempt.ts
index 65ad90a3a31..0a6fd6bed3e 100644
--- a/apps/sim/lib/credentials/oauth-chat-attempt.ts
+++ b/apps/sim/lib/credentials/oauth-chat-attempt.ts
@@ -4,6 +4,8 @@ import { generateShortId } from '@sim/utils/id'
export const OAUTH_CHAT_ATTEMPT_PARAM = 'oauthAttempt'
export const OAUTH_CHAT_ATTEMPT_EVENT = 'sim:oauth-chat-attempt'
+export const OAUTH_CHAT_COMPLETE_PATH = '/oauth/chat-complete'
+export const OAUTH_CHAT_RETURN_TO_PARAM = 'returnTo'
const OAUTH_CHAT_ATTEMPT_KEY_PREFIX = 'sim.oauth-chat-attempt.'
const OAUTH_CHAT_LATEST_KEY_PREFIX = 'sim.oauth-chat-latest.'
@@ -143,8 +145,13 @@ function isOAuthChatAttempt(value: unknown): value is OAuthChatAttempt {
function writeOAuthChatAttempt(attempt: OAuthChatAttempt): void {
if (typeof window === 'undefined') return
- window.localStorage.setItem(attemptStorageKey(attempt.id), JSON.stringify(attempt))
- window.localStorage.setItem(latestAttemptStorageKey(attempt), attempt.id)
+ // A blocked or full store must not throw into the caller: the chat-complete
+ // page writes the verdict before closing its window, so an unguarded throw
+ // would strand the popup open instead of losing only the verdict.
+ try {
+ window.localStorage.setItem(attemptStorageKey(attempt.id), JSON.stringify(attempt))
+ window.localStorage.setItem(latestAttemptStorageKey(attempt), attempt.id)
+ } catch {}
window.dispatchEvent(
new CustomEvent(OAUTH_CHAT_ATTEMPT_EVENT, { detail: attempt })
)
@@ -244,11 +251,31 @@ function appendAttemptToReturnUrl(rawReturnUrl: string, attemptId: string): stri
return returnUrl.toString()
}
-/** Adds the attempt id to the eventual same-origin OAuth return URL. */
-export function addOAuthChatAttemptToAuthorizeUrl(rawUrl: string, attemptId: string): string {
+interface AuthorizeReturnTarget {
+ authorizeUrl: URL
+ /** Which param this provider's authorize route reads the return URL from. */
+ returnParam: 'callbackURL' | 'returnUrl'
+ rawReturnUrl: string | null
+}
+
+/**
+ * Locates the return URL an authorize link will come back through. Shared so
+ * both builders below agree on which param carries it — a provider added to
+ * one and missed in the other would break only the path its caller uses.
+ */
+function resolveAuthorizeReturnTarget(rawUrl: string): AuthorizeReturnTarget {
const authorizeUrl = new URL(rawUrl, window.location.origin)
const returnParam = authorizeUrl.searchParams.has('callbackURL') ? 'callbackURL' : 'returnUrl'
- const rawReturnUrl = authorizeUrl.searchParams.get(returnParam)
+ return {
+ authorizeUrl,
+ returnParam,
+ rawReturnUrl: authorizeUrl.searchParams.get(returnParam),
+ }
+}
+
+/** Adds the attempt id to the eventual same-origin OAuth return URL. */
+export function addOAuthChatAttemptToAuthorizeUrl(rawUrl: string, attemptId: string): string {
+ const { authorizeUrl, returnParam, rawReturnUrl } = resolveAuthorizeReturnTarget(rawUrl)
if (rawReturnUrl) {
authorizeUrl.searchParams.set(returnParam, appendAttemptToReturnUrl(rawReturnUrl, attemptId))
@@ -258,3 +285,43 @@ export function addOAuthChatAttemptToAuthorizeUrl(rawUrl: string, attemptId: str
return authorizeUrl.toString()
}
+
+/**
+ * Chat-flavored authorize URL: the return leg lands on the lightweight
+ * chat-complete page — which publishes the verdict and closes the window —
+ * instead of reloading the whole app in the OAuth window.
+ *
+ * The complete page's fallback redirect target is the plain return URL with no
+ * attempt id, so a window that cannot close lands on the chat surface without
+ * its return router re-deciding a verdict the completion page already
+ * published.
+ *
+ * Returns null when the authorize URL is not a same-origin Sim route, or
+ * carries no return param to rewrite; the caller falls back to
+ * {@link addOAuthChatAttemptToAuthorizeUrl} and its plain anchor navigation.
+ */
+export function buildOAuthChatCompleteAuthorizeUrl(
+ rawUrl: string,
+ attemptId: string
+): string | null {
+ const { authorizeUrl, returnParam, rawReturnUrl } = resolveAuthorizeReturnTarget(rawUrl)
+ // The connect URL is streamed model output, checked only for a safe protocol.
+ // Refusing a foreign origin here keeps the attempt id out of a URL we do not
+ // control, and keeps the caller on its anchor — whose rel='noopener' the
+ // popup path would otherwise drop, handing a hostile page our window handle.
+ if (authorizeUrl.origin !== window.location.origin) return null
+ if (!rawReturnUrl) return null
+
+ // Anchored on the return URL the server generated, not this tab's origin —
+ // both the authorize route and the custom-provider callbacks accept a return
+ // target only when it matches the deployment's configured base URL, which a
+ // proxied or aliased origin need not equal.
+ const completeUrl = new URL(
+ OAUTH_CHAT_COMPLETE_PATH,
+ new URL(rawReturnUrl, window.location.origin).origin
+ )
+ completeUrl.searchParams.set(OAUTH_CHAT_ATTEMPT_PARAM, attemptId)
+ completeUrl.searchParams.set(OAUTH_CHAT_RETURN_TO_PARAM, rawReturnUrl)
+ authorizeUrl.searchParams.set(returnParam, completeUrl.toString())
+ return authorizeUrl.toString()
+}
diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts
index 172bf16d220..8fdc0915632 100644
--- a/apps/sim/next.config.ts
+++ b/apps/sim/next.config.ts
@@ -318,8 +318,12 @@ const nextConfig: NextConfig = {
},
{
// Exclude Vercel internal resources and static assets from strict COOP, Google Drive Picker
- // and the /demo Cal.com booking embed to prevent 'refused to connect' / slow-load issues
- source: '/((?!_next|_vercel|api|favicon.ico|w/.*|workspace/.*|api/tools/drive|demo).*)',
+ // and the /demo Cal.com booking embed to prevent 'refused to connect' / slow-load issues.
+ // The pages an OAuth popup can land on are excluded too: `same-origin` would disown the
+ // popup from its opener, leaving it not reliably script-closable and reporting `closed`
+ // for a live window.
+ source:
+ '/((?!_next|_vercel|api|favicon.ico|w/.*|workspace|api/tools/drive|demo|oauth-error|oauth/chat-complete).*)',
headers: [
{
key: 'Cross-Origin-Opener-Policy',
@@ -342,8 +346,11 @@ const nextConfig: NextConfig = {
],
},
{
- // For main app routes, Google Drive Picker, the /demo Cal.com embed, and Vercel resources - use permissive policies
- source: '/(w/.*|workspace/.*|api/tools/drive|demo.*|_next/.*|_vercel/.*)',
+ // For main app routes, Google Drive Picker, the /demo Cal.com embed, the OAuth popup pages,
+ // and Vercel resources - use permissive policies. The popup pages match their opener's
+ // value so the two stay in one browsing-context group.
+ source:
+ '/(w/.*|workspace.*|api/tools/drive|demo.*|oauth-error|oauth/chat-complete|_next/.*|_vercel/.*)',
headers: [
{
key: 'Cross-Origin-Embedder-Policy',