Skip to content
Draft
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
18 changes: 17 additions & 1 deletion src/platform/cloud/onboarding/CloudLoginView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -96,31 +96,47 @@

<script setup lang="ts">
import Message from 'primevue/message'
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'

import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { hasDesktopLoginRequest } from '@/platform/cloud/onboarding/desktopLoginBridge'
import CloudSignInForm from '@/platform/cloud/onboarding/components/CloudSignInForm.vue'
import { usePostAuthRedirect } from '@/platform/cloud/onboarding/composables/usePostAuthRedirect'
import type { SignInData } from '@/schemas/signInSchema'
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
import { useAuthStore } from '@/stores/authStore'

const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authActions = useAuthActions()
const authStore = useAuthStore()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const showEmailForm = ref(false)
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
let desktopLoginCompletionStarted = false
const { onAuthSuccess } = usePostAuthRedirect({
authError,
successSummary: 'Login Completed',
defaultRedirect: () => ({ name: 'cloud-user-check' })
})

watch(
() => [authStore.isInitialized, authStore.currentUser] as const,
([isInitialized, user]) => {
if (!isInitialized || !user) return
if (!hasDesktopLoginRequest(route.query)) return
if (desktopLoginCompletionStarted) return
desktopLoginCompletionStarted = true
void onAuthSuccess()
},
{ immediate: true }
)

function switchToEmailForm() {
showEmailForm.value = true
}
Expand Down
22 changes: 22 additions & 0 deletions src/platform/cloud/onboarding/composables/usePostAuthRedirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import type { RouteLocationRaw } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'

import { useOAuthPostLoginRedirect } from '@/platform/cloud/oauth/useOAuthPostLoginRedirect'
import { completeDesktopLoginIfNeeded } from '@/platform/cloud/onboarding/desktopLoginBridge'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useAuthStore } from '@/stores/authStore'

/**
* Shared post-authentication redirect logic used by both CloudLoginView and
Expand All @@ -22,8 +24,28 @@ export function usePostAuthRedirect(options: {
const route = useRoute()
const toastStore = useToastStore()
const { resumeOAuthIfNeeded } = useOAuthPostLoginRedirect()
const authStore = useAuthStore()

async function onAuthSuccess() {
try {
const desktopLoginCompleted = await completeDesktopLoginIfNeeded(
route.query,
authStore.currentUser
)
if (desktopLoginCompleted) return
} catch (error) {
const message =
error instanceof Error ? error.message : 'Desktop login callback failed'
options.authError.value = message
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: message,
life: 4000
})
return
}

toastStore.add({
severity: 'success',
summary: options.successSummary,
Expand Down
80 changes: 80 additions & 0 deletions src/platform/cloud/onboarding/desktopLoginBridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

import {
completeDesktopLoginIfNeeded,
getDesktopLoginRequest,
hasDesktopLoginRequest
} from './desktopLoginBridge'

const hoisted = vi.hoisted(() => ({
identifyPostHogUser: vi.fn()
}))

vi.mock('@/platform/telemetry/providers/cloud/posthogIdentity', () => ({
identifyPostHogUser: hoisted.identifyPostHogUser
}))

vi.mock('@/config/firebase', () => ({
getFirebaseConfig: () => ({ apiKey: 'firebase-api-key' })
}))

describe('desktopLoginBridge', () => {
beforeEach(() => {
hoisted.identifyPostHogUser.mockClear()
vi.unstubAllGlobals()
})

it('accepts localhost callback requests with state', () => {
const query = {
desktop_login_callback: 'http://localhost:9876/callback',
desktop_login_state: 'state-123'
}

expect(hasDesktopLoginRequest(query)).toBe(true)
expect(getDesktopLoginRequest(query)).toMatchObject({
callbackUrl: new URL('http://localhost:9876/callback'),
state: 'state-123'
})
})

it('rejects non-loopback callback requests', () => {
expect(
hasDesktopLoginRequest({
desktop_login_callback: 'https://evil.example/callback',
desktop_login_state: 'state-123'
})
).toBe(false)
})

it('identifies the browser PostHog user and posts Firebase user payload to Desktop', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 204 })
vi.stubGlobal('fetch', fetchMock)

const completed = await completeDesktopLoginIfNeeded(
{
desktop_login_callback: 'http://localhost:9876/callback',
desktop_login_state: 'state-123'
},
{
uid: 'user-123',
toJSON: () => ({ uid: 'user-123', email: 'person@example.com' })
} as never
)

expect(completed).toBe(true)
expect(hoisted.identifyPostHogUser).toHaveBeenCalledWith('user-123')
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:9876/callback',
expect.objectContaining({
method: 'POST',
mode: 'cors',
credentials: 'omit',
body: JSON.stringify({
state: 'state-123',
apiKey: 'firebase-api-key',
user: { uid: 'user-123', email: 'person@example.com' }
})
})
)
})
})
84 changes: 84 additions & 0 deletions src/platform/cloud/onboarding/desktopLoginBridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type { User } from 'firebase/auth'
import type { LocationQuery } from 'vue-router'

import { getFirebaseConfig } from '@/config/firebase'
import { identifyPostHogUser } from '@/platform/telemetry/providers/cloud/posthogIdentity'

const CALLBACK_PARAM = 'desktop_login_callback'
const STATE_PARAM = 'desktop_login_state'
const MAX_STATE_LENGTH = 256

function firstQueryValue(value: LocationQuery[string]): string | null {
if (Array.isArray(value)) return value[0] ?? null
return value ?? null
}

function parseLoopbackCallback(rawUrl: string): URL | null {
let url: URL
try {
url = new URL(rawUrl)
} catch {
return null
}

if (url.protocol !== 'http:') return null
if (!['localhost', '127.0.0.1'].includes(url.hostname)) return null
if (url.pathname !== '/callback') return null

return url
}

export function hasDesktopLoginRequest(query: LocationQuery): boolean {
return Boolean(getDesktopLoginRequest(query))
}

export function getDesktopLoginRequest(query: LocationQuery): {
callbackUrl: URL
state: string
} | null {
const callback = firstQueryValue(query[CALLBACK_PARAM])
const state = firstQueryValue(query[STATE_PARAM])
if (!callback || !state || state.length > MAX_STATE_LENGTH) return null

const callbackUrl = parseLoopbackCallback(callback)
if (!callbackUrl) return null

return { callbackUrl, state }
}

export async function completeDesktopLoginIfNeeded(
query: LocationQuery,
user: User | null | undefined
): Promise<boolean> {
const request = getDesktopLoginRequest(query)
if (!request || !user) return false

// Queue-safe: if PostHog has not finished initializing yet, the cloud
// provider flushes this identify call once the browser cookie store is ready.
identifyPostHogUser(user.uid)

const firebaseConfig = getFirebaseConfig()
if (!firebaseConfig.apiKey) {
throw new Error('Firebase API key missing')
}

const response = await fetch(request.callbackUrl.href, {
method: 'POST',
mode: 'cors',
credentials: 'omit',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
state: request.state,
apiKey: firebaseConfig.apiKey,
user: user.toJSON()
})
})

if (!response.ok) {
throw new Error(`Desktop login callback returned ${response.status}`)
}

return true
}
5 changes: 3 additions & 2 deletions src/platform/cloud/onboarding/onboardingCloudRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { RouteRecordRaw } from 'vue-router'

import { hasDesktopLoginRequest } from '@/platform/cloud/onboarding/desktopLoginBridge'
import { getOAuthRequestId } from '@/platform/cloud/oauth/oauthState'

// `oauth_request_id` capture lives in the global router.beforeEach guard
Expand Down Expand Up @@ -53,7 +54,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
import('@/platform/cloud/onboarding/CloudLoginView.vue'),
beforeEnter: async (to, _from, next) => {
// Only redirect if not explicitly switching accounts
if (!to.query.switchAccount) {
if (!to.query.switchAccount && !hasDesktopLoginRequest(to.query)) {
const { useCurrentUser } =
await import('@/composables/auth/useCurrentUser')
const { isLoggedIn } = useCurrentUser()
Expand All @@ -71,7 +72,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
component: () =>
import('@/platform/cloud/onboarding/CloudSignupView.vue'),
beforeEnter: async (to, _from, next) => {
if (!to.query.switchAccount) {
if (!to.query.switchAccount && !hasDesktopLoginRequest(to.query)) {
const { useCurrentUser } =
await import('@/composables/auth/useCurrentUser')
const { isLoggedIn } = useCurrentUser()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'

import {
identifyPostHogUser,
setPostHogIdentityClient
} from './posthogIdentity'
import type {
AuthMetadata,
DefaultViewSetMetadata,
Expand Down Expand Up @@ -139,14 +143,15 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
// to clear localStorage on other subdomains, causing identity bleed on logout.
before_send: createPostHogBeforeSend()
})
setPostHogIdentityClient(this.posthog)
this.isInitialized = true
this.flushEventQueue()
this.registerDesktopEntryProps()

const currentUser = useCurrentUser()
currentUser.onUserResolved((user) => {
if (this.posthog && user.id) {
this.posthog.identify(user.id)
identifyPostHogUser(user.id)
this.setDesktopEntryPersonProperties()
this.setSubscriptionProperties()
}
Expand All @@ -166,14 +171,17 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
})
.catch((error) => {
console.error('Failed to load PostHog:', error)
setPostHogIdentityClient(null)
this.isEnabled = false
})
} catch (error) {
console.error('Failed to initialize PostHog:', error)
setPostHogIdentityClient(null)
this.isEnabled = false
}
} else {
console.warn('PostHog API key not provided in runtime config')
setPostHogIdentityClient(null)
this.isEnabled = false
}
}
Expand Down
39 changes: 39 additions & 0 deletions src/platform/telemetry/providers/cloud/posthogIdentity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { PostHog } from 'posthog-js'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import {
identifyPostHogUser,
setPostHogIdentityClient
} from './posthogIdentity'

describe('posthogIdentity', () => {
beforeEach(() => {
setPostHogIdentityClient(null)
})

afterEach(() => {
setPostHogIdentityClient(null)
})

it('queues identify calls until the PostHog client is ready', () => {
identifyPostHogUser('user-123')

const posthog = {
identify: vi.fn()
} as unknown as PostHog
setPostHogIdentityClient(posthog)

expect(posthog.identify).toHaveBeenCalledWith('user-123')
})

it('identifies immediately when the PostHog client is ready', () => {
const posthog = {
identify: vi.fn()
} as unknown as PostHog
setPostHogIdentityClient(posthog)

identifyPostHogUser('user-123')

expect(posthog.identify).toHaveBeenCalledWith('user-123')
})
})
Loading
Loading