Skip to content
Closed
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
16 changes: 16 additions & 0 deletions apps/sim/app/(auth)/signup/complete/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Metadata } from 'next'
import { Suspense } from 'react'
import { isHosted } from '@/lib/core/config/env-flags'
import { SocialSignUpCompletion } from '@/app/(auth)/signup/complete/social-sign-up-completion'

export const metadata: Metadata = {
title: 'Finishing Sign Up',
}

export default function SocialSignUpCompletePage() {
return (
<Suspense fallback={null}>
<SocialSignUpCompletion consentRequired={isHosted} />
</Suspense>
)
}
62 changes: 62 additions & 0 deletions apps/sim/app/(auth)/signup/complete/social-sign-up-completion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use client'

import { useEffect, useRef } from 'react'
import { useSearchParams } from 'next/navigation'
import { useSession } from '@/lib/auth/client'
import type { SocialSignInProvider } from '@/lib/auth/social-sign-in'
import { trackSignUpCompletionConversions } from '@/lib/auth/social-sign-up-conversion'
import { useTrackingConsent } from '@/lib/consent/tracking-consent'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
import { DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect'

interface SocialSignUpCompletionProps {
consentRequired: boolean
}

function isSignUpConversionMethod(value: string | null): value is SocialSignInProvider | 'email' {
return value === 'email' || value === 'github' || value === 'google' || value === 'microsoft'
}

export function SocialSignUpCompletion({ consentRequired }: SocialSignUpCompletionProps) {
const redirectStartedRef = useRef(false)
const searchParams = useSearchParams()
Comment thread
waleedlatif1 marked this conversation as resolved.
const { data: session, isPending } = useSession()
const { isResolved, measurement, marketing } = useTrackingConsent()
const callbackURL = searchParams.get('callbackURL')
const destination =
callbackURL && validateCallbackUrl(callbackURL) ? callbackURL : DEFAULT_POST_AUTH_ROUTE
const marker = searchParams.get('conversion')
const provider = searchParams.get('provider')

useEffect(() => {
if (redirectStartedRef.current || isPending || (consentRequired && !isResolved)) return
redirectStartedRef.current = true

const finishSignUp = async () => {
if (session?.user && isSignUpConversionMethod(provider)) {
await trackSignUpCompletionConversions({
marker,
method: provider,
userId: session.user.id,
measurement: consentRequired && measurement,
marketing: consentRequired && marketing,
})
}
window.location.href = destination
}
Comment on lines +35 to +46

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tracking can block signup

SocialSignUpCompletion makes the user's redirect depend on outside tracking code. If a vendor function throws, window.location.href never runs, and redirectStartedRef prevents another attempt. Put the redirect in a finally block so a tracking error cannot leave a signed-up user on “Finishing signup…”.

Suggested change
const finishSignUp = async () => {
if (session?.user && isSocialSignInProvider(provider)) {
await trackSocialSignUpConversions({
marker,
provider,
userId: session.user.id,
measurement: consentRequired && measurement,
marketing: consentRequired && marketing,
})
}
window.location.href = destination
}
const finishSignUp = async () => {
try {
if (session?.user && isSocialSignInProvider(provider)) {
await trackSocialSignUpConversions({
marker,
provider,
userId: session.user.id,
measurement: consentRequired && measurement,
marketing: consentRequired && marketing,
})
}
} finally {
window.location.href = destination
}
}

Comment on lines +37 to +46

@cubic-dev-ai cubic-dev-ai Bot Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Put the redirect in a finally block so a tracking error cannot leave a signed-up user stuck on “Finishing signup…”.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/app/(auth)/signup/complete/social-sign-up-completion.tsx, line 37:

<comment>Put the redirect in a `finally` block so a tracking error cannot leave a signed-up user stuck on “Finishing signup…”.</comment>

<file context>
@@ -0,0 +1,62 @@
+
+    const finishSignUp = async () => {
+      if (session?.user && isSignUpConversionMethod(provider)) {
+        await trackSignUpCompletionConversions({
+          marker,
+          method: provider,
</file context>
Suggested change
await trackSignUpCompletionConversions({
marker,
method: provider,
userId: session.user.id,
measurement: consentRequired && measurement,
marketing: consentRequired && marketing,
})
}
window.location.href = destination
}
try {
if (session?.user && isSignUpConversionMethod(provider)) {
await trackSignUpCompletionConversions({
marker,
method: provider,
userId: session.user.id,
measurement: consentRequired && measurement,
marketing: consentRequired && marketing,
})
}
} finally {
window.location.href = destination
}
Fix with cubic


void finishSignUp()
}, [
consentRequired,
destination,
isPending,
isResolved,
marker,
measurement,
marketing,
provider,
session,
])

return <p className='text-center text-[var(--text-secondary)] text-sm'>Finishing signup…</p>
}
16 changes: 16 additions & 0 deletions apps/sim/app/(auth)/signup/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { ReactNode } from 'react'
import { isHosted } from '@/lib/core/config/env-flags'
import { SignupConsentTracking } from '@/app/(auth)/signup/signup-consent-tracking'

interface SignupLayoutProps {
children: ReactNode
}

export default function SignupLayout({ children }: SignupLayoutProps) {
return (
<>
{children}
{isHosted && <SignupConsentTracking />}
</>
)
}
9 changes: 9 additions & 0 deletions apps/sim/app/(auth)/signup/signup-consent-tracking.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'use client'

import { useConsentScript } from '@c15t/nextjs/headless'
import { X_PIXEL_SCRIPT } from '@/lib/consent/scripts'

export function SignupConsentTracking() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Required props interfaces are missing

SignupConsentTracking has no props interface. The component guide requires one for every component. Add an explicit props interface here and to the new SocialSignUpCompletePage, which has the same omission. This repository requirement must be satisfied before merging.

Context Used: Component patterns and structure for React components (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

useConsentScript({ script: X_PIXEL_SCRIPT, unmountBehavior: 'keep' })
return null
}
26 changes: 19 additions & 7 deletions apps/sim/app/(auth)/signup/signup-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
import { createLogger } from '@sim/logger'
import { useRouter, useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { trackFreebuffConversion } from '@/lib/analytics/freebuff'
import { trackGoogleEvent } from '@/lib/analytics/google'
import { trackSignUpConversions } from '@/lib/analytics/sign-up'
import { client, useSession } from '@/lib/auth/client'
import { createEmailSignUpCallbackURL } from '@/lib/auth/social-sign-up-conversion'
import { useTrackingConsent } from '@/lib/consent/tracking-consent'
import { getEnv, isFalsy } from '@/lib/core/config/env'
import { isSsoEnabled } from '@/lib/core/config/env-flags'
Expand Down Expand Up @@ -347,8 +347,18 @@ function SignupFormContent({
return
}

if (measurement) trackGoogleEvent('sign_up', { method: 'email' })
if (marketing) trackFreebuffConversion('signup_completed', response.data.user.id)
const destination = resolvePostSignupDestination({ emailVerificationEnabled, redirectUrl })

// Verification-enabled duplicate signups return synthetic success, so those conversions
// wait until the OTP-created session matches the user ID returned here.
if (!emailVerificationEnabled) {
await trackSignUpConversions({
method: 'email',
userId: response.data.user.id,
measurement,
marketing,
})
}

try {
await refetchSession()
Expand All @@ -357,17 +367,19 @@ function SignupFormContent({
logger.error('Failed to refresh session after signup:', sessionError)
}

const destination = resolvePostSignupDestination({ emailVerificationEnabled, redirectUrl })

if (typeof window !== 'undefined') {
// Clear any leftover from an earlier signup in this tab — otherwise a
// signup with no callbackUrl inherits the previous CLI/invite destination.
sessionStorage.removeItem('verificationEmail')
sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY)

if (destination.kind === 'verify') {
const postVerificationDestination = redirectUrl || DEFAULT_POST_AUTH_ROUTE
sessionStorage.setItem('verificationEmail', emailValue)
if (redirectUrl) sessionStorage.setItem(POST_AUTH_REDIRECT_STORAGE_KEY, redirectUrl)
sessionStorage.setItem(
POST_AUTH_REDIRECT_STORAGE_KEY,
createEmailSignUpCallbackURL(response.data.user.id, postVerificationDestination)
)
}
}

Expand Down
43 changes: 43 additions & 0 deletions apps/sim/lib/analytics/sign-up.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* @vitest-environment jsdom
* @vitest-environment-options {"url":"https://www.sim.ai/signup"}
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { trackSignUpConversions } from '@/lib/analytics/sign-up'

afterEach(() => {
Reflect.deleteProperty(window, 'twq')
Reflect.deleteProperty(window, 'freebuff')
Reflect.deleteProperty(window, 'gtag')
})

describe('email signup conversions', () => {
it('reports the X signup event when marketing consent is granted', async () => {
const trackXEvent = vi.fn()
window.twq = trackXEvent

await trackSignUpConversions({
method: 'email',
userId: 'user-1',
measurement: false,
marketing: true,
})

expect(trackXEvent).toHaveBeenCalledOnce()
expect(trackXEvent).toHaveBeenCalledWith('event', 'tw-q5xbl-rhis8', {})
})

it('does not report the X signup event without marketing consent', async () => {
const trackXEvent = vi.fn()
window.twq = trackXEvent

await trackSignUpConversions({
method: 'email',
userId: 'user-1',
measurement: true,
marketing: false,
})

expect(trackXEvent).not.toHaveBeenCalled()
})
})
38 changes: 38 additions & 0 deletions apps/sim/lib/analytics/sign-up.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { sleep } from '@sim/utils/helpers'
import { trackFreebuffConversion } from '@/lib/analytics/freebuff'
import { trackGoogleEvent } from '@/lib/analytics/google'
import { X_SIGN_UP_EVENT_ID } from '@/lib/consent/scripts'

const X_PIXEL_STUB_WAIT_INTERVAL_MS = 50
const X_PIXEL_STUB_WAIT_ATTEMPTS = 20

interface TrackSignUpConversionsOptions {
method: string
userId: string
measurement: boolean
marketing: boolean
}

async function trackXSignUpConversion(): Promise<void> {
for (let attempt = 0; attempt < X_PIXEL_STUB_WAIT_ATTEMPTS; attempt += 1) {
if (typeof window.twq === 'function') {
window.twq('event', X_SIGN_UP_EVENT_ID, {})
return
}
await sleep(X_PIXEL_STUB_WAIT_INTERVAL_MS)
}
}

/** Reports a completed signup only to vendors allowed by the settled consent state. */
export async function trackSignUpConversions({
Comment thread
waleedlatif1 marked this conversation as resolved.
method,
userId,
measurement,
marketing,
}: TrackSignUpConversionsOptions): Promise<void> {
if (measurement) trackGoogleEvent('sign_up', { method })
if (!marketing) return

trackFreebuffConversion('signup_completed', userId)
await trackXSignUpConversion()
}
7 changes: 6 additions & 1 deletion apps/sim/lib/auth/social-sign-in.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { client } from '@/lib/auth/client'
import { createSocialSignUpCallbackURL } from '@/lib/auth/social-sign-up-conversion'
import { captureClientEvent } from '@/lib/posthog/client'
import type { PostHogEventMap } from '@/lib/posthog/events'

Expand All @@ -25,5 +26,9 @@ export function startSocialSignIn({
callbackURL,
}: StartSocialSignInOptions) {
captureClientEvent('external_sign_in_started', { provider, view, surface })
return client.signIn.social({ provider, callbackURL })
return client.signIn.social({
provider,
callbackURL,
newUserCallbackURL: createSocialSignUpCallbackURL(provider, callbackURL),
})
}
93 changes: 93 additions & 0 deletions apps/sim/lib/auth/social-sign-up-conversion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* @vitest-environment jsdom
* @vitest-environment-options {"url":"https://www.sim.ai/signup"}
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createEmailSignUpCallbackURL,
createSocialSignUpCallbackURL,
trackSignUpCompletionConversions,
} from '@/lib/auth/social-sign-up-conversion'

afterEach(() => {
sessionStorage.clear()
Reflect.deleteProperty(window, 'twq')
Reflect.deleteProperty(window, 'freebuff')
Reflect.deleteProperty(window, 'gtag')
})

function getConversionMarker(callbackURL: string): string {
return new URL(callbackURL, window.location.origin).searchParams.get('conversion') ?? ''
}

describe('signup completion conversions', () => {
it('reports a new social user once', async () => {
vi.stubEnv('NODE_ENV', 'production')
const trackXEvent = vi.fn()
const trackGoogleEvent = vi.fn()
const trackFreebuffEvent = vi.fn()
window.twq = trackXEvent
window.gtag = trackGoogleEvent
window.freebuff = trackFreebuffEvent
Comment on lines +26 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tests skip the signup journey

The social signup test replaces every vendor function with a mock and checks their calls. CLAUDE.md explicitly forbids tests that mock every collaborator and assert toHaveBeenCalledWith. Replace this with a test through the real signup boundary that checks consent, one-time reporting, and arrival at the requested page. This repository requirement must be satisfied before merging.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

const marker = getConversionMarker(createSocialSignUpCallbackURL('google', '/workspace'))

const firstReport = await trackSignUpCompletionConversions({
marker,
method: 'google',
userId: 'user-1',
measurement: true,
marketing: true,
})
const duplicateReport = await trackSignUpCompletionConversions({
marker,
method: 'google',
userId: 'user-1',
measurement: true,
marketing: true,
})

expect(firstReport).toBe(true)
expect(duplicateReport).toBe(false)
expect(trackXEvent).toHaveBeenCalledOnce()
expect(trackGoogleEvent).toHaveBeenCalledWith(
'event',
'sign_up',
expect.objectContaining({ method: 'google' })
)
expect(trackFreebuffEvent).toHaveBeenCalledOnce()
})

it('does not report a returning social login that lacks the new-user callback marker', async () => {
const trackXEvent = vi.fn()
window.twq = trackXEvent
createSocialSignUpCallbackURL('github', '/workspace')

const reported = await trackSignUpCompletionConversions({
marker: 'not-the-pending-new-user-marker',
method: 'github',
userId: 'user-1',
measurement: true,
marketing: true,
})

expect(reported).toBe(false)
expect(trackXEvent).not.toHaveBeenCalled()
})

it('does not report a duplicate email signup with a synthetic user ID', async () => {
const trackXEvent = vi.fn()
window.twq = trackXEvent
const marker = getConversionMarker(createEmailSignUpCallbackURL('synthetic-user', '/workspace'))

const reported = await trackSignUpCompletionConversions({
marker,
method: 'email',
userId: 'existing-user',
measurement: true,
marketing: true,
})

expect(reported).toBe(false)
expect(trackXEvent).not.toHaveBeenCalled()
})
})
Loading
Loading