first commit
This commit is contained in:
248
src/router/guards.js
Normal file
248
src/router/guards.js
Normal file
@@ -0,0 +1,248 @@
|
||||
// ⚠️ Guard depende de sessão estável.
|
||||
// Nunca disparar refresh concorrente durante navegação protegida.
|
||||
// Ver comentário em session.js sobre race condition.
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import { buildUpgradeUrl } from '@/utils/upgradeContext'
|
||||
|
||||
import { sessionUser, sessionReady, sessionRefreshing, initSession } from '@/app/session'
|
||||
|
||||
// cache simples (evita bater no banco em toda navegação)
|
||||
let sessionUidCache = null
|
||||
|
||||
// cache de saas admin por uid (pra não consultar tabela toda vez)
|
||||
let saasAdminCacheUid = null
|
||||
let saasAdminCacheIsAdmin = null
|
||||
|
||||
function roleToPath (role) {
|
||||
if (role === 'tenant_admin') return '/admin'
|
||||
if (role === 'therapist') return '/therapist'
|
||||
if (role === 'patient') return '/patient'
|
||||
if (role === 'saas_admin') return '/saas'
|
||||
return '/'
|
||||
}
|
||||
|
||||
function sleep (ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function waitSessionIfRefreshing () {
|
||||
if (!sessionReady.value) {
|
||||
try { await initSession({ initial: true }) } catch (e) {
|
||||
console.warn('[guards] initSession falhou:', e)
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if (!sessionRefreshing.value) return
|
||||
await sleep(50)
|
||||
}
|
||||
}
|
||||
|
||||
async function isSaasAdmin (uid) {
|
||||
if (!uid) return false
|
||||
if (saasAdminCacheUid === uid && typeof saasAdminCacheIsAdmin === 'boolean') {
|
||||
return saasAdminCacheIsAdmin
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('saas_admins')
|
||||
.select('user_id')
|
||||
.eq('user_id', uid)
|
||||
.maybeSingle()
|
||||
|
||||
const ok = !error && !!data
|
||||
saasAdminCacheUid = uid
|
||||
saasAdminCacheIsAdmin = ok
|
||||
return ok
|
||||
}
|
||||
|
||||
// heurística segura: carrega entitlements se ainda não carregou ou mudou tenant
|
||||
function shouldLoadEntitlements (ent, tenantId) {
|
||||
if (!tenantId) return false
|
||||
|
||||
const loaded = typeof ent.loaded === 'boolean' ? ent.loaded : false
|
||||
const entTenantId = ent.activeTenantId ?? ent.tenantId ?? null
|
||||
|
||||
if (!loaded) return true
|
||||
if (entTenantId && entTenantId !== tenantId) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// wrapper: chama loadForTenant sem depender de force:false existir
|
||||
async function loadEntitlementsSafe (ent, tenantId, force) {
|
||||
if (!ent?.loadForTenant) return
|
||||
|
||||
try {
|
||||
await ent.loadForTenant(tenantId, { force: !!force })
|
||||
} catch (e) {
|
||||
// se quebrou tentando force false (store não suporta), tenta força true uma vez
|
||||
if (!force) {
|
||||
console.warn('[guards] ent.loadForTenant(force:false) falhou, tentando force:true', e)
|
||||
await ent.loadForTenant(tenantId, { force: true })
|
||||
return
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// util: roles guard (plural)
|
||||
function matchesRoles (roles, activeRole) {
|
||||
if (!Array.isArray(roles) || !roles.length) return true
|
||||
return roles.includes(activeRole)
|
||||
}
|
||||
|
||||
export function applyGuards (router) {
|
||||
if (window.__guardsBound) return
|
||||
window.__guardsBound = true
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const tlabel = `[guard] ${to.fullPath}`
|
||||
console.time(tlabel)
|
||||
|
||||
try {
|
||||
// públicos
|
||||
if (to.meta?.public) { console.timeEnd(tlabel); return true }
|
||||
if (to.path.startsWith('/auth')) { console.timeEnd(tlabel); return true }
|
||||
|
||||
// se rota não exige auth, libera
|
||||
if (!to.meta?.requiresAuth) { console.timeEnd(tlabel); return true }
|
||||
|
||||
// não decide nada no meio do refresh do session.js
|
||||
console.timeLog(tlabel, 'waitSessionIfRefreshing')
|
||||
await waitSessionIfRefreshing()
|
||||
|
||||
// precisa estar logado (fonte estável do session.js)
|
||||
const uid = sessionUser.value?.id || null
|
||||
if (!uid) {
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/auth/login' }
|
||||
}
|
||||
|
||||
// se uid mudou, invalida caches e stores dependentes
|
||||
if (sessionUidCache !== uid) {
|
||||
sessionUidCache = uid
|
||||
saasAdminCacheUid = null
|
||||
saasAdminCacheIsAdmin = null
|
||||
|
||||
const ent0 = useEntitlementsStore()
|
||||
if (typeof ent0.invalidate === 'function') ent0.invalidate()
|
||||
}
|
||||
|
||||
// saas admin (com cache)
|
||||
if (to.meta?.saasAdmin) {
|
||||
console.timeLog(tlabel, 'isSaasAdmin')
|
||||
const ok = await isSaasAdmin(uid)
|
||||
if (!ok) { console.timeEnd(tlabel); return { path: '/pages/access' } }
|
||||
}
|
||||
|
||||
// carrega tenant + role
|
||||
const tenant = useTenantStore()
|
||||
console.timeLog(tlabel, 'tenant.loadSessionAndTenant?')
|
||||
if (!tenant.loaded && !tenant.loading) {
|
||||
await tenant.loadSessionAndTenant()
|
||||
}
|
||||
|
||||
// se não tem user no store, trata como não logado
|
||||
if (!tenant.user) {
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/auth/login' }
|
||||
}
|
||||
|
||||
// se não tem tenant ativo:
|
||||
// - se não tem memberships active -> manda pro access (sem clínica)
|
||||
// - se tem memberships active mas activeTenantId está null -> seta e segue
|
||||
if (!tenant.activeTenantId) {
|
||||
const mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
|
||||
const firstActive = mem.find(m => m && m.status === 'active' && m.tenant_id)
|
||||
|
||||
if (!firstActive) {
|
||||
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/pages/access' }
|
||||
}
|
||||
|
||||
if (typeof tenant.setActiveTenant === 'function') {
|
||||
tenant.setActiveTenant(firstActive.tenant_id)
|
||||
} else {
|
||||
tenant.activeTenantId = firstActive.tenant_id
|
||||
tenant.activeRole = firstActive.role
|
||||
}
|
||||
}
|
||||
|
||||
const tenantId = tenant.activeTenantId
|
||||
if (!tenantId) {
|
||||
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/pages/access' }
|
||||
}
|
||||
|
||||
// entitlements (✅ carrega só quando precisa)
|
||||
const ent = useEntitlementsStore()
|
||||
if (shouldLoadEntitlements(ent, tenantId)) {
|
||||
console.timeLog(tlabel, 'ent.loadForTenant')
|
||||
await loadEntitlementsSafe(ent, tenantId, true)
|
||||
}
|
||||
|
||||
// roles guard (plural)
|
||||
const allowedRoles = to.meta?.roles
|
||||
if (Array.isArray(allowedRoles) && allowedRoles.length) {
|
||||
if (!matchesRoles(allowedRoles, tenant.activeRole)) {
|
||||
const fallback = roleToPath(tenant.activeRole)
|
||||
if (to.path === fallback) { console.timeEnd(tlabel); return { path: '/pages/access' } }
|
||||
console.timeEnd(tlabel)
|
||||
return { path: fallback }
|
||||
}
|
||||
}
|
||||
|
||||
// role guard (singular) - mantém compatibilidade
|
||||
const requiredRole = to.meta?.role
|
||||
if (requiredRole && tenant.activeRole !== requiredRole) {
|
||||
const fallback = roleToPath(tenant.activeRole)
|
||||
if (to.path === fallback) { console.timeEnd(tlabel); return { path: '/pages/access' } }
|
||||
console.timeEnd(tlabel)
|
||||
return { path: fallback }
|
||||
}
|
||||
|
||||
// feature guard
|
||||
const requiredFeature = to.meta?.feature
|
||||
if (requiredFeature && ent?.can && !ent.can(requiredFeature)) {
|
||||
if (to.name === 'upgrade') { console.timeEnd(tlabel); return true }
|
||||
|
||||
const url = buildUpgradeUrl({
|
||||
missingKeys: [requiredFeature],
|
||||
redirectTo: to.fullPath
|
||||
})
|
||||
console.timeEnd(tlabel)
|
||||
return { path: url }
|
||||
}
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('[guards] erro no beforeEach:', e)
|
||||
|
||||
// fallback seguro
|
||||
if (to.meta?.public || to.path.startsWith('/auth')) return true
|
||||
if (to.path === '/pages/access') return true
|
||||
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
return { path: '/auth/login' }
|
||||
}
|
||||
})
|
||||
|
||||
// auth listener (reset caches)
|
||||
if (!window.__supabaseAuthListenerBound) {
|
||||
window.__supabaseAuthListenerBound = true
|
||||
|
||||
supabase.auth.onAuthStateChange(() => {
|
||||
sessionUidCache = null
|
||||
saasAdminCacheUid = null
|
||||
saasAdminCacheIsAdmin = null
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user