first commit

This commit is contained in:
Leonardo
2026-02-18 22:36:45 -03:00
parent ec6b6ef53a
commit 676042268b
122 changed files with 26354 additions and 1615 deletions

248
src/router/guards.js Normal file
View 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
})
}
}