Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions apps/sim/app/oauth/chat-complete/chat-complete-handoff.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ChatCompleteHandoff />))
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(<ChatCompleteHandoff />))
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())
})
})
})
66 changes: 66 additions & 0 deletions apps/sim/app/oauth/chat-complete/chat-complete-handoff.tsx
Original file line number Diff line number Diff line change
@@ -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
}
28 changes: 28 additions & 0 deletions apps/sim/app/oauth/chat-complete/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<ChatCompleteHandoff />
<DesktopHandoffShell title='Finishing the connection' description='Returning you to Sim.' />
</>
)
}
Loading
Loading