903 lines
30 KiB
JavaScript
903 lines
30 KiB
JavaScript
// src/router/guard.js
|
|
// ⚠️ 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 { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
|
|
import { buildUpgradeUrl } from '@/utils/upgradeContext'
|
|
import { logGuard, logError, logPerf } from '@/support/supportLogger'
|
|
|
|
import { useMenuStore } from '@/stores/menuStore'
|
|
import { getMenuByRole } from '@/navigation'
|
|
|
|
import { sessionUser, sessionReady, sessionRefreshing, initSession } from '@/app/session'
|
|
|
|
// ✅ separa RBAC (papel) vs Plano (upgrade)
|
|
import { denyByRole, denyByPlan } from '@/router/accessRedirects' // (denyByPlan pode ficar, mesmo que não use aqui)
|
|
|
|
// 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
|
|
|
|
// cache de globalRole por uid (evita query ao banco em cada navegação)
|
|
let globalRoleCacheUid = null
|
|
let globalRoleCache = null
|
|
|
|
// -----------------------------------------
|
|
// Pending invite (Modelo B) — retomada pós-login
|
|
// -----------------------------------------
|
|
const PENDING_INVITE_TOKEN_KEY = 'pending_invite_token_v1'
|
|
|
|
function readPendingInviteToken () {
|
|
try { return sessionStorage.getItem(PENDING_INVITE_TOKEN_KEY) } catch (_) { return null }
|
|
}
|
|
|
|
function clearPendingInviteToken () {
|
|
try { sessionStorage.removeItem(PENDING_INVITE_TOKEN_KEY) } catch (_) {}
|
|
}
|
|
|
|
function isUuid (v) {
|
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''))
|
|
}
|
|
|
|
/**
|
|
* ✅ Normaliza roles (aliases) para RBAC.
|
|
*
|
|
* tenant_admin / admin + kind = 'therapist' → 'therapist'
|
|
* tenant_admin / admin + kind = clinic_* → 'clinic_admin'
|
|
* tenant_admin / admin + kind desconhecido → 'clinic_admin' (legado)
|
|
* qualquer outro role → pass-through
|
|
*/
|
|
function normalizeRole (role, kind) {
|
|
const r = String(role || '').trim()
|
|
if (!r) return ''
|
|
|
|
const isAdmin = (r === 'tenant_admin' || r === 'admin')
|
|
|
|
if (isAdmin) {
|
|
const k = String(kind || '').trim()
|
|
if (k === 'therapist' || k === 'saas') return 'therapist'
|
|
if (k === 'supervisor') return 'supervisor'
|
|
return 'clinic_admin'
|
|
}
|
|
|
|
if (r === 'clinic_admin') return 'clinic_admin'
|
|
|
|
// demais
|
|
return r
|
|
}
|
|
|
|
function roleToPath (role) {
|
|
// ✅ clínica: aceita nomes canônicos e legado
|
|
if (role === 'clinic_admin' || role === 'tenant_admin' || role === 'admin') return '/admin'
|
|
|
|
if (role === 'therapist') return '/therapist'
|
|
|
|
// ✅ supervisor (papel de tenant)
|
|
if (role === 'supervisor') return '/supervisor'
|
|
|
|
// ⚠️ legado (se ainda existir em algum lugar)
|
|
if (role === 'patient') return '/portal'
|
|
if (role === 'portal_user') return '/portal'
|
|
|
|
// ✅ saas master
|
|
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) {
|
|
logGuard('[guards] initSession falhou', { error: e?.message })
|
|
}
|
|
}
|
|
|
|
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('profiles')
|
|
.select('role')
|
|
.eq('id', uid)
|
|
.single()
|
|
|
|
const ok = !error && data?.role === 'saas_admin'
|
|
|
|
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) {
|
|
logGuard('[guards] ent.loadForTenant force fallback', { error: e?.message })
|
|
await ent.loadForTenant(tenantId, { force: true })
|
|
return
|
|
}
|
|
throw e
|
|
}
|
|
}
|
|
|
|
/**
|
|
* wrapper: tenant features store pode não aceitar force:false (ou pode falhar silenciosamente)
|
|
* -> tenta sem forçar e, se der ruim, tenta force:true.
|
|
*/
|
|
async function fetchTenantFeaturesSafe (tf, tenantId) {
|
|
if (!tf?.fetchForTenant) return
|
|
try {
|
|
await tf.fetchForTenant(tenantId, { force: false })
|
|
} catch (e) {
|
|
logGuard('[guards] tf.fetchForTenant force fallback', { error: e?.message })
|
|
await tf.fetchForTenant(tenantId, { force: true })
|
|
}
|
|
}
|
|
|
|
// util: roles guard (plural) com aliases
|
|
function matchesRoles (roles, activeRole) {
|
|
if (!Array.isArray(roles) || !roles.length) return true
|
|
|
|
const ar = normalizeRole(activeRole)
|
|
const wanted = roles.map(normalizeRole)
|
|
|
|
return wanted.includes(ar)
|
|
}
|
|
|
|
// ======================================================
|
|
// ✅ MENU: monta 1x por contexto (sem flicker)
|
|
// - O AppMenu lê menuStore.model e não recalcula.
|
|
// ======================================================
|
|
|
|
async function ensureMenuBuilt ({ uid, tenantId, tenantRole, globalRole }) {
|
|
try {
|
|
const menuStore = useMenuStore()
|
|
|
|
const isSaas = (globalRole === 'saas_admin')
|
|
const roleForMenu = isSaas ? 'saas_admin' : normalizeRole(tenantRole)
|
|
|
|
// ✅ FIX: inclui o role normalizado E o tenantId no key de forma explícita
|
|
// O bug era: em alguns fluxos tenantRole chegava vazio/antigo antes de
|
|
// setActiveTenant() ser chamado, fazendo o key bater com o menu errado.
|
|
const safeRole = roleForMenu || 'unknown'
|
|
const safeTenant = tenantId || 'no-tenant'
|
|
const safeGlobal = globalRole || 'no-global'
|
|
const key = `${uid}:${safeTenant}:${safeRole}:${safeGlobal}`
|
|
|
|
// ✅ FIX PRINCIPAL: só considera cache válido se role E tenant baterem.
|
|
// Antes, o check era feito antes de garantir que tenant.activeRole
|
|
// já tinha sido resolvido corretamente nessa navegação.
|
|
if (menuStore.ready && menuStore.key === key && Array.isArray(menuStore.model) && menuStore.model.length > 0) {
|
|
// sanity check extra: verifica se o modelo tem itens do role correto
|
|
// (evita falso positivo quando key colide por acidente)
|
|
const firstLabel = menuStore.model?.[0]?.label || ''
|
|
const isClinicMenu = firstLabel === 'Clínica'
|
|
const isTherapistMenu = firstLabel === 'Terapeuta'
|
|
const isSupervisorMenu = firstLabel === 'Supervisão'
|
|
const isEditorMenu = firstLabel === 'Editor'
|
|
const isPortalMenu = firstLabel === 'Paciente'
|
|
const isSaasMenuCached = firstLabel === 'SaaS'
|
|
|
|
const expectClinic = safeRole === 'clinic_admin'
|
|
const expectTherapist = safeRole === 'therapist'
|
|
const expectSupervisor = safeRole === 'supervisor'
|
|
const expectEditor = safeRole === 'editor'
|
|
const expectPortal = safeRole === 'patient'
|
|
const expectSaas = safeRole === 'saas_admin'
|
|
|
|
const menuMatchesRole =
|
|
(expectClinic && isClinicMenu) ||
|
|
(expectTherapist && isTherapistMenu) ||
|
|
(expectSupervisor && isSupervisorMenu) ||
|
|
(expectEditor && isEditorMenu) ||
|
|
(expectPortal && isPortalMenu) ||
|
|
(expectSaas && isSaasMenuCached) ||
|
|
// roles desconhecidos: aceita o cache (coreMenu)
|
|
(!expectClinic && !expectTherapist && !expectSupervisor && !expectEditor && !expectPortal && !expectSaas)
|
|
|
|
if (menuMatchesRole) {
|
|
return // cache válido e menu correto
|
|
}
|
|
|
|
// cache com key igual mas menu errado: força rebuild
|
|
logGuard('[ensureMenuBuilt] menu incompatível com role, forçando rebuild', { key, safeRole, firstLabel })
|
|
menuStore.reset()
|
|
}
|
|
|
|
// garante tenant_features pronto ANTES de construir
|
|
if (!isSaas && tenantId) {
|
|
const tfm = useTenantFeaturesStore()
|
|
const hasAny = tfm?.features && typeof tfm.features === 'object' && Object.keys(tfm.features).length > 0
|
|
const loadedFor = tfm?.loadedForTenantId || null
|
|
if (!hasAny || (loadedFor && loadedFor !== tenantId)) {
|
|
await fetchTenantFeaturesSafe(tfm, tenantId)
|
|
} else if (!loadedFor) {
|
|
await fetchTenantFeaturesSafe(tfm, tenantId)
|
|
}
|
|
}
|
|
|
|
const tfm2 = useTenantFeaturesStore()
|
|
const ctx = {
|
|
isSaasAdmin: isSaas,
|
|
tenantLoading: () => false,
|
|
tenantFeaturesLoading: () => false,
|
|
tenantFeatureEnabled: (featureKey) => {
|
|
if (!tenantId) return false
|
|
try { return !!tfm2.isEnabled(featureKey, tenantId) } catch { return false }
|
|
},
|
|
role: () => normalizeRole(tenantRole)
|
|
}
|
|
|
|
const model = getMenuByRole(roleForMenu, ctx) || []
|
|
menuStore.setMenu(key, model)
|
|
} catch (e) {
|
|
logGuard('[guards] ensureMenuBuilt failed', { error: e?.message })
|
|
}
|
|
}
|
|
|
|
export function applyGuards (router) {
|
|
if (window.__guardsBound) return
|
|
window.__guardsBound = true
|
|
|
|
router.beforeEach(async (to) => {
|
|
const tlabel = `[guard] ${to.fullPath}`
|
|
const _perfEnd = logPerf('router.guard', tlabel)
|
|
|
|
try {
|
|
// ==========================================
|
|
// ✅ AUTH SEMPRE LIBERADO (blindagem total)
|
|
// (ordem importa: /auth antes de meta.public)
|
|
// ==========================================
|
|
if (to.path.startsWith('/auth')) {
|
|
_perfEnd()
|
|
return true
|
|
}
|
|
|
|
// ==========================================
|
|
// ✅ Rotas públicas
|
|
// ==========================================
|
|
if (to.meta?.public) {
|
|
_perfEnd()
|
|
return true
|
|
}
|
|
|
|
// se rota não exige auth, libera
|
|
if (!to.meta?.requiresAuth) {
|
|
_perfEnd()
|
|
return true
|
|
}
|
|
|
|
// não decide nada no meio do refresh do session.js
|
|
logGuard('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)
|
|
_perfEnd()
|
|
return { path: '/auth/login' }
|
|
}
|
|
|
|
const isTenantArea =
|
|
to.path.startsWith('/admin') ||
|
|
to.path.startsWith('/therapist') ||
|
|
to.path.startsWith('/supervisor')
|
|
|
|
// ======================================
|
|
// ✅ IDENTIDADE GLOBAL (cached por uid — sem query a cada navegação)
|
|
// - se falhar, NÃO nega por engano: volta pro login (seguro)
|
|
// ======================================
|
|
let globalRole = null
|
|
|
|
if (globalRoleCacheUid === uid && globalRoleCache) {
|
|
globalRole = globalRoleCache
|
|
logGuard('profiles.role (cache) =', globalRole)
|
|
} else {
|
|
const { data: prof, error: profErr } = await supabase
|
|
.from('profiles')
|
|
.select('role')
|
|
.eq('id', uid)
|
|
.single()
|
|
|
|
globalRole = !profErr ? prof?.role : null
|
|
if (globalRole) {
|
|
globalRoleCacheUid = uid
|
|
globalRoleCache = globalRole
|
|
}
|
|
logGuard('profiles.role (db) =', globalRole)
|
|
}
|
|
|
|
if (!globalRole) {
|
|
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
|
_perfEnd()
|
|
return { path: '/auth/login' }
|
|
}
|
|
|
|
// ======================================
|
|
// ✅ TRAVA GLOBAL: portal_user não entra em tenant-app
|
|
// ======================================
|
|
if (isTenantArea && globalRole === 'portal_user') {
|
|
// limpa lixo de tenant herdado
|
|
try {
|
|
localStorage.removeItem('tenant_id')
|
|
localStorage.removeItem('tenant')
|
|
localStorage.removeItem('currentTenantId')
|
|
} catch (_) {}
|
|
|
|
_perfEnd()
|
|
return { path: '/portal' }
|
|
}
|
|
|
|
// ======================================
|
|
// ✅ Portal (identidade global) via meta.profileRole
|
|
// ======================================
|
|
if (to.meta?.profileRole) {
|
|
if (globalRole !== to.meta.profileRole) {
|
|
_perfEnd()
|
|
return { path: '/pages/access' }
|
|
}
|
|
|
|
// monta menu do portal (patient) antes de liberar
|
|
await ensureMenuBuilt({
|
|
uid,
|
|
tenantId: null,
|
|
tenantRole: globalRole, // ex.: 'portal_user'
|
|
globalRole
|
|
})
|
|
|
|
_perfEnd()
|
|
return true
|
|
}
|
|
|
|
// ======================================
|
|
// ✅ ÁREA GLOBAL (não-tenant)
|
|
// - /account/* é perfil/config do usuário
|
|
// - NÃO pode carregar tenantStore nem trocar contexto de tenant
|
|
// ======================================
|
|
const isAccountArea = (to.path === '/account' || to.path.startsWith('/account/'))
|
|
if (isAccountArea) {
|
|
_perfEnd()
|
|
return true
|
|
}
|
|
|
|
// (opcional, mas recomendado)
|
|
// se não é tenant_member, evita carregar tenant/entitlements sem necessidade
|
|
if (globalRole && globalRole !== 'tenant_member') {
|
|
try {
|
|
localStorage.removeItem('tenant_id')
|
|
localStorage.removeItem('tenant')
|
|
localStorage.removeItem('currentTenantId')
|
|
} catch (_) {}
|
|
}
|
|
|
|
// ==========================================
|
|
// ✅ Pending invite (Modelo B)
|
|
// ==========================================
|
|
const pendingInviteToken = readPendingInviteToken()
|
|
|
|
if (pendingInviteToken && !isUuid(pendingInviteToken)) {
|
|
clearPendingInviteToken()
|
|
}
|
|
|
|
if (
|
|
pendingInviteToken &&
|
|
isUuid(pendingInviteToken) &&
|
|
!to.path.startsWith('/accept-invite')
|
|
) {
|
|
_perfEnd()
|
|
return { path: '/accept-invite', query: { token: pendingInviteToken } }
|
|
}
|
|
|
|
// se uid mudou, invalida caches e stores dependentes
|
|
if (sessionUidCache !== uid) {
|
|
sessionUidCache = uid
|
|
saasAdminCacheUid = null
|
|
saasAdminCacheIsAdmin = null
|
|
globalRoleCacheUid = null
|
|
globalRoleCache = null
|
|
|
|
const ent0 = useEntitlementsStore()
|
|
if (typeof ent0.invalidate === 'function') ent0.invalidate()
|
|
|
|
const tf0 = useTenantFeaturesStore()
|
|
if (typeof tf0.invalidate === 'function') tf0.invalidate()
|
|
|
|
try {
|
|
const menuStore = useMenuStore()
|
|
if (typeof menuStore.reset === 'function') menuStore.reset()
|
|
} catch {}
|
|
}
|
|
|
|
// ================================
|
|
// ✅ SAAS MASTER: não depende de tenant
|
|
// ================================
|
|
if (to.meta?.saasAdmin) {
|
|
logGuard('isSaasAdmin')
|
|
// usa identidade global primeiro (evita cache fantasma)
|
|
const ok = (globalRole === 'saas_admin') ? true : await isSaasAdmin(uid)
|
|
if (!ok) { _perfEnd(); return { path: '/pages/access' } }
|
|
|
|
// ✅ monta menu SaaS 1x (AppMenu lê do menuStore)
|
|
await ensureMenuBuilt({
|
|
uid,
|
|
tenantId: null,
|
|
tenantRole: 'saas_admin',
|
|
globalRole
|
|
})
|
|
|
|
_perfEnd()
|
|
return true
|
|
}
|
|
|
|
// ================================
|
|
// ✅ ÁREA DO EDITOR (papel de plataforma)
|
|
// Verificado por platform_roles[] em profiles, não por tenant.
|
|
// ⚠️ Requer migration: ALTER TABLE profiles ADD COLUMN platform_roles text[] DEFAULT '{}'
|
|
// ================================
|
|
if (to.meta?.editorArea) {
|
|
let platformRoles = []
|
|
try {
|
|
const { data: pRoles } = await supabase
|
|
.from('profiles')
|
|
.select('platform_roles')
|
|
.eq('id', uid)
|
|
.single()
|
|
platformRoles = Array.isArray(pRoles?.platform_roles) ? pRoles.platform_roles : []
|
|
} catch {
|
|
// coluna ainda não existe: acesso negado por padrão
|
|
}
|
|
|
|
if (!platformRoles.includes('editor')) {
|
|
_perfEnd()
|
|
return { path: '/pages/access' }
|
|
}
|
|
|
|
await ensureMenuBuilt({
|
|
uid,
|
|
tenantId: null,
|
|
tenantRole: 'editor',
|
|
globalRole
|
|
})
|
|
|
|
_perfEnd()
|
|
return true
|
|
}
|
|
|
|
// ================================
|
|
// 🚫 SaaS master: bloqueia tenant-app por padrão
|
|
// ✅ Mas libera rotas de DEMO em DEV (Sakai)
|
|
// ================================
|
|
logGuard('saas.lockdown?')
|
|
|
|
// 🔥 PATCH: aqui NÃO consulta isSaasAdmin — usa só identidade global
|
|
const isSaas = (globalRole === 'saas_admin')
|
|
|
|
if (isSaas) {
|
|
const isSaasArea = to.path === '/saas' || to.path.startsWith('/saas/')
|
|
|
|
// Rotas do Sakai Demo (no seu caso ficam em /demo/*)
|
|
const isDemoArea = import.meta.env.DEV && (
|
|
to.path === '/demo' ||
|
|
to.path.startsWith('/demo/')
|
|
)
|
|
|
|
// Se for demo em DEV, libera
|
|
if (isDemoArea) {
|
|
// ✅ ainda assim monta menu SaaS (pra layout não piscar)
|
|
await ensureMenuBuilt({
|
|
uid,
|
|
tenantId: null,
|
|
tenantRole: 'saas_admin',
|
|
globalRole
|
|
})
|
|
|
|
_perfEnd()
|
|
return true
|
|
}
|
|
|
|
// Fora de /saas (e não-demo), não pode
|
|
if (!isSaasArea) {
|
|
_perfEnd()
|
|
return { path: '/saas' }
|
|
}
|
|
|
|
// ✅ estamos no /saas: monta menu SaaS
|
|
await ensureMenuBuilt({
|
|
uid,
|
|
tenantId: null,
|
|
tenantRole: 'saas_admin',
|
|
globalRole
|
|
})
|
|
}
|
|
|
|
// ================================
|
|
// ✅ Abaixo daqui é tudo tenant-app
|
|
// ================================
|
|
|
|
// carrega tenant + role
|
|
const tenant = useTenantStore()
|
|
logGuard('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)
|
|
_perfEnd()
|
|
return { path: '/auth/login' }
|
|
}
|
|
|
|
// se não tem tenant ativo:
|
|
if (!tenant.activeTenantId) {
|
|
const mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
|
|
|
|
// 1) tenta casar role da rota (ex.: therapist) com membership
|
|
const wantedRoles = Array.isArray(to.meta?.roles) ? to.meta.roles : []
|
|
const wantedNorm = wantedRoles.map(normalizeRole)
|
|
|
|
const preferred = wantedNorm.length
|
|
? mem.find(m =>
|
|
m &&
|
|
m.status === 'active' &&
|
|
m.tenant_id &&
|
|
wantedNorm.includes(normalizeRole(m.role, m.kind))
|
|
)
|
|
: null
|
|
|
|
// 2) fallback: primeiro active
|
|
const firstActive = preferred || mem.find(m => m && m.status === 'active' && m.tenant_id)
|
|
|
|
if (!firstActive) {
|
|
// 🔥 race/sem vínculo: em área tenant, trate como login necessário (não AccessDenied)
|
|
if (isTenantArea) {
|
|
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
|
_perfEnd()
|
|
return { path: '/auth/login' }
|
|
}
|
|
|
|
if (to.path === '/pages/access') { _perfEnd(); return true }
|
|
_perfEnd()
|
|
return { path: '/pages/access' }
|
|
}
|
|
|
|
if (typeof tenant.setActiveTenant === 'function') {
|
|
tenant.setActiveTenant(firstActive.tenant_id)
|
|
} else {
|
|
tenant.activeTenantId = firstActive.tenant_id
|
|
tenant.activeRole = firstActive.role
|
|
}
|
|
}
|
|
|
|
// 🔥 FIX: se ainda assim não resolveu tenant/role e estamos em tenant area, não negue “por engano”
|
|
if (isTenantArea && (!tenant.activeTenantId || !tenant.activeRole)) {
|
|
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
|
_perfEnd()
|
|
return { path: '/auth/login' }
|
|
}
|
|
|
|
let tenantId = tenant.activeTenantId
|
|
if (!tenantId) {
|
|
if (to.path === '/pages/access') { _perfEnd(); return true }
|
|
_perfEnd()
|
|
return { path: '/pages/access' }
|
|
}
|
|
|
|
// =====================================================
|
|
// ✅ tenantScope baseado em tenants.kind (fonte da verdade)
|
|
// =====================================================
|
|
const scope = to.meta?.tenantScope // 'personal' | 'clinic'
|
|
|
|
if (scope) {
|
|
const mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
|
|
|
|
// seleciona membership ativa cujo kind corresponde ao escopo
|
|
const desired = mem.find(m =>
|
|
m &&
|
|
m.status === 'active' &&
|
|
m.tenant_id &&
|
|
(
|
|
(scope === 'personal' && m.kind === 'saas') ||
|
|
(scope === 'clinic' && m.kind === 'clinic') ||
|
|
(scope === 'supervisor' && m.kind === 'supervisor')
|
|
)
|
|
)
|
|
|
|
const desiredTenantId = desired?.tenant_id || null
|
|
|
|
if (desiredTenantId && tenant.activeTenantId !== desiredTenantId) {
|
|
logGuard(`tenantScope.switch(${scope})`)
|
|
|
|
// ✅ guarda o tenant antigo para invalidar APENAS ele
|
|
const oldTenantId = tenant.activeTenantId
|
|
|
|
if (typeof tenant.setActiveTenant === 'function') {
|
|
tenant.setActiveTenant(desiredTenantId)
|
|
} else {
|
|
tenant.activeTenantId = desiredTenantId
|
|
}
|
|
|
|
localStorage.setItem('tenant_id', desiredTenantId)
|
|
tenantId = desiredTenantId
|
|
|
|
try {
|
|
const entX = useEntitlementsStore()
|
|
if (typeof entX.invalidate === 'function') entX.invalidate()
|
|
} catch {}
|
|
|
|
// ✅ NÃO invalidar o cache inteiro — invalida somente o tenant anterior
|
|
try {
|
|
const tfX = useTenantFeaturesStore()
|
|
if (typeof tfX.invalidate === 'function' && oldTenantId) tfX.invalidate(oldTenantId)
|
|
} catch {}
|
|
|
|
// ✅ troca tenant => menu precisa recompôr (contexto mudou)
|
|
try {
|
|
const menuStore = useMenuStore()
|
|
if (typeof menuStore.reset === 'function') menuStore.reset()
|
|
} catch {}
|
|
} else if (!desiredTenantId) {
|
|
logGuard('[guards] tenantScope sem match', {
|
|
scope,
|
|
memberships: mem.map(x => ({ tenant_id: x?.tenant_id, role: x?.role, kind: x?.kind, status: x?.status }))
|
|
})
|
|
}
|
|
}
|
|
|
|
// se trocar tenant, invalida cache de tenant_features (evita ler feature de tenant errado)
|
|
const tfSwitch = useTenantFeaturesStore()
|
|
if (tfSwitch.loadedForTenantId && tfSwitch.loadedForTenantId !== tenantId) {
|
|
// ✅ invalida só o tenant que estava carregado antes
|
|
tfSwitch.invalidate(tfSwitch.loadedForTenantId)
|
|
}
|
|
|
|
// entitlements (✅ carrega só quando precisa)
|
|
const ent = useEntitlementsStore()
|
|
if (shouldLoadEntitlements(ent, tenantId)) {
|
|
logGuard('ent.loadForTenant')
|
|
await loadEntitlementsSafe(ent, tenantId, true)
|
|
}
|
|
|
|
// ✅ user entitlements: terapeuta pode ter assinatura pessoal (therapist_pro)
|
|
// que gera features em v_user_entitlements, não em v_tenant_entitlements.
|
|
// user entitlements: therapist e supervisor têm assinatura pessoal (v_user_entitlements)
|
|
const activeRoleNormForEnt = normalizeRole(tenant.activeRole)
|
|
if (['therapist', 'supervisor'].includes(activeRoleNormForEnt) && uid && ent.loadedForUser !== uid) {
|
|
logGuard('ent.loadForUser')
|
|
try { await ent.loadForUser(uid) } catch (e) {
|
|
logGuard('[guards] ent.loadForUser failed', { error: e?.message })
|
|
}
|
|
}
|
|
|
|
// ================================
|
|
// ✅ tenant_features (módulos ativáveis por clínica)
|
|
// meta.tenantFeature = 'patients' | ...
|
|
// ================================
|
|
const requiredTenantFeature = to.meta?.tenantFeature
|
|
if (requiredTenantFeature) {
|
|
const tf = useTenantFeaturesStore()
|
|
logGuard('tenantFeatures.fetchForTenant')
|
|
await fetchTenantFeaturesSafe(tf, tenantId)
|
|
|
|
// ✅ IMPORTANTÍSSIMO: passa tenantId
|
|
const enabled = typeof tf.isEnabled === 'function'
|
|
? tf.isEnabled(requiredTenantFeature, tenantId)
|
|
: false
|
|
|
|
if (!enabled) {
|
|
if (to.path === '/admin/clinic/features') { _perfEnd(); return true }
|
|
|
|
_perfEnd()
|
|
return {
|
|
path: '/admin/clinic/features',
|
|
query: { missing: requiredTenantFeature, redirectTo: to.fullPath }
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------
|
|
// ✅ RBAC (roles) — BLOQUEIA se não for compatível
|
|
// ------------------------------------------------
|
|
const allowedRolesRaw = Array.isArray(to.meta?.roles) ? to.meta.roles : null
|
|
const allowedRoles = allowedRolesRaw && allowedRolesRaw.length
|
|
? allowedRolesRaw.map(normalizeRole)
|
|
: null
|
|
|
|
const activeRoleNorm = normalizeRole(tenant.activeRole)
|
|
|
|
if (allowedRoles && allowedRoles.length && !allowedRoles.includes(activeRoleNorm)) {
|
|
const mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
|
|
|
|
const compatible = mem.find(m =>
|
|
m &&
|
|
m.status === 'active' &&
|
|
m.tenant_id === tenantId &&
|
|
allowedRoles.includes(normalizeRole(m.role, m.kind))
|
|
)
|
|
|
|
if (compatible) {
|
|
tenant.activeRole = normalizeRole(compatible.role, compatible.kind)
|
|
} else {
|
|
_perfEnd()
|
|
return denyByRole({ to, currentRole: tenant.activeRole })
|
|
}
|
|
}
|
|
|
|
// role guard (singular)
|
|
const requiredRoleRaw = to.meta?.role
|
|
const requiredRole = requiredRoleRaw ? normalizeRole(requiredRoleRaw) : null
|
|
|
|
if (requiredRole && normalizeRole(tenant.activeRole) !== requiredRole) {
|
|
_perfEnd()
|
|
return denyByRole({ to, currentRole: tenant.activeRole })
|
|
}
|
|
|
|
// ------------------------------------------------
|
|
// ✅ feature guard (entitlements/plano → upgrade)
|
|
// ------------------------------------------------
|
|
const requiredFeature = to.meta?.feature
|
|
if (requiredFeature && ent?.can && !ent.can(requiredFeature)) {
|
|
if (to.name === 'upgrade') { _perfEnd(); return true }
|
|
|
|
const url = buildUpgradeUrl({
|
|
missingKeys: [requiredFeature],
|
|
redirectTo: to.fullPath,
|
|
role: normalizeRole(tenant.activeRole) // ✅ passa o role para a UpgradePage detectar
|
|
})
|
|
|
|
_perfEnd()
|
|
return url
|
|
}
|
|
|
|
// ======================================================
|
|
// ✅ MENU: monta 1x por contexto APÓS estabilizar tenant+role
|
|
// ======================================================
|
|
await ensureMenuBuilt({
|
|
uid,
|
|
tenantId,
|
|
tenantRole: tenant.activeRole,
|
|
globalRole
|
|
})
|
|
|
|
_perfEnd()
|
|
return true
|
|
} catch (e) {
|
|
logError('router.guard', 'erro no beforeEach', e)
|
|
|
|
if (to.path.startsWith('/auth')) return true
|
|
if (to.meta?.public) return true
|
|
if (to.path === '/pages/access') return true
|
|
|
|
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
|
return { path: '/auth/login' }
|
|
}
|
|
})
|
|
|
|
// auth listener (reset caches) — ✅ agora com filtro de evento
|
|
if (!window.__supabaseAuthListenerBound) {
|
|
window.__supabaseAuthListenerBound = true
|
|
|
|
supabase.auth.onAuthStateChange((event, sess) => {
|
|
// ⚠️ NÃO derrubar caches em token refresh / eventos redundantes.
|
|
const uid = sess?.user?.id || null
|
|
|
|
// ✅ SIGNED_OUT: aqui sim zera tudo
|
|
if (event === 'SIGNED_OUT') {
|
|
sessionUidCache = null
|
|
saasAdminCacheUid = null
|
|
saasAdminCacheIsAdmin = null
|
|
globalRoleCacheUid = null
|
|
globalRoleCache = null
|
|
|
|
// ✅ FIX: limpa o localStorage de tenant na saída
|
|
// Sem isso, o próximo login restaura o tenant do usuário anterior.
|
|
try {
|
|
localStorage.removeItem('tenant_id')
|
|
localStorage.removeItem('tenant')
|
|
localStorage.removeItem('currentTenantId')
|
|
} catch (_) {}
|
|
|
|
try {
|
|
const tf = useTenantFeaturesStore()
|
|
if (typeof tf.invalidate === 'function') tf.invalidate()
|
|
} catch {}
|
|
|
|
try {
|
|
const ent = useEntitlementsStore()
|
|
if (typeof ent.invalidate === 'function') ent.invalidate()
|
|
} catch {}
|
|
|
|
try {
|
|
const tenant = useTenantStore()
|
|
if (typeof tenant.reset === 'function') tenant.reset()
|
|
} catch {}
|
|
|
|
try {
|
|
const menuStore = useMenuStore()
|
|
if (typeof menuStore.reset === 'function') menuStore.reset()
|
|
} catch {}
|
|
|
|
return
|
|
}
|
|
|
|
// ✅ TOKEN_REFRESHED: NÃO invalida nada (é o caso clássico de trocar de aba)
|
|
if (event === 'TOKEN_REFRESHED') return
|
|
|
|
// ✅ SIGNED_IN / USER_UPDATED:
|
|
// só invalida se o usuário mudou de verdade
|
|
if (event === 'SIGNED_IN' || event === 'USER_UPDATED') {
|
|
if (uid && sessionUidCache && sessionUidCache === uid) {
|
|
// mesmo usuário -> não derruba caches
|
|
return
|
|
}
|
|
|
|
// user mudou (ou cache vazio) -> invalida dependências
|
|
sessionUidCache = uid || null
|
|
saasAdminCacheUid = null
|
|
saasAdminCacheIsAdmin = null
|
|
globalRoleCacheUid = null
|
|
globalRoleCache = null
|
|
|
|
try {
|
|
const tf = useTenantFeaturesStore()
|
|
if (typeof tf.invalidate === 'function') tf.invalidate()
|
|
} catch {}
|
|
|
|
try {
|
|
const ent = useEntitlementsStore()
|
|
if (typeof ent.invalidate === 'function') ent.invalidate()
|
|
} catch {}
|
|
|
|
try {
|
|
const menuStore = useMenuStore()
|
|
if (typeof menuStore.reset === 'function') menuStore.reset()
|
|
} catch {}
|
|
|
|
// tenantStore carrega de novo no fluxo do guard quando precisar
|
|
return
|
|
}
|
|
|
|
// default: não faz nada
|
|
})
|
|
}
|
|
} |