src/modules/auth.ts
Rama main · réplica de lectura del repositorio original.
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<typeof useMainStore>,
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<ReturnType<ConsoleClient['auth']['getSession']>>['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<boolean | null> {
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<void>) {
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()
}
})
}