Files
agenciapsilmno/src/router/guards.js
T
Leonardo e95ed9b585 agenda: Fase 5 (status change/edit cobrada) + indicadores visuais + UX convenio
DB
- drop agenda_excecoes (substituida por financial_exceptions + lock-edit
  baseado em financial_records)
- financial_records.payment_link (Asaas + link compartilhavel)
- financial_exceptions.consume_on_miss (rotular nao-show consome ou nao)
- billing_contracts.charging_style (upfront/saldo/per_session)

Payment refactor
- paymentSettlement -> paymentMethod (string) + markPaidNow (bool).
  Handler aplica payment_method sempre; status='paid'+paid_at apenas
  quando markPaidNow=true && method != 'link'. Asaas (link) sempre
  liquida via webhook, nunca nasce paid.
- create_financial_record_for_session com pos-RPC patch pra payment_method
  e (opcional) status='paid' quando user marca "ja recebi".

Indicadores visuais (3 canais distintos por estado)
- Paid: barra esquerda emerald-500 4px na agenda (MelissaAgenda),
  pi-check-circle no popover/Resumo.
- Pending: badge \$ amber canto direito (mantido); linha amber no popover/
  Resumo "A receber R\$ X (cobranca pendente)".
- Neutro: sem badge nem barra (compromisso pessoal, bloqueio, ou
  ocorrencia virtual de pacote upfront/saldo).
- Bulk-load de paymentState em _reloadRange etapa 4 (1 query unica em
  financial_records mapeada por agenda_evento_id).
- AgendaEventDialog Resumo lateral ganha linha entre pi-clock e
  pi-map-marker via novo sessionPaymentRecord (sem guard de
  occurrenceMode, contrario ao occFinancialRecord que continua so pra
  Rail/Clinica). 5 estados: paid+paid_at, overdue+venceu, pending+vence,
  sem cobranca c/ valor, sem cobranca s/ valor.

UX de convenio
- InsurancePlanServiceQuickCreateDialog novo: cadastra procedimento
  POR CIMA do AgendaEventDialog sem sair da agenda. Auto-seleciona
  novo procedimento so quando nada estava selecionado antes.
- Caixa cinza "Cadastrar procedimento" sempre visivel quando convenio
  selecionado, com copy variavel (0 procedimentos: chamada urgente;
  1+: "se quiser adicionar mais").
- "+ Novo convenio" toolbar em ConfiguracoesConveniosPage (botao
  estava faltando, empty state mandava clicar em botao inexistente).
- Hint contextual abaixo do card Sessao/Honorarios: convenio = "N da
  guia eh opcional", gratuito = "sem cobranca", particular = sem hint.
  Label "N da Guia" tambem ganhou "(opcional)" no service-picker dialog.

Bug fixes
- pickDbFields whitelist faltando 'modalidade' (useMelissaAgenda.js:74)
  — sessoes avulsas eram salvas como presencial independente da
  escolha visual. Adicionado.
- goToConveniosConfig removida — fazia router.push("/therapist/
  configuracoes/convenios") mas /configuracoes/* eh rota raiz, nao
  filha. Substituida pelo quick-create inline (#1).
- bloqueioCobrindo + dialogBlockOverlap passados via deps em
  _buildHandlers (refs do useMelissaAgenda nao sao acessiveis no
  escopo de _buildHandlers).

Fase 5 (status change + edit cobrada)
- AgendaStatusChangeConfirmDialog: confirm dialog quando user muda
  status pra realizada/faltou/cancelado, com opcoes de markPaid ou
  gerar cobranca conforme o caso.
- useAgendaBloqueios novo composable: extrai logica de bloqueios
  cinza (background events) do MelissaAgenda.

Doc viva
- src/docs/agenda-compromisso-financeiro-cenarios.html: 13 cenarios
  de teste manual. C1-C4 ja validados. Cada teste validado vira parte
  da doc final pra area de ajuda (pos-Fase 9).

Wiki/handoff
- agenda-compromisso-fluxo e agenda-billing-pesquisa-mercado (decisoes
  arquiteturais sobre billing).
- HANDOFF.md atualizado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:31:18 -03:00

933 lines
37 KiB
JavaScript

/*
|--------------------------------------------------------------------------
| 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)
}
}