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:
Leonardo
2026-04-19 15:42:46 -03:00
parent d088a89fb7
commit 7c20b518d4
175 changed files with 37325 additions and 37968 deletions
+169
View File
@@ -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
View File
@@ -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
View File
@@ -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) => {
+22
View File
@@ -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 }
}
]
};