import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router' import type { ConsoleClient } from '~/services/consoleClient' import type { UserModule } from '~/types' import { ADMIN_DASHBOARD_URL } from '~/constants/adminDashboard' import { clearChartDataCache } from '~/services/chartDataCache' import { isAgentClaimPath, isCliLoginPath, isMcpAuthorizePath } from '~/services/cliLogin' import { getLocalConfig, useConsole } from '~/services/console' import { hideLoader } from '~/services/loader' import { isNativeAppStoreContext } from '~/services/nativeCompliance' import { setUser } from '~/services/posthog' import { applySignupAttributionToPostHog } from '~/services/signupAttribution' import { isSsoUser, provisionSsoUser } from '~/services/ssoProvisioning' import { createSignedImageUrl, getImmediateImageUrl } from '~/services/storage' import { sendEvent } from '~/services/tracking' import { clearWebsitePaidUserCookie } from '~/services/websiteAuthCookie' import { useMainStore } from '~/stores/main' import { isPendingOrganizationInvite, useOrganizationStore } from '~/stores/organization' import { shouldSkipOnboardingResume } from '~/utils/appOnboardingProgress' import { needsInitialOnboardingName } from '~/utils/onboardingProfile' import { canExploreOnboardingDashboard, getOnboardingResumeRedirect, isNewOnboardingUser } from '~/utils/onboardingRedirect' import { AI_ONBOARDING_PATH, shouldRedirectToAiOnboarding } from '~/utils/onboardingSetupMode' import { hasPendingInviteSkip } from '~/utils/pendingInviteSkip' import { validateRedirectPath } from '~/utils/safeRedirect' import { getPlans } from './../services/console' async function updateUser( main: ReturnType, capgo: ConsoleClient, ) { const config = getLocalConfig() // console.log('set auth', auth) try { const { data, error } = await capgo .from('users') .select() .eq('id', main.auth?.id ?? '') .maybeSingle() let userRecord = data ?? null if (error && error.code !== 'PGRST116') { console.error('Failed to fetch public user profile', error) return } if (!userRecord && main.auth) { const { data: inserted, error: insertError } = await capgo .from('users') .insert({ id: main.auth.id, email: main.auth.email ?? '', first_name: main.auth.user_metadata?.first_name ?? '', last_name: main.auth.user_metadata?.last_name ?? '', country: null, enable_notifications: true, opt_for_newsletters: false, }) .select() .single() if (insertError) { console.error('Failed to create public user profile', insertError) } else { userRecord = inserted } } if (!userRecord) return if (main.auth?.email && userRecord.email !== main.auth?.email) { const { error: updateError } = await capgo .from('users') .update({ email: main.auth?.email }) .eq('id', main.auth?.id ?? '') if (updateError) console.error('update error', updateError) userRecord.email = main.auth?.email } const rawImageUrl = userRecord.image_url const immediateImageUrl = getImmediateImageUrl(rawImageUrl) if (rawImageUrl) userRecord.image_url = immediateImageUrl || null const updatePosthogUser = (avatar?: string | null) => { const authUser = main.auth setUser( authUser?.id ?? '', { email: userRecord.email, nickname: [userRecord.first_name, userRecord.last_name].filter(Boolean).join(' ') || undefined, avatar: avatar ?? undefined, }, config.apiHost, ) applySignupAttributionToPostHog(authUser, config.apiHost) } main.user = userRecord updatePosthogUser(userRecord.image_url) if (rawImageUrl && !immediateImageUrl) { void (async () => { try { const signedImageUrl = await createSignedImageUrl(rawImageUrl) const currentUser = main.user if (!signedImageUrl || !currentUser || currentUser.id !== userRecord.id) return currentUser.image_url = signedImageUrl main.user = currentUser updatePosthogUser(signedImageUrl) } catch (error) { console.warn('Cannot load signed user image', { userId: userRecord.id, error }) } })() } } catch (error) { console.error('auth', error) } } async function maybeProvisionSsoMembership( capgo: ConsoleClient, session: Awaited>['data']['session'] | null, ): Promise<'continue' | 'redirect_login' | 'abort_navigation'> { if (!session || !isSsoUser(session.user)) return 'continue' const result = await provisionSsoUser(session) if (result.merged) { const { error: signOutError } = await capgo.auth.signOut() if (signOutError) { console.error('Failed to sign out merged SSO session during auth guard:', signOutError) return 'abort_navigation' } return 'redirect_login' } if (result.error) { console.error('Failed to provision SSO membership during auth guard:', result.error) return 'abort_navigation' } return 'continue' } // Reuse a recent "account is active" answer instead of paying an API round trip before every // dashboard navigation. A disabled answer is never reused, so restoring the account is seen at once. const ACTIVE_ACCOUNT_CHECK_TTL_MS = 60_000 let activeAccountCheck: { userId: string, checkedAt: number } | null = null async function isDisabledAccount(capgo: ConsoleClient, userId: string | null | undefined, { fresh = false } = {}) { if (!userId) return false if (!fresh && activeAccountCheck?.userId === userId && Date.now() - activeAccountCheck.checkedAt < ACTIVE_ACCOUNT_CHECK_TTL_MS) return false const disabled = await fetchDisabledAccount(capgo, userId) // Only an explicit "active" answer is reused; a failed check (null) asks again next time. activeAccountCheck = disabled === false ? { userId, checkedAt: Date.now() } : null return disabled === true } /** true or false from the API, null when the check failed. */ async function fetchDisabledAccount(capgo: ConsoleClient, userId: string): Promise { try { const { data: isDisabled, error } = await capgo .rpc('is_account_disabled', { user_id: userId }) if (error) { console.error('Error checking account status:', error) return null } return !!isDisabled } catch (error) { console.error('Error checking if account is disabled:', error) return null } } function getAccountDisabledRedirect(to: RouteLocationNormalized) { return { path: '/accountDisabled', query: to.fullPath && to.path !== '/accountDisabled' ? { to: to.fullPath } : {}, } } function getPostRestorePath(to: RouteLocationNormalized) { const target = typeof to.query.to === 'string' ? to.query.to : '' return validateRedirectPath(target, '/dashboard', { blockedPrefixes: ['/accountDisabled'] }) } async function guard( next: NavigationGuardNext, to: RouteLocationNormalized, from: RouteLocationNormalized, ) { const capgo = useConsole() const main = useMainStore() const organizationStore = useOrganizationStore() const { data: claimsData } = await capgo.auth.getClaims() const { data: sessionData } = await capgo.auth.getSession() const sessionUser = sessionData?.session?.user ?? null const hasAuth = !!claimsData?.claims?.sub && !!sessionUser const hadAuth = !!main.auth const inviteOrgId = typeof to.query.invite_org === 'string' && to.query.invite_org.length > 0 ? to.query.invite_org : null const isCliLoginRoute = isCliLoginPath(to.path) || isMcpAuthorizePath(to.path) || isAgentClaimPath(to.path) const organizationFetchOptions = { loadImages: !isCliLoginRoute } async function tryLoadOrganizations(fetcher: () => Promise) { try { await fetcher() return true } catch (error) { console.error('Failed to load organizations during auth guard:', error) return false } } async function loadPlansIfNeeded() { if (main.plans.length > 0) return main.plans = await getPlans() } async function resolvePlatformAdminStatus() { try { await main.resolvePlatformAdminStatus() } catch (error) { console.error('Failed to resolve platform admin status:', error) main.isAdmin = false } } function requiredNameRedirect(organizationsLoaded: boolean) { // CLI and MCP authorization must finish before any onboarding detour. if (isCliLoginRoute) return null // An unknown profile cannot establish whether names are required. Offer a // retry without letting the user bypass required names or stranding navigation. if (!main.user) return { path: '/login', query: { profile_unavailable: '1', to: to.fullPath } } // Scope is unknown after an org fetch failure. Never force an established // member into initial onboarding; the onboarding wizard re-checks names. if (!organizationsLoaded || !needsInitialOnboardingName(main.user)) return null if (to.path === '/onboarding/name' || organizationStore.hasOrganizations) return null return { path: '/onboarding/name', query: { to: to.fullPath } } } function shouldRedirectToOrgOnboarding() { if (isCliLoginRoute) return false if (to.path === '/onboarding/name') return false if (to.path.startsWith('/onboarding/app')) return false if (to.path.startsWith('/onboarding/organization')) return false if (to.path.startsWith('/onboarding/invitation')) return false if (!inviteOrgId) return true return !organizationStore.organizations.some(org => org.gid === inviteOrgId && isPendingOrganizationInvite(org)) } function shouldRedirectToPendingInviteOnboarding(organizationsLoaded: boolean) { if (isCliLoginRoute) return false if (!organizationsLoaded) return false if (isNativeAppStoreContext()) return false if (to.path === '/onboarding/name' || to.path.startsWith('/onboarding/invitation')) return false if (hasPendingInviteSkip(sessionUser?.id ?? main.auth?.id)) return false return organizationStore.organizations.some(org => isPendingOrganizationInvite(org)) } async function getPendingOnboardingRedirect(organizationsLoaded: boolean) { if (to.path === '/onboarding/name') return null if (isCliLoginRoute) return null // Creating another organization must not bounce back to pending setup. if (to.path === '/onboarding/app' && to.query.new_org === '1') return null if (!organizationsLoaded) return null if (!isNewOnboardingUser(sessionUser?.created_at)) return null const selectableOrganizations = organizationStore.organizations.filter(org => !isPendingOrganizationInvite(org)) if (selectableOrganizations.length !== 1) return null const { data: apps, error } = await capgo .from('apps') .select('app_id, need_onboarding, onboarding') .eq('owner_org', selectableOrganizations[0].gid) .limit(2) if (error) { console.error('Cannot resolve onboarding redirect', error) return null } if (shouldRedirectToAiOnboarding({ onboarding: main.user?.onboarding, appCount: apps?.length ?? 0, organizationCount: selectableOrganizations.length, path: to.path, canExploreDashboard: canExploreOnboardingDashboard(sessionUser?.id), })) { return { path: AI_ONBOARDING_PATH } } const app = apps?.[0] if (app && shouldSkipOnboardingResume(app.onboarding)) return null return getOnboardingResumeRedirect({ appId: app?.need_onboarding ? app.app_id : null, appCount: apps?.length ?? 0, createdAt: sessionUser?.created_at, organizationCount: selectableOrganizations.length, path: to.path, userId: sessionUser?.id, }) } if (hasAuth && sessionUser) { const authConfirmedAt = main.auth?.email_confirmed_at if (!main.auth || main.auth.id !== sessionUser.id || authConfirmedAt !== sessionUser.email_confirmed_at) { main.auth = sessionUser } applySignupAttributionToPostHog(sessionUser, getLocalConfig().apiHost) } const { data: mfaData, error: mfaError } = await capgo.auth.mfa.getAuthenticatorAssuranceLevel() if (mfaError) { console.error('Cannot guard auth', mfaError) return next({ path: '/login', query: { to: to.fullPath, }, }) } if (mfaData.currentLevel === 'aal1' && mfaData.nextLevel === 'aal2') { // Only the backend may waive MFA: verify_mfa passes an aal1 session solely // when platform-admin log_as registered its session_id as an impersonation. const { data: hasMfaAssurance, error: verifyMfaError } = await capgo.rpc('verify_mfa') if (verifyMfaError) console.error('Cannot verify MFA assurance', verifyMfaError) if (verifyMfaError || hasMfaAssurance !== true) { return next({ path: '/login', query: { to: to.fullPath, }, }) } } if (hasAuth && sessionUser && !hadAuth) { // Start the reads that do not depend on each other at once: on a slow network each // sequential request adds a full round trip before the first screen renders. // SSO users must be provisioned into their organization before it is listed. const disabledRequest = isDisabledAccount(capgo, sessionUser.id, { fresh: true }) const userRequest = main.user ? Promise.resolve() : updateUser(main, capgo) const plansRequest = loadPlansIfNeeded() const organizationsRequest = isSsoUser(sessionUser) ? null : tryLoadOrganizations(() => organizationStore.fetchOrganizations(organizationFetchOptions)) const isDisabled = await disabledRequest if (to.path === '/accountDisabled') { hideLoader() return isDisabled ? next() : next(getPostRestorePath(to)) } if (isDisabled) { hideLoader() return next(getAccountDisabledRedirect(to)) } const provisioningResult = await maybeProvisionSsoMembership(capgo, sessionData?.session ?? null) if (provisioningResult === 'redirect_login') { return next('/login?message=sso_account_linked') } if (provisioningResult === 'abort_navigation') { await hideLoader() next(false) return } await userRequest sendEvent({ channel: 'user-login', event: 'User Login', user_id: main.auth?.id, }).catch() await plansRequest const organizationsLoaded = await (organizationsRequest ?? tryLoadOrganizations(() => organizationStore.fetchOrganizations(organizationFetchOptions))) const nameRedirect = requiredNameRedirect(organizationsLoaded) if (nameRedirect) { await hideLoader() return next(nameRedirect) } if (shouldRedirectToPendingInviteOnboarding(organizationsLoaded)) { return next({ path: '/onboarding/invitation', query: { to: to.path.startsWith('/onboarding/') ? '/dashboard' : to.fullPath, }, }) } await resolvePlatformAdminStatus() if (organizationsLoaded && !organizationStore.hasOrganizations && !main.isAdmin && shouldRedirectToOrgOnboarding()) { return next({ path: '/onboarding/app', query: { to: to.fullPath, }, }) } const onboardingRedirect = await getPendingOnboardingRedirect(organizationsLoaded) if (onboardingRedirect) return next(onboardingRedirect) next() hideLoader() } else if (from.path !== 'login' && !hasAuth) { main.auth = undefined next({ path: '/login', query: { to: to.fullPath, }, }) } else if (hasAuth && main.auth) { const isDisabled = await isDisabledAccount(capgo, sessionUser?.id ?? main.auth.id, { fresh: to.path === '/accountDisabled' }) if (isDisabled && to.path !== '/accountDisabled') { hideLoader() return next(getAccountDisabledRedirect(to)) } if (!isDisabled && to.path === '/accountDisabled') { hideLoader() return next(getPostRestorePath(to)) } if (!main.user) await updateUser(main, capgo) await loadPlansIfNeeded() let organizationsLoaded = await tryLoadOrganizations(() => organizationStore.dedupFetchOrganizations(organizationFetchOptions)) if (shouldRedirectToPendingInviteOnboarding(organizationsLoaded)) { return next({ path: '/onboarding/invitation', query: { to: to.path.startsWith('/onboarding/') ? '/dashboard' : to.fullPath, }, }) } if (organizationsLoaded && !organizationStore.hasOrganizations && isSsoUser(sessionUser)) { const didProvisionSsoMembership = await maybeProvisionSsoMembership(capgo, sessionData?.session ?? null) if (didProvisionSsoMembership === 'redirect_login') { return next('/login?message=sso_account_linked') } if (didProvisionSsoMembership === 'abort_navigation') { hideLoader() return next(false) } organizationsLoaded = await tryLoadOrganizations(() => organizationStore.fetchOrganizations(organizationFetchOptions)) } // Check names after SSO provisioning so enterprise members are not sent to self-signup onboarding. const nameRedirect = requiredNameRedirect(organizationsLoaded) if (nameRedirect) { await hideLoader() return next(nameRedirect) } await resolvePlatformAdminStatus() if (organizationsLoaded && !organizationStore.hasOrganizations && !main.isAdmin && shouldRedirectToOrgOnboarding()) { return next({ path: '/onboarding/app', query: { to: to.fullPath, }, }) } const onboardingRedirect = await getPendingOnboardingRedirect(organizationsLoaded) if (onboardingRedirect) return next(onboardingRedirect) await hideLoader() void next() } else { await hideLoader() void next() } } export const install: UserModule = ({ router }) => { const capgo = useConsole() capgo.auth.getSession() .then(({ data }) => { if (!data.session) clearWebsitePaidUserCookie() }) .catch(error => console.error('Failed to clear website paid user cookie', error)) if (typeof capgo.auth.onAuthStateChange === 'function') { capgo.auth.onAuthStateChange((_event, session) => { if (!session) { clearWebsitePaidUserCookie() clearChartDataCache() } }) } router.beforeEach(async (to, from, next) => { // The admin dashboard moved to its own app; send old bookmarks there. if (to.path === '/admin' || to.path.startsWith('/admin/')) { window.location.replace(ADMIN_DASHBOARD_URL) return next(false) } if (to.meta.middleware) { await guard(next, to, from) } else { hideLoader() next() } }) }