Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.
Ver commit.md na raiz para descricao completa por sessao.
# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)
# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)
# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)
# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* guards.spec.js
|
||||
*
|
||||
* Cobre as funções puras extraídas de guards.js. O beforeEach inteiro
|
||||
* (applyGuards) não é unit-testado aqui — exige mock de pinia + supabase +
|
||||
* vue-router. Cobertura completa de navegação fica para testes E2E (T#10).
|
||||
*
|
||||
* Mocks: supabase e stores são mockados via vi.mock para permitir que o
|
||||
* módulo guards.js carregue sem explodir — mas os testes focam nas puras.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mocks antes do import do SUT
|
||||
vi.mock('@/lib/supabase/client', () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getUser: vi.fn().mockResolvedValue({ data: { user: null }, error: null }),
|
||||
onAuthStateChange: vi.fn().mockReturnValue({ data: { subscription: { unsubscribe: vi.fn() } } })
|
||||
},
|
||||
from: vi.fn().mockReturnThis(),
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
single: vi.fn().mockResolvedValue({ data: null, error: null }),
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null })
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/tenantStore', () => ({ useTenantStore: vi.fn() }));
|
||||
vi.mock('@/stores/entitlementsStore', () => ({ useEntitlementsStore: vi.fn() }));
|
||||
vi.mock('@/stores/tenantFeaturesStore', () => ({ useTenantFeaturesStore: vi.fn() }));
|
||||
vi.mock('@/stores/menuStore', () => ({ useMenuStore: vi.fn() }));
|
||||
vi.mock('@/navigation', () => ({ getMenuByRole: vi.fn().mockReturnValue([]) }));
|
||||
vi.mock('@/utils/upgradeContext', () => ({ buildUpgradeUrl: vi.fn() }));
|
||||
vi.mock('@/support/supportLogger', () => ({
|
||||
logGuard: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logPerf: vi.fn(() => () => {})
|
||||
}));
|
||||
vi.mock('@/composables/useAjuda', () => ({ resetAjuda: vi.fn() }));
|
||||
vi.mock('@/app/session', () => ({
|
||||
sessionUser: { value: null },
|
||||
sessionReady: { value: true },
|
||||
sessionRefreshing: { value: false },
|
||||
initSession: vi.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
vi.mock('@/router/accessRedirects', () => ({
|
||||
denyByRole: vi.fn(({ to }) => ({ path: '/pages/access' })),
|
||||
denyByPlan: vi.fn()
|
||||
}));
|
||||
|
||||
const { isUuid, roleToPath, shouldLoadEntitlements, matchesRoles } = await import('../guards.js');
|
||||
|
||||
describe('isUuid — validação UUID v1-5 case insensitive', () => {
|
||||
it('aceita UUIDs v4 canônicos', () => {
|
||||
expect(isUuid('550e8400-e29b-41d4-a716-446655440000')).toBe(true);
|
||||
expect(isUuid('f47ac10b-58cc-4372-a567-0e02b2c3d479')).toBe(true);
|
||||
});
|
||||
|
||||
it('aceita uppercase', () => {
|
||||
expect(isUuid('550E8400-E29B-41D4-A716-446655440000')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejeita strings vazias/null', () => {
|
||||
expect(isUuid('')).toBe(false);
|
||||
expect(isUuid(null)).toBe(false);
|
||||
expect(isUuid(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejeita formatos truncados/sem hífen', () => {
|
||||
expect(isUuid('550e8400e29b41d4a716446655440000')).toBe(false); // sem hífen
|
||||
expect(isUuid('550e8400-e29b-41d4-a716')).toBe(false); // truncado
|
||||
expect(isUuid('not-a-uuid')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejeita version 6+ e variant inválido', () => {
|
||||
// variant precisa começar com 8, 9, a ou b
|
||||
expect(isUuid('550e8400-e29b-41d4-c716-446655440000')).toBe(false);
|
||||
// version precisa ser 1-5
|
||||
expect(isUuid('550e8400-e29b-61d4-a716-446655440000')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roleToPath — mapa de role → home path', () => {
|
||||
it('mapeia clinic_admin/tenant_admin/admin → /admin', () => {
|
||||
expect(roleToPath('clinic_admin')).toBe('/admin');
|
||||
expect(roleToPath('tenant_admin')).toBe('/admin');
|
||||
expect(roleToPath('admin')).toBe('/admin');
|
||||
});
|
||||
|
||||
it('mapeia therapist → /therapist', () => {
|
||||
expect(roleToPath('therapist')).toBe('/therapist');
|
||||
});
|
||||
|
||||
it('mapeia supervisor → /supervisor', () => {
|
||||
expect(roleToPath('supervisor')).toBe('/supervisor');
|
||||
});
|
||||
|
||||
it('mapeia patient/portal_user → /portal', () => {
|
||||
expect(roleToPath('patient')).toBe('/portal');
|
||||
expect(roleToPath('portal_user')).toBe('/portal');
|
||||
});
|
||||
|
||||
it('mapeia saas_admin → /saas', () => {
|
||||
expect(roleToPath('saas_admin')).toBe('/saas');
|
||||
});
|
||||
|
||||
it('fallback para /', () => {
|
||||
expect(roleToPath('unknown')).toBe('/');
|
||||
expect(roleToPath('')).toBe('/');
|
||||
expect(roleToPath(null)).toBe('/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldLoadEntitlements — heurística de carga de entitlements', () => {
|
||||
it('retorna false sem tenantId', () => {
|
||||
expect(shouldLoadEntitlements({ loaded: true, activeTenantId: 't1' }, null)).toBe(false);
|
||||
expect(shouldLoadEntitlements({ loaded: true, activeTenantId: 't1' }, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('retorna true se nunca carregou', () => {
|
||||
expect(shouldLoadEntitlements({ loaded: false }, 't1')).toBe(true);
|
||||
expect(shouldLoadEntitlements({}, 't1')).toBe(true); // loaded undefined
|
||||
});
|
||||
|
||||
it('retorna true se tenant mudou', () => {
|
||||
expect(shouldLoadEntitlements({ loaded: true, activeTenantId: 't1' }, 't2')).toBe(true);
|
||||
expect(shouldLoadEntitlements({ loaded: true, tenantId: 't1' }, 't2')).toBe(true);
|
||||
});
|
||||
|
||||
it('retorna false se já carregado pro mesmo tenant', () => {
|
||||
expect(shouldLoadEntitlements({ loaded: true, activeTenantId: 't1' }, 't1')).toBe(false);
|
||||
expect(shouldLoadEntitlements({ loaded: true, tenantId: 't1' }, 't1')).toBe(false);
|
||||
});
|
||||
|
||||
it('loaded=true sem tenantId anterior ainda exige carga se target existe', () => {
|
||||
// edge case: loaded=true mas sem activeTenantId (cenário inconsistente) →
|
||||
// heurística conservadora retorna false (não recarrega)
|
||||
expect(shouldLoadEntitlements({ loaded: true, activeTenantId: null }, 't1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesRoles — RBAC com normalização de aliases', () => {
|
||||
it('retorna true se lista vazia/null (sem restrição)', () => {
|
||||
expect(matchesRoles(null, 'therapist')).toBe(true);
|
||||
expect(matchesRoles([], 'therapist')).toBe(true);
|
||||
expect(matchesRoles(undefined, 'any')).toBe(true);
|
||||
});
|
||||
|
||||
it('bate role canônico', () => {
|
||||
expect(matchesRoles(['therapist'], 'therapist')).toBe(true);
|
||||
expect(matchesRoles(['clinic_admin', 'therapist'], 'therapist')).toBe(true);
|
||||
});
|
||||
|
||||
it('bate via normalização — tenant_admin contexto therapist tenant_admin equiv clinic_admin default', () => {
|
||||
// Sem kind, tenant_admin vira clinic_admin
|
||||
expect(matchesRoles(['clinic_admin'], 'tenant_admin')).toBe(true);
|
||||
expect(matchesRoles(['admin'], 'tenant_admin')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejeita role não listado', () => {
|
||||
expect(matchesRoles(['therapist'], 'clinic_admin')).toBe(false);
|
||||
expect(matchesRoles(['saas_admin'], 'therapist')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignora não-arrays', () => {
|
||||
expect(matchesRoles('therapist', 'therapist')).toBe(true);
|
||||
expect(matchesRoles({}, 'therapist')).toBe(true);
|
||||
});
|
||||
});
|
||||
+119
-121
@@ -28,11 +28,14 @@ import { resetAjuda } from '@/composables/useAjuda';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { getMenuByRole } from '@/navigation';
|
||||
|
||||
import { sessionUser, sessionReady, sessionRefreshing, initSession } from '@/app/session';
|
||||
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;
|
||||
|
||||
@@ -40,9 +43,18 @@ let sessionUidCache = null;
|
||||
let saasAdminCacheUid = null;
|
||||
let saasAdminCacheIsAdmin = null;
|
||||
|
||||
// cache de globalRole por uid (evita query ao banco em cada navegação)
|
||||
// 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
|
||||
@@ -63,38 +75,12 @@ function clearPendingInviteToken() {
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
function isUuid(v) {
|
||||
// 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 || ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ 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) {
|
||||
export function roleToPath(role) {
|
||||
// ✅ clínica: aceita nomes canônicos e legado
|
||||
if (role === 'clinic_admin' || role === 'tenant_admin' || role === 'admin') return '/admin';
|
||||
|
||||
@@ -150,7 +136,7 @@ async function isSaasAdmin(uid) {
|
||||
}
|
||||
|
||||
// heurística segura: carrega entitlements se ainda não carregou ou mudou tenant
|
||||
function shouldLoadEntitlements(ent, tenantId) {
|
||||
export function shouldLoadEntitlements(ent, tenantId) {
|
||||
if (!tenantId) return false;
|
||||
|
||||
const loaded = typeof ent.loaded === 'boolean' ? ent.loaded : false;
|
||||
@@ -193,7 +179,7 @@ async function fetchTenantFeaturesSafe(tf, tenantId) {
|
||||
}
|
||||
|
||||
// util: roles guard (plural) com aliases
|
||||
function matchesRoles(roles, activeRole) {
|
||||
export function matchesRoles(roles, activeRole) {
|
||||
if (!Array.isArray(roles) || !roles.length) return true;
|
||||
|
||||
const ar = normalizeRole(activeRole);
|
||||
@@ -207,10 +193,12 @@ function matchesRoles(roles, activeRole) {
|
||||
// - 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 menuStore = useMenuStore();
|
||||
|
||||
const isSaas = globalRole === 'saas_admin';
|
||||
const roleForMenu = isSaas ? 'saas_admin' : normalizeRole(tenantRole);
|
||||
|
||||
@@ -222,6 +210,11 @@ async function ensureMenuBuilt({ uid, tenantId, tenantRole, globalRole }) {
|
||||
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.
|
||||
@@ -254,6 +247,7 @@ async function ensureMenuBuilt({ uid, tenantId, tenantRole, globalRole }) {
|
||||
(!expectClinic && !expectTherapist && !expectSupervisor && !expectEditor && !expectPortal && !expectSaas);
|
||||
|
||||
if (menuMatchesRole) {
|
||||
lastEnsureKey = key;
|
||||
return; // cache válido e menu correto
|
||||
}
|
||||
|
||||
@@ -292,14 +286,20 @@ async function ensureMenuBuilt({ uid, tenantId, tenantRole, globalRole }) {
|
||||
|
||||
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 (window.__guardsBound) return;
|
||||
window.__guardsBound = true;
|
||||
if (guardsBound) return;
|
||||
guardsBound = true;
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const tlabel = `[guard] ${to.fullPath}`;
|
||||
@@ -353,7 +353,10 @@ export function applyGuards(router) {
|
||||
// ======================================
|
||||
let globalRole = null;
|
||||
|
||||
if (globalRoleCacheUid === uid && globalRoleCache) {
|
||||
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 {
|
||||
@@ -363,6 +366,7 @@ export function applyGuards(router) {
|
||||
if (globalRole) {
|
||||
globalRoleCacheUid = uid;
|
||||
globalRoleCache = globalRole;
|
||||
globalRoleCacheAt = Date.now();
|
||||
}
|
||||
logGuard('profiles.role (db) =', globalRole);
|
||||
}
|
||||
@@ -479,6 +483,7 @@ export function applyGuards(router) {
|
||||
saasAdminCacheIsAdmin = null;
|
||||
globalRoleCacheUid = null;
|
||||
globalRoleCache = null;
|
||||
globalRoleCacheAt = 0;
|
||||
|
||||
const ent0 = useEntitlementsStore();
|
||||
if (typeof ent0.invalidate === 'function') ent0.invalidate();
|
||||
@@ -556,10 +561,15 @@ export function applyGuards(router) {
|
||||
const isSaas = globalRole === 'saas_admin';
|
||||
|
||||
if (isSaas) {
|
||||
const isSaasArea = to.path === '/saas' || to.path.startsWith('/saas/');
|
||||
// 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 (no seu caso ficam em /demo/*)
|
||||
const isDemoArea = import.meta.env.DEV && (to.path === '/demo' || to.path.startsWith('/demo/'));
|
||||
// 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) {
|
||||
@@ -842,93 +852,81 @@ export function applyGuards(router) {
|
||||
}
|
||||
});
|
||||
|
||||
// auth listener (reset caches) — ✅ agora com filtro de evento
|
||||
if (!window.__supabaseAuthListenerBound) {
|
||||
window.__supabaseAuthListenerBound = true;
|
||||
// V#2 — listener consolidado: session.js é o único registrante de
|
||||
// supabase.auth.onAuthStateChange. Aqui só nos inscrevemos via onSessionEvent.
|
||||
if (!authListenerBound) {
|
||||
authListenerBound = true;
|
||||
|
||||
supabase.auth.onAuthStateChange((event, sess) => {
|
||||
// ⚠️ NÃO derrubar caches em token refresh / eventos redundantes.
|
||||
const uid = sess?.user?.id || null;
|
||||
// SIGNED_OUT: zera caches e localStorage tenant
|
||||
onSessionEvent('SIGNED_OUT', () => {
|
||||
sessionUidCache = null;
|
||||
saasAdminCacheUid = null;
|
||||
saasAdminCacheIsAdmin = null;
|
||||
globalRoleCacheUid = null;
|
||||
globalRoleCache = null;
|
||||
globalRoleCacheAt = 0;
|
||||
resetEnsureMenuKey();
|
||||
|
||||
// ✅ SIGNED_OUT: aqui sim zera tudo
|
||||
if (event === 'SIGNED_OUT') {
|
||||
sessionUidCache = null;
|
||||
saasAdminCacheUid = null;
|
||||
saasAdminCacheIsAdmin = null;
|
||||
globalRoleCacheUid = null;
|
||||
globalRoleCache = null;
|
||||
try { resetAjuda(); } catch (_) { }
|
||||
|
||||
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 (_) { }
|
||||
|
||||
// ✅ 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 tf = useTenantFeaturesStore();
|
||||
if (typeof tf.invalidate === 'function') tf.invalidate();
|
||||
} catch { }
|
||||
try {
|
||||
const ent = useEntitlementsStore();
|
||||
if (typeof ent.invalidate === 'function') ent.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 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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -38,6 +38,7 @@ import miscRoutes from './routes.misc';
|
||||
|
||||
import { pinia } from '@/plugins/pinia';
|
||||
import { supportGuard } from '@/support/supportGuard';
|
||||
import { logError } from '@/support/supportLogger';
|
||||
import { applyGuards } from './guards';
|
||||
|
||||
const routes = [
|
||||
@@ -97,7 +98,7 @@ const router = createRouter({
|
||||
}
|
||||
});
|
||||
|
||||
router.onError((e) => console.error('[router.onError]', e));
|
||||
router.onError((e) => logError('router.onError', 'router error', e));
|
||||
|
||||
// ✅ support guard — passa pinia para garantir acesso ao store antes do app.use(pinia)
|
||||
router.beforeEach(async (to) => {
|
||||
|
||||
@@ -77,6 +77,16 @@ export default {
|
||||
name: 'saas-tenants',
|
||||
component: () => import('@/views/pages/saas/SaasPlaceholder.vue')
|
||||
},
|
||||
{
|
||||
path: 'tenant-features',
|
||||
name: 'saas-tenant-features',
|
||||
component: () => import('@/views/pages/saas/SaasTenantFeaturesPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'security',
|
||||
name: 'saas-security',
|
||||
component: () => import('@/views/pages/saas/SaasSecurityPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'feriados',
|
||||
name: 'saas-feriados',
|
||||
@@ -133,6 +143,12 @@ export default {
|
||||
component: () => import('@/views/pages/saas/SaasTwilioWhatsappPage.vue'),
|
||||
meta: { requiresAuth: true, saasAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'twilio-config',
|
||||
name: 'saas-twilio-config',
|
||||
component: () => import('@/views/pages/saas/SaasTwilioConfigPage.vue'),
|
||||
meta: { requiresAuth: true, saasAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'addons',
|
||||
name: 'saas-addons',
|
||||
@@ -144,6 +160,12 @@ export default {
|
||||
name: 'saas-document-templates',
|
||||
component: () => import('@/views/pages/saas/SaasDocumentTemplatesPage.vue'),
|
||||
meta: { requiresAuth: true, saasAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'desenvolvimento',
|
||||
name: 'saas-desenvolvimento',
|
||||
component: () => import('@/views/pages/saas/development/SaasDevelopmentPage.vue'),
|
||||
meta: { requiresAuth: true, saasAdmin: true }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user