/* |-------------------------------------------------------------------------- | Agência PSI |-------------------------------------------------------------------------- | Criado e desenvolvido por Leonardo Nohama | | Tecnologia aplicada à escuta. | Estrutura para o cuidado. | | Arquivo: src/router/guards.js | Data: 2026 | Local: São Carlos/SP — Brasil |-------------------------------------------------------------------------- | © 2026 — Todos os direitos reservados |-------------------------------------------------------------------------- */ // 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 { resetAjuda } from '@/composables/useAjuda'; import { useMenuStore } from '@/stores/menuStore'; import { getMenuByRole } from '@/navigation'; import { sessionUser, sessionReady, sessionRefreshing, initSession, onSessionEvent } from '@/app/session'; // ✅ separa RBAC (papel) vs Plano (upgrade) import { denyByRole, denyByPlan } from '@/router/accessRedirects'; // (denyByPlan pode ficar, mesmo que não use aqui) // ✅ única fonte de verdade pra normalizar role import { normalizeRole } from '@/utils/roleNormalizer'; // 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; // V#6 — cache de globalRole por uid com TTL. // Antes era invalidado apenas em SIGNED_IN/SIGNED_OUT, ficando stale se a role // mudasse durante a sessão. TTL de 5min força re-fetch periódico. const GLOBAL_ROLE_TTL_MS = 5 * 60 * 1000; let globalRoleCacheUid = null; let globalRoleCache = null; let globalRoleCacheAt = 0; // Flags module-level para garantir single-bind (ES modules são singletons, // então basta uma variável aqui — não precisa poluir window). let guardsBound = false; let authListenerBound = false; // ----------------------------------------- // 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 (_) { } } // Exportadas pra permitir teste unitário sem montar o router inteiro. export 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 || '')); } export 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 export 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 export 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. // ====================================================== // V#9 router — skip-fast: evita useMenuStore() + comparações quando o último // key processado é o mesmo. Reset em SIGNED_OUT/SIGNED_IN garante invalidação. let lastEnsureKey = null; async function ensureMenuBuilt({ uid, tenantId, tenantRole, globalRole }) { try { 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}`; // V#9 — short-circuit: mesmo key da última chamada → menu já construído nessa nav if (lastEnsureKey === key) return; const menuStore = useMenuStore(); // ✅ 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) { lastEnsureKey = key; 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); lastEnsureKey = key; } catch (e) { logGuard('[guards] ensureMenuBuilt failed', { error: e?.message }); } } // V#9 — invalida o short-circuit do ensureMenuBuilt (chamado em SIGNED_IN/OUT) function resetEnsureMenuKey() { lastEnsureKey = null; } export function applyGuards(router) { if (guardsBound) return; 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') || to.path.startsWith('/configuracoes'); // ====================================== // ✅ 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; const cacheAge = Date.now() - globalRoleCacheAt; const cacheValid = globalRoleCacheUid === uid && globalRoleCache && cacheAge < GLOBAL_ROLE_TTL_MS; if (cacheValid) { 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; globalRoleCacheAt = Date.now(); } 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) { // Garante menu + entitlements ao recarregar diretamente em /account/* (ex.: F5). // globalRole (profiles.role) não mapeia para menus reais → precisamos da tenant role. const _menuStore = useMenuStore(); if (!_menuStore.ready) { try { const _tStore = useTenantStore(); if (!_tStore.activeRole) { await _tStore.loadSessionAndTenant(); } const _role = _tStore.activeRole; const _tid = _tStore.activeTenantId || null; if (_role && _tid) { // Carrega entitlements do tenant (mesma lógica do guard principal) const _ent = useEntitlementsStore(); if (shouldLoadEntitlements(_ent, _tid)) { await loadEntitlementsSafe(_ent, _tid, true); } // Entitlements pessoais — subscription do user independe do role // no tenant ativo (tenant_admin do próprio tenant pode ter therapist_pro) if (_ent.loadedForUser !== uid) { try { await _ent.loadForUser(uid); } catch { } } await ensureMenuBuilt({ uid, tenantId: _tid, tenantRole: _role, globalRole }); } } catch { } } _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; globalRoleCacheAt = 0; 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 // ================================ logGuard('saas.lockdown?'); // 🔥 PATCH: aqui NÃO consulta isSaasAdmin — usa só identidade global const isSaas = globalRole === 'saas_admin'; if (isSaas) { // V#10 — usa meta.area como fonte primária; path.startsWith vira fallback // pra rotas legacy que ainda não declaram meta.area. matchedRouteHasArea() // checa a cadeia inteira de matched (lida com routes aninhadas). const matchedHasSaasArea = (to.matched || []).some((r) => r.meta?.area === 'saas' || r.meta?.saasAdmin === true); const matchedHasDemoArea = (to.matched || []).some((r) => r.meta?.area === 'demo'); const isSaasArea = matchedHasSaasArea || to.path === '/saas' || to.path.startsWith('/saas/'); // Rotas do Tema Demo (em DEV) const isDemoArea = import.meta.env.DEV && (matchedHasDemoArea || 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: subscription pessoal (therapist_pro/free, // supervisor_*, etc) independe do role no tenant ativo — um terapeuta // pode ser tenant_admin do próprio tenant E ter assinatura pessoal. // Sempre carrega pra qualquer user autenticado. if (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' }; } }); // V#2 — listener consolidado: session.js é o único registrante de // supabase.auth.onAuthStateChange. Aqui só nos inscrevemos via onSessionEvent. if (!authListenerBound) { authListenerBound = true; // SIGNED_OUT: zera caches e localStorage tenant onSessionEvent('SIGNED_OUT', () => { sessionUidCache = null; saasAdminCacheUid = null; saasAdminCacheIsAdmin = null; globalRoleCacheUid = null; globalRoleCache = null; globalRoleCacheAt = 0; resetEnsureMenuKey(); try { resetAjuda(); } catch (_) { } // limpa localStorage de tenant — sem isso, próximo login restaura // o tenant do usuário anterior (mesma máquina) 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 { } }); // SIGNED_IN: só invalida se o usuário mudou de verdade onSessionEvent('SIGNED_IN', (sess) => { const uid = sess?.user?.id || null; if (uid && sessionUidCache && sessionUidCache === uid) return; // mesmo user sessionUidCache = uid || null; saasAdminCacheUid = null; saasAdminCacheIsAdmin = null; globalRoleCacheUid = null; globalRoleCache = null; globalRoleCacheAt = 0; resetEnsureMenuKey(); 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 { } }); // TOKEN_REFRESHED: não invalida nada (caso clássico de trocar de aba) } }