ZERADO
This commit is contained in:
@@ -9,10 +9,13 @@ import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
|
||||
import { buildUpgradeUrl } from '@/utils/upgradeContext'
|
||||
|
||||
import { useMenuStore } from '@/stores/menuStore'
|
||||
import { getMenuByRole } from '@/navigation'
|
||||
|
||||
import { sessionUser, sessionReady, sessionRefreshing, initSession } from '@/app/session'
|
||||
|
||||
// ✅ separa RBAC (papel) vs Plano (upgrade)
|
||||
import { denyByRole, denyByPlan } from '@/router/accessRedirects'
|
||||
import { denyByRole, denyByPlan } from '@/router/accessRedirects' // (denyByPlan pode ficar, mesmo que não use aqui)
|
||||
|
||||
// cache simples (evita bater no banco em toda navegação)
|
||||
let sessionUidCache = null
|
||||
@@ -38,12 +41,45 @@ function isUuid (v) {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ Normaliza roles (aliases) para RBAC.
|
||||
*
|
||||
* tenant_admin / admin + kind = 'therapist' → 'therapist'
|
||||
* tenant_admin / admin + kind = clinic_* → 'clinic_admin'
|
||||
* tenant_admin / admin + kind desconhecido → 'clinic_admin' (legado)
|
||||
* qualquer outro role → pass-through
|
||||
*/
|
||||
function normalizeRole (role, kind) {
|
||||
const r = String(role || '').trim()
|
||||
if (!r) return ''
|
||||
|
||||
const isAdmin = (r === 'tenant_admin' || r === 'admin')
|
||||
|
||||
if (isAdmin) {
|
||||
const k = String(kind || '').trim()
|
||||
if (k === 'therapist' || k === 'saas') return 'therapist'
|
||||
if (k === 'supervisor') return 'supervisor'
|
||||
return 'clinic_admin'
|
||||
}
|
||||
|
||||
if (r === 'clinic_admin') return 'clinic_admin'
|
||||
|
||||
// demais
|
||||
return r
|
||||
}
|
||||
|
||||
function roleToPath (role) {
|
||||
// ✅ clínica: aceita nomes canônicos e legado
|
||||
if (role === 'clinic_admin' || role === 'tenant_admin' || role === 'admin') return '/admin'
|
||||
|
||||
if (role === 'therapist') return '/therapist'
|
||||
|
||||
// ✅ supervisor (papel de tenant)
|
||||
if (role === 'supervisor') return '/supervisor'
|
||||
|
||||
// ⚠️ legado (se ainda existir em algum lugar)
|
||||
if (role === 'patient') return '/portal'
|
||||
if (role === 'portal_user') return '/portal'
|
||||
|
||||
// ✅ saas master
|
||||
if (role === 'saas_admin') return '/saas'
|
||||
@@ -70,19 +106,22 @@ async function waitSessionIfRefreshing () {
|
||||
|
||||
async function isSaasAdmin (uid) {
|
||||
if (!uid) return false
|
||||
|
||||
if (saasAdminCacheUid === uid && typeof saasAdminCacheIsAdmin === 'boolean') {
|
||||
return saasAdminCacheIsAdmin
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('saas_admins')
|
||||
.select('user_id')
|
||||
.eq('user_id', uid)
|
||||
.maybeSingle()
|
||||
.from('profiles')
|
||||
.select('role')
|
||||
.eq('id', uid)
|
||||
.single()
|
||||
|
||||
const ok = !error && data?.role === 'saas_admin'
|
||||
|
||||
const ok = !error && !!data
|
||||
saasAdminCacheUid = uid
|
||||
saasAdminCacheIsAdmin = ok
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
@@ -115,10 +154,121 @@ async function loadEntitlementsSafe (ent, tenantId, force) {
|
||||
}
|
||||
}
|
||||
|
||||
// util: roles guard (plural)
|
||||
/**
|
||||
* 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) {
|
||||
console.warn('[guards] tf.fetchForTenant(force:false) falhou, tentando force:true', e)
|
||||
await tf.fetchForTenant(tenantId, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
// util: roles guard (plural) com aliases
|
||||
function matchesRoles (roles, activeRole) {
|
||||
if (!Array.isArray(roles) || !roles.length) return true
|
||||
return roles.includes(activeRole)
|
||||
|
||||
const ar = normalizeRole(activeRole)
|
||||
const wanted = roles.map(normalizeRole)
|
||||
|
||||
return wanted.includes(ar)
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// ✅ MENU: monta 1x por contexto (sem flicker)
|
||||
// - O AppMenu lê menuStore.model e não recalcula.
|
||||
// ======================================================
|
||||
|
||||
async function ensureMenuBuilt ({ uid, tenantId, tenantRole, globalRole }) {
|
||||
try {
|
||||
const menuStore = useMenuStore()
|
||||
|
||||
const isSaas = (globalRole === 'saas_admin')
|
||||
const roleForMenu = isSaas ? 'saas_admin' : normalizeRole(tenantRole)
|
||||
|
||||
// ✅ FIX: inclui o role normalizado E o tenantId no key de forma explícita
|
||||
// O bug era: em alguns fluxos tenantRole chegava vazio/antigo antes de
|
||||
// setActiveTenant() ser chamado, fazendo o key bater com o menu errado.
|
||||
const safeRole = roleForMenu || 'unknown'
|
||||
const safeTenant = tenantId || 'no-tenant'
|
||||
const safeGlobal = globalRole || 'no-global'
|
||||
const key = `${uid}:${safeTenant}:${safeRole}:${safeGlobal}`
|
||||
|
||||
// ✅ FIX PRINCIPAL: só considera cache válido se role E tenant baterem.
|
||||
// Antes, o check era feito antes de garantir que tenant.activeRole
|
||||
// já tinha sido resolvido corretamente nessa navegação.
|
||||
if (menuStore.ready && menuStore.key === key && Array.isArray(menuStore.model) && menuStore.model.length > 0) {
|
||||
// sanity check extra: verifica se o modelo tem itens do role correto
|
||||
// (evita falso positivo quando key colide por acidente)
|
||||
const firstLabel = menuStore.model?.[0]?.label || ''
|
||||
const isClinicMenu = firstLabel === 'Clínica'
|
||||
const isTherapistMenu = firstLabel === 'Terapeuta'
|
||||
const isSupervisorMenu = firstLabel === 'Supervisão'
|
||||
const isEditorMenu = firstLabel === 'Editor'
|
||||
const isPortalMenu = firstLabel === 'Paciente'
|
||||
const isSaasMenuCached = firstLabel === 'SaaS'
|
||||
|
||||
const expectClinic = safeRole === 'clinic_admin'
|
||||
const expectTherapist = safeRole === 'therapist'
|
||||
const expectSupervisor = safeRole === 'supervisor'
|
||||
const expectEditor = safeRole === 'editor'
|
||||
const expectPortal = safeRole === 'patient'
|
||||
const expectSaas = safeRole === 'saas_admin'
|
||||
|
||||
const menuMatchesRole =
|
||||
(expectClinic && isClinicMenu) ||
|
||||
(expectTherapist && isTherapistMenu) ||
|
||||
(expectSupervisor && isSupervisorMenu) ||
|
||||
(expectEditor && isEditorMenu) ||
|
||||
(expectPortal && isPortalMenu) ||
|
||||
(expectSaas && isSaasMenuCached) ||
|
||||
// roles desconhecidos: aceita o cache (coreMenu)
|
||||
(!expectClinic && !expectTherapist && !expectSupervisor && !expectEditor && !expectPortal && !expectSaas)
|
||||
|
||||
if (menuMatchesRole) {
|
||||
return // cache válido e menu correto
|
||||
}
|
||||
|
||||
// cache com key igual mas menu errado: força rebuild
|
||||
console.warn('[ensureMenuBuilt] key match mas menu incompatível com role, forçando rebuild:', {
|
||||
key, safeRole, firstLabel
|
||||
})
|
||||
menuStore.reset()
|
||||
}
|
||||
|
||||
// garante tenant_features pronto ANTES de construir
|
||||
if (!isSaas && tenantId) {
|
||||
const tfm = useTenantFeaturesStore()
|
||||
const hasAny = tfm?.features && typeof tfm.features === 'object' && Object.keys(tfm.features).length > 0
|
||||
const loadedFor = tfm?.loadedForTenantId || null
|
||||
if (!hasAny || (loadedFor && loadedFor !== tenantId)) {
|
||||
await fetchTenantFeaturesSafe(tfm, tenantId)
|
||||
} else if (!loadedFor) {
|
||||
await fetchTenantFeaturesSafe(tfm, tenantId)
|
||||
}
|
||||
}
|
||||
|
||||
const tfm2 = useTenantFeaturesStore()
|
||||
const ctx = {
|
||||
isSaasAdmin: isSaas,
|
||||
tenantLoading: () => false,
|
||||
tenantFeaturesLoading: () => false,
|
||||
tenantFeatureEnabled: (featureKey) => {
|
||||
if (!tenantId) return false
|
||||
try { return !!tfm2.isEnabled(featureKey, tenantId) } catch { return false }
|
||||
},
|
||||
role: () => normalizeRole(tenantRole)
|
||||
}
|
||||
|
||||
const model = getMenuByRole(roleForMenu, ctx) || []
|
||||
menuStore.setMenu(key, model)
|
||||
} catch (e) {
|
||||
console.warn('[guards] ensureMenuBuilt failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
export function applyGuards (router) {
|
||||
@@ -165,20 +315,96 @@ export function applyGuards (router) {
|
||||
return { path: '/auth/login' }
|
||||
}
|
||||
|
||||
const isTenantArea =
|
||||
to.path.startsWith('/admin') ||
|
||||
to.path.startsWith('/therapist') ||
|
||||
to.path.startsWith('/supervisor')
|
||||
|
||||
// ======================================
|
||||
// ✅ IDENTIDADE GLOBAL (1x por navegação)
|
||||
// - se falhar, NÃO nega por engano: volta pro login (seguro)
|
||||
// ======================================
|
||||
const { data: prof, error: profErr } = await supabase
|
||||
.from('profiles')
|
||||
.select('role')
|
||||
.eq('id', uid)
|
||||
.single()
|
||||
|
||||
const globalRole = !profErr ? prof?.role : null
|
||||
console.timeLog(tlabel, 'profiles.role =', globalRole)
|
||||
|
||||
if (!globalRole) {
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
console.timeEnd(tlabel)
|
||||
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 (_) {}
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/portal' }
|
||||
}
|
||||
|
||||
// ======================================
|
||||
// ✅ Portal (identidade global) via meta.profileRole
|
||||
// ======================================
|
||||
if (to.meta?.profileRole) {
|
||||
if (globalRole !== to.meta.profileRole) {
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/pages/access' }
|
||||
}
|
||||
|
||||
// monta menu do portal (patient) antes de liberar
|
||||
await ensureMenuBuilt({
|
||||
uid,
|
||||
tenantId: null,
|
||||
tenantRole: globalRole, // ex.: 'portal_user'
|
||||
globalRole
|
||||
})
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
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) {
|
||||
console.timeEnd(tlabel)
|
||||
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): se o usuário logou e tem token pendente,
|
||||
// redireciona para /accept-invite antes de qualquer load pesado.
|
||||
// ✅ Pending invite (Modelo B)
|
||||
// ==========================================
|
||||
const pendingInviteToken = readPendingInviteToken()
|
||||
|
||||
// Se tiver lixo no storage, limpa para não “travar” o app.
|
||||
if (pendingInviteToken && !isUuid(pendingInviteToken)) {
|
||||
clearPendingInviteToken()
|
||||
}
|
||||
|
||||
// Evita loop/efeito colateral:
|
||||
// - não interfere se já está em /accept-invite
|
||||
// (não precisamos checar /auth aqui porque /auth já retornou lá em cima)
|
||||
if (
|
||||
pendingInviteToken &&
|
||||
isUuid(pendingInviteToken) &&
|
||||
@@ -199,6 +425,11 @@ export function applyGuards (router) {
|
||||
|
||||
const tf0 = useTenantFeaturesStore()
|
||||
if (typeof tf0.invalidate === 'function') tf0.invalidate()
|
||||
|
||||
try {
|
||||
const menuStore = useMenuStore()
|
||||
if (typeof menuStore.reset === 'function') menuStore.reset()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ================================
|
||||
@@ -206,13 +437,103 @@ export function applyGuards (router) {
|
||||
// ================================
|
||||
if (to.meta?.saasAdmin) {
|
||||
console.timeLog(tlabel, 'isSaasAdmin')
|
||||
const ok = await isSaasAdmin(uid)
|
||||
// usa identidade global primeiro (evita cache fantasma)
|
||||
const ok = (globalRole === 'saas_admin') ? true : await isSaasAdmin(uid)
|
||||
if (!ok) { console.timeEnd(tlabel); return { path: '/pages/access' } }
|
||||
|
||||
// ✅ monta menu SaaS 1x (AppMenu lê do menuStore)
|
||||
await ensureMenuBuilt({
|
||||
uid,
|
||||
tenantId: null,
|
||||
tenantRole: 'saas_admin',
|
||||
globalRole
|
||||
})
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
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')) {
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/pages/access' }
|
||||
}
|
||||
|
||||
await ensureMenuBuilt({
|
||||
uid,
|
||||
tenantId: null,
|
||||
tenantRole: 'editor',
|
||||
globalRole
|
||||
})
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
return true
|
||||
}
|
||||
|
||||
// ================================
|
||||
// 🚫 SaaS master: bloqueia tenant-app por padrão
|
||||
// ✅ Mas libera rotas de DEMO em DEV (Sakai)
|
||||
// ================================
|
||||
console.timeLog(tlabel, 'saas.lockdown?')
|
||||
|
||||
// 🔥 PATCH: aqui NÃO consulta isSaasAdmin — usa só identidade global
|
||||
const isSaas = (globalRole === 'saas_admin')
|
||||
|
||||
if (isSaas) {
|
||||
const isSaasArea = to.path === '/saas' || to.path.startsWith('/saas/')
|
||||
|
||||
// Rotas do Sakai Demo (no seu caso ficam em /demo/*)
|
||||
const isDemoArea = import.meta.env.DEV && (
|
||||
to.path === '/demo' ||
|
||||
to.path.startsWith('/demo/')
|
||||
)
|
||||
|
||||
// Se for demo em DEV, libera
|
||||
if (isDemoArea) {
|
||||
// ✅ ainda assim monta menu SaaS (pra layout não piscar)
|
||||
await ensureMenuBuilt({
|
||||
uid,
|
||||
tenantId: null,
|
||||
tenantRole: 'saas_admin',
|
||||
globalRole
|
||||
})
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
return true
|
||||
}
|
||||
|
||||
// Fora de /saas (e não-demo), não pode
|
||||
if (!isSaasArea) {
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/saas' }
|
||||
}
|
||||
|
||||
// ✅ estamos no /saas: monta menu SaaS
|
||||
await ensureMenuBuilt({
|
||||
uid,
|
||||
tenantId: null,
|
||||
tenantRole: 'saas_admin',
|
||||
globalRole
|
||||
})
|
||||
}
|
||||
|
||||
// ================================
|
||||
// ✅ Abaixo daqui é tudo tenant-app
|
||||
// ================================
|
||||
@@ -232,21 +553,33 @@ export function applyGuards (router) {
|
||||
}
|
||||
|
||||
// se não tem tenant ativo:
|
||||
// - se não tem memberships active -> manda pro access (sem clínica)
|
||||
// - se tem memberships active mas activeTenantId está null -> seta e segue
|
||||
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 preferred = wantedRoles.length
|
||||
? mem.find(m => m && m.status === 'active' && m.tenant_id && wantedRoles.includes(m.role))
|
||||
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)
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/auth/login' }
|
||||
}
|
||||
|
||||
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/pages/access' }
|
||||
@@ -260,17 +593,90 @@ export function applyGuards (router) {
|
||||
}
|
||||
}
|
||||
|
||||
const tenantId = tenant.activeTenantId
|
||||
// 🔥 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)
|
||||
console.timeEnd(tlabel)
|
||||
return { path: '/auth/login' }
|
||||
}
|
||||
|
||||
let tenantId = tenant.activeTenantId
|
||||
if (!tenantId) {
|
||||
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
|
||||
console.timeEnd(tlabel)
|
||||
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) {
|
||||
console.timeLog(tlabel, `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) {
|
||||
console.warn('[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) {
|
||||
tfSwitch.invalidate()
|
||||
// ✅ invalida só o tenant que estava carregado antes
|
||||
tfSwitch.invalidate(tfSwitch.loadedForTenantId)
|
||||
}
|
||||
|
||||
// entitlements (✅ carrega só quando precisa)
|
||||
@@ -280,6 +686,17 @@ export function applyGuards (router) {
|
||||
await loadEntitlementsSafe(ent, tenantId, true)
|
||||
}
|
||||
|
||||
// ✅ user entitlements: terapeuta pode ter assinatura pessoal (therapist_pro)
|
||||
// que gera features em v_user_entitlements, não em v_tenant_entitlements.
|
||||
// user entitlements: therapist e supervisor têm assinatura pessoal (v_user_entitlements)
|
||||
const activeRoleNormForEnt = normalizeRole(tenant.activeRole)
|
||||
if (['therapist', 'supervisor'].includes(activeRoleNormForEnt) && uid && ent.loadedForUser !== uid) {
|
||||
console.timeLog(tlabel, 'ent.loadForUser')
|
||||
try { await ent.loadForUser(uid) } catch (e) {
|
||||
console.warn('[guards] ent.loadForUser failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// ================================
|
||||
// ✅ tenant_features (módulos ativáveis por clínica)
|
||||
// meta.tenantFeature = 'patients' | ...
|
||||
@@ -288,10 +705,14 @@ export function applyGuards (router) {
|
||||
if (requiredTenantFeature) {
|
||||
const tf = useTenantFeaturesStore()
|
||||
console.timeLog(tlabel, 'tenantFeatures.fetchForTenant')
|
||||
await tf.fetchForTenant(tenantId, { force: false })
|
||||
await fetchTenantFeaturesSafe(tf, tenantId)
|
||||
|
||||
if (!tf.isEnabled(requiredTenantFeature)) {
|
||||
// evita loop
|
||||
// ✅ IMPORTANTÍSSIMO: passa tenantId
|
||||
const enabled = typeof tf.isEnabled === 'function'
|
||||
? tf.isEnabled(requiredTenantFeature, tenantId)
|
||||
: false
|
||||
|
||||
if (!enabled) {
|
||||
if (to.path === '/admin/clinic/features') { console.timeEnd(tlabel); return true }
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
@@ -304,65 +725,73 @@ export function applyGuards (router) {
|
||||
|
||||
// ------------------------------------------------
|
||||
// ✅ RBAC (roles) — BLOQUEIA se não for compatível
|
||||
//
|
||||
// Importante:
|
||||
// - Isso é "papel": se falhar, NÃO é caso de upgrade.
|
||||
// - Só depois disso checamos feature/plano.
|
||||
// ------------------------------------------------
|
||||
const allowedRoles = Array.isArray(to.meta?.roles) ? to.meta.roles : null
|
||||
if (allowedRoles && allowedRoles.length && !allowedRoles.includes(tenant.activeRole)) {
|
||||
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(m.role)
|
||||
m &&
|
||||
m.status === 'active' &&
|
||||
m.tenant_id === tenantId &&
|
||||
allowedRoles.includes(normalizeRole(m.role, m.kind))
|
||||
)
|
||||
|
||||
if (compatible) {
|
||||
// muda role ativo para o compatível (mesmo tenant)
|
||||
tenant.activeRole = compatible.role
|
||||
tenant.activeRole = normalizeRole(compatible.role, compatible.kind)
|
||||
} else {
|
||||
// 🔥 aqui era o "furo": antes ajustava se achasse, mas se não achasse, deixava passar.
|
||||
console.timeEnd(tlabel)
|
||||
return denyByRole({ to, currentRole: tenant.activeRole })
|
||||
}
|
||||
}
|
||||
|
||||
// role guard (singular) - mantém compatibilidade
|
||||
const requiredRole = to.meta?.role
|
||||
if (requiredRole && tenant.activeRole !== requiredRole) {
|
||||
// RBAC singular também é "papel" → cai fora (não é upgrade)
|
||||
// role guard (singular)
|
||||
const requiredRoleRaw = to.meta?.role
|
||||
const requiredRole = requiredRoleRaw ? normalizeRole(requiredRoleRaw) : null
|
||||
|
||||
if (requiredRole && normalizeRole(tenant.activeRole) !== requiredRole) {
|
||||
console.timeEnd(tlabel)
|
||||
return denyByRole({ to, currentRole: tenant.activeRole })
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
// ✅ feature guard (entitlements/plano → upgrade)
|
||||
//
|
||||
// Aqui sim é caso de upgrade:
|
||||
// - o usuário "poderia" usar, mas o plano do tenant não liberou.
|
||||
// ------------------------------------------------
|
||||
const requiredFeature = to.meta?.feature
|
||||
if (requiredFeature && ent?.can && !ent.can(requiredFeature)) {
|
||||
// evita loop
|
||||
if (to.name === 'upgrade') { console.timeEnd(tlabel); return true }
|
||||
|
||||
// Mantém compatibilidade com seu fluxo existente (buildUpgradeUrl)
|
||||
const url = buildUpgradeUrl({
|
||||
missingKeys: [requiredFeature],
|
||||
redirectTo: to.fullPath
|
||||
})
|
||||
missingKeys: [requiredFeature],
|
||||
redirectTo: to.fullPath,
|
||||
role: normalizeRole(tenant.activeRole) // ✅ passa o role para a UpgradePage detectar
|
||||
})
|
||||
|
||||
// Se quiser padronizar no futuro, você pode trocar por:
|
||||
// return denyByPlan({ to, missingFeature: requiredFeature, redirectTo: to.fullPath })
|
||||
console.timeEnd(tlabel)
|
||||
return url
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// ✅ MENU: monta 1x por contexto APÓS estabilizar tenant+role
|
||||
// ======================================================
|
||||
await ensureMenuBuilt({
|
||||
uid,
|
||||
tenantId,
|
||||
tenantRole: tenant.activeRole,
|
||||
globalRole
|
||||
})
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('[guards] erro no beforeEach:', e)
|
||||
|
||||
// fallback seguro
|
||||
if (to.path.startsWith('/auth')) return true
|
||||
if (to.meta?.public) return true
|
||||
if (to.path === '/pages/access') return true
|
||||
@@ -372,19 +801,87 @@ export function applyGuards (router) {
|
||||
}
|
||||
})
|
||||
|
||||
// auth listener (reset caches)
|
||||
// auth listener (reset caches) — ✅ agora com filtro de evento
|
||||
if (!window.__supabaseAuthListenerBound) {
|
||||
window.__supabaseAuthListenerBound = true
|
||||
|
||||
supabase.auth.onAuthStateChange(() => {
|
||||
sessionUidCache = null
|
||||
saasAdminCacheUid = null
|
||||
saasAdminCacheIsAdmin = null
|
||||
supabase.auth.onAuthStateChange((event, sess) => {
|
||||
// ⚠️ NÃO derrubar caches em token refresh / eventos redundantes.
|
||||
const uid = sess?.user?.id || null
|
||||
|
||||
try {
|
||||
const tf = useTenantFeaturesStore()
|
||||
if (typeof tf.invalidate === 'function') tf.invalidate()
|
||||
} catch {}
|
||||
// ✅ SIGNED_OUT: aqui sim zera tudo
|
||||
if (event === 'SIGNED_OUT') {
|
||||
sessionUidCache = null
|
||||
saasAdminCacheUid = null
|
||||
saasAdminCacheIsAdmin = null
|
||||
|
||||
// ✅ FIX: limpa o localStorage de tenant na saída
|
||||
// Sem isso, o próximo login restaura o tenant do usuário anterior.
|
||||
try {
|
||||
localStorage.removeItem('tenant_id')
|
||||
localStorage.removeItem('tenant')
|
||||
localStorage.removeItem('currentTenantId')
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
const tf = useTenantFeaturesStore()
|
||||
if (typeof tf.invalidate === 'function') tf.invalidate()
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const ent = useEntitlementsStore()
|
||||
if (typeof ent.invalidate === 'function') ent.invalidate()
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const tenant = useTenantStore()
|
||||
if (typeof tenant.reset === 'function') tenant.reset()
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const menuStore = useMenuStore()
|
||||
if (typeof menuStore.reset === 'function') menuStore.reset()
|
||||
} catch {}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ TOKEN_REFRESHED: NÃO invalida nada (é o caso clássico de trocar de aba)
|
||||
if (event === 'TOKEN_REFRESHED') return
|
||||
|
||||
// ✅ SIGNED_IN / USER_UPDATED:
|
||||
// só invalida se o usuário mudou de verdade
|
||||
if (event === 'SIGNED_IN' || event === 'USER_UPDATED') {
|
||||
if (uid && sessionUidCache && sessionUidCache === uid) {
|
||||
// mesmo usuário -> não derruba caches
|
||||
return
|
||||
}
|
||||
|
||||
// user mudou (ou cache vazio) -> invalida dependências
|
||||
sessionUidCache = uid || null
|
||||
saasAdminCacheUid = null
|
||||
saasAdminCacheIsAdmin = null
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,17 @@ import { createRouter, createWebHistory, isNavigationFailure, NavigationFailureT
|
||||
|
||||
import configuracoesRoutes from './routes.configs';
|
||||
import meRoutes from './routes.account';
|
||||
import adminRoutes from './routes.admin';
|
||||
import adminRoutes from './routes.clinic';
|
||||
import authRoutes from './routes.auth';
|
||||
import billingRoutes from './routes.billing';
|
||||
import demoRoutes from './routes.demo';
|
||||
import miscRoutes from './routes.misc';
|
||||
import patientRoutes from './routes.portal';
|
||||
import portalRoutes from './routes.portal';
|
||||
import publicRoutes from './routes.public';
|
||||
import saasRoutes from './routes.saas';
|
||||
import therapistRoutes from './routes.therapist';
|
||||
import supervisorRoutes from './routes.supervisor';
|
||||
import editorRoutes from './routes.editor';
|
||||
import featuresRoutes from './routes.features'
|
||||
|
||||
import { applyGuards } from './guards';
|
||||
@@ -24,7 +26,9 @@ const routes = [
|
||||
...(Array.isArray(meRoutes) ? meRoutes : [meRoutes]),
|
||||
...(Array.isArray(adminRoutes) ? adminRoutes : [adminRoutes]),
|
||||
...(Array.isArray(therapistRoutes) ? therapistRoutes : [therapistRoutes]),
|
||||
...(Array.isArray(patientRoutes) ? patientRoutes : [patientRoutes]),
|
||||
...(Array.isArray(supervisorRoutes) ? supervisorRoutes : [supervisorRoutes]),
|
||||
...(Array.isArray(editorRoutes) ? editorRoutes : [editorRoutes]),
|
||||
...(Array.isArray(portalRoutes) ? portalRoutes : [portalRoutes]),
|
||||
...(Array.isArray(demoRoutes) ? demoRoutes : [demoRoutes]),
|
||||
...(Array.isArray(configuracoesRoutes) ? configuracoesRoutes : [configuracoesRoutes]),
|
||||
...(Array.isArray(featuresRoutes) ? featuresRoutes : [featuresRoutes]),
|
||||
|
||||
@@ -3,7 +3,7 @@ import AppLayout from '@/layout/AppLayout.vue'
|
||||
export default {
|
||||
path: '/account',
|
||||
component: AppLayout,
|
||||
meta: { requiresAuth: true },
|
||||
meta: { requiresAuth: true, area: 'account' },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
// src/router/routes.admin.js
|
||||
// src/router/routes.clinic.js
|
||||
import AppLayout from '@/layout/AppLayout.vue'
|
||||
|
||||
export default {
|
||||
path: '/admin',
|
||||
component: AppLayout,
|
||||
|
||||
meta: {
|
||||
meta: {
|
||||
// 🔐 Tudo aqui dentro exige login
|
||||
requiresAuth: true,
|
||||
|
||||
area: 'admin',
|
||||
requiresAuth: true,
|
||||
|
||||
// 👤 Perfil de acesso (tenant-level)
|
||||
// tenantStore normaliza tenant_admin -> clinic_admin, mas mantemos compatibilidade
|
||||
roles: ['clinic_admin', 'tenant_admin']
|
||||
roles: ['clinic_admin']
|
||||
},
|
||||
|
||||
children: [
|
||||
// ======================================================
|
||||
// 📊 DASHBOARD
|
||||
// ======================================================
|
||||
{
|
||||
path: '',
|
||||
name: 'admin-dashboard',
|
||||
component: () => import('@/views/pages/admin/AdminDashboard.vue')
|
||||
},
|
||||
{ path: '', name: 'admin.dashboard', component: () => import('@/views/pages/clinic/ClinicDashboard.vue') },
|
||||
|
||||
// ======================================================
|
||||
// 🧩 CLÍNICA — MÓDULOS (tenant_features)
|
||||
@@ -30,53 +26,54 @@ export default {
|
||||
{
|
||||
path: 'clinic/features',
|
||||
name: 'admin-clinic-features',
|
||||
component: () => import('@/views/pages/admin/clinic/ClinicFeaturesPage.vue'),
|
||||
component: () => import('@/views/pages/clinic/clinic/ClinicFeaturesPage.vue'),
|
||||
meta: {
|
||||
// opcional: restringir apenas para admin canônico
|
||||
roles: ['clinic_admin', 'tenant_admin']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'clinic/professionals',
|
||||
name: 'admin-clinic-professionals',
|
||||
component: () => import('@/views/pages/admin/clinic/ClinicProfessionalsPage.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
roles: ['clinic_admin', 'tenant_admin']
|
||||
}
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 📅 MINHA AGENDA
|
||||
// ======================================================
|
||||
|
||||
// 🔎 Visão geral da agenda
|
||||
{
|
||||
path: 'agenda',
|
||||
name: 'admin-agenda',
|
||||
component: () => import('@/views/pages/admin/agenda/MyAppointmentsPage.vue'),
|
||||
path: 'clinic/professionals',
|
||||
name: 'admin-clinic-professionals',
|
||||
component: () => import('@/views/pages/clinic/clinic/ClinicProfessionalsPage.vue'),
|
||||
meta: {
|
||||
feature: 'agenda.view'
|
||||
requiresAuth: true,
|
||||
roles: ['clinic_admin', 'tenant_admin']
|
||||
}
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 💳 MEU PLANO
|
||||
// ======================================================
|
||||
{
|
||||
path: 'meu-plano',
|
||||
name: 'admin-meu-plano',
|
||||
component: () => import('@/views/pages/billing/ClinicMeuPlanoPage.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 📅 AGENDA DA CLÍNICA
|
||||
// ======================================================
|
||||
|
||||
{
|
||||
path: 'agenda/clinica',
|
||||
name: 'admin-agenda-clinica',
|
||||
component: () => import('@/features/agenda/pages/AgendaClinicaPage.vue'),
|
||||
meta: {
|
||||
feature: 'agenda.view',
|
||||
roles: ['clinic_admin', 'tenant_admin']
|
||||
}
|
||||
},
|
||||
|
||||
// ➕ Adicionar novo compromisso
|
||||
{
|
||||
path: 'agenda/adicionar',
|
||||
name: 'admin-agenda-adicionar',
|
||||
component: () => import('@/views/pages/admin/agenda/NewAppointmentPage.vue'),
|
||||
meta: {
|
||||
feature: 'agenda.manage'
|
||||
}
|
||||
},
|
||||
|
||||
// ✅ NOVO: Compromissos determinísticos (tipos)
|
||||
{
|
||||
path: 'agenda/clinica',
|
||||
name: 'admin-agenda-clinica',
|
||||
component: () => import('@/features/agenda/pages/AgendaClinicaPage.vue'),
|
||||
path: 'agenda/compromissos',
|
||||
name: 'admin-agenda-compromissos',
|
||||
component: () => import('@/features/agenda/pages/CompromissosDeterminados.vue'),
|
||||
meta: {
|
||||
feature: 'agenda.view',
|
||||
roles: ['clinic_admin', 'tenant_admin']
|
||||
// ✅ sem tenantScope: a área /admin já está no tenant da clínica pelo fluxo normal
|
||||
}
|
||||
},
|
||||
|
||||
@@ -171,7 +168,7 @@ export default {
|
||||
{
|
||||
path: 'online-scheduling',
|
||||
name: 'admin-online-scheduling',
|
||||
component: () => import('@/views/pages/admin/OnlineSchedulingAdminPage.vue'),
|
||||
component: () => import('@/views/pages/clinic/OnlineSchedulingAdminPage.vue'),
|
||||
meta: {
|
||||
feature: 'online_scheduling.manage'
|
||||
}
|
||||
@@ -22,11 +22,6 @@ const configuracoesRoutes = {
|
||||
name: 'ConfiguracoesAgenda',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesAgendaPage.vue')
|
||||
}
|
||||
|
||||
// Futuro:
|
||||
// { path: 'clinica', name: 'ConfiguracoesClinica', component: () => import('@/layout/configuracoes/ConfiguracoesClinicaPage.vue') },
|
||||
// { path: 'intake', name: 'ConfiguracoesIntake', component: () => import('@/layout/configuracoes/ConfiguracoesIntakePage.vue') },
|
||||
// { path: 'conta', name: 'ConfiguracoesConta', component: () => import('@/layout/configuracoes/ConfiguracoesContaPage.vue') },
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,7 +4,12 @@ export default {
|
||||
// ✅ não use '/' aqui (conflita com HomeCards)
|
||||
path: '/demo',
|
||||
component: AppLayout,
|
||||
meta: { requiresAuth: true, role: 'tenant_admin' },
|
||||
|
||||
// ✅ DEMO pertence ao backoffice SaaS (somente DEV)
|
||||
// - assim o guard trata como área SaaS e não cai no tenant-app
|
||||
// - remove dependência de role tenant_admin / tenant ativo
|
||||
meta: { requiresAuth: true, saasAdmin: true },
|
||||
|
||||
children: [
|
||||
{ path: 'uikit/formlayout', name: 'uikit-formlayout', component: () => import('@/views/uikit/FormLayout.vue') },
|
||||
{ path: 'uikit/input', name: 'uikit-input', component: () => import('@/views/uikit/InputDoc.vue') },
|
||||
@@ -26,4 +31,4 @@ export default {
|
||||
{ path: 'pages/empty', name: 'pages-empty', component: () => import('@/views/pages/Empty.vue') },
|
||||
{ path: 'pages/crud', name: 'pages-crud', component: () => import('@/views/pages/Crud.vue') }
|
||||
]
|
||||
}
|
||||
}
|
||||
64
src/router/routes.editor.js
Normal file
64
src/router/routes.editor.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// src/router/routes.editor.js
|
||||
//
|
||||
// Área de Editor de Conteúdo — papel de PLATAFORMA.
|
||||
// Acesso controlado por `platform_roles` no guard (não por tenant role).
|
||||
// meta.editorArea: true sinaliza ao guard que use a verificação de plataforma.
|
||||
//
|
||||
import AppLayout from '@/layout/AppLayout.vue'
|
||||
|
||||
export default {
|
||||
path: '/editor',
|
||||
component: AppLayout,
|
||||
|
||||
meta: { area: 'editor', requiresAuth: true, editorArea: true },
|
||||
|
||||
children: [
|
||||
// ======================================================
|
||||
// 📊 DASHBOARD
|
||||
// ======================================================
|
||||
{
|
||||
path: '',
|
||||
name: 'editor.dashboard',
|
||||
component: () => import('@/views/pages/editor/EditorDashboard.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 📚 CURSOS
|
||||
// ======================================================
|
||||
{
|
||||
path: 'cursos',
|
||||
name: 'editor-cursos',
|
||||
// placeholder — módulo de microlearning a implementar
|
||||
component: () => import('@/views/pages/editor/EditorDashboard.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 📦 MÓDULOS
|
||||
// ======================================================
|
||||
{
|
||||
path: 'modulos',
|
||||
name: 'editor-modulos',
|
||||
// placeholder
|
||||
component: () => import('@/views/pages/editor/EditorDashboard.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// ✅ PUBLICADOS
|
||||
// ======================================================
|
||||
{
|
||||
path: 'publicados',
|
||||
name: 'editor-publicados',
|
||||
// placeholder
|
||||
component: () => import('@/views/pages/editor/EditorDashboard.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 💳 MEU PLANO (assinatura pessoal do editor)
|
||||
// ======================================================
|
||||
{
|
||||
path: 'meu-plano',
|
||||
name: 'editor-meu-plano',
|
||||
component: () => import('@/views/pages/billing/TherapistMeuPlanoPage.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,24 +4,22 @@ import AppLayout from '@/layout/AppLayout.vue'
|
||||
export default {
|
||||
path: '/portal',
|
||||
component: AppLayout,
|
||||
meta: { requiresAuth: true, roles: ['patient'] },
|
||||
meta: { area: 'portal', requiresAuth: true, profileRole: 'portal_user' },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'portal-dashboard',
|
||||
component: () => import('@/views/pages/portal/PortalDashboard.vue')
|
||||
{ path: '', name: 'portal.dashboard', component: () => import('@/views/pages/portal/PortalDashboard.vue') },
|
||||
{
|
||||
path: 'sessoes',
|
||||
name: 'portal-sessoes',
|
||||
component: () => import('@/views/pages/portal/MinhasSessoes.vue')
|
||||
},
|
||||
|
||||
// ✅ Appointments (era agenda)
|
||||
// ======================================================
|
||||
// 💳 MEU PLANO (assinatura pessoal do paciente)
|
||||
// ======================================================
|
||||
{
|
||||
path: 'agenda',
|
||||
name: 'portal-agenda',
|
||||
component: () => import('@/views/pages/portal/agenda/MyAppointmentsPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'agenda/new',
|
||||
name: 'portal-agenda-new',
|
||||
component: () => import('@/views/pages/portal/agenda/NewAppointmentPage.vue')
|
||||
path: 'meu-plano',
|
||||
name: 'portal-meu-plano',
|
||||
component: () => import('@/views/pages/billing/TherapistMeuPlanoPage.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -30,6 +30,11 @@ export default {
|
||||
name: 'saas-plan-features',
|
||||
component: () => import('@/views/pages/saas/SaasPlanFeaturesMatrixPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'plan-limits',
|
||||
name: 'saas-plan-limits',
|
||||
component: () => import('@/views/pages/saas/SaasPlanLimitsPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'subscriptions',
|
||||
name: 'saas-subscriptions',
|
||||
@@ -45,7 +50,7 @@ export default {
|
||||
name: 'saas-subscription-health',
|
||||
component: () => import('@/views/pages/saas/SaasSubscriptionHealthPage.vue')
|
||||
},
|
||||
{
|
||||
{
|
||||
path: 'subscription-intents',
|
||||
name: 'saas.subscriptionIntents',
|
||||
component: () => import('@/views/pages/saas/SubscriptionIntentsPage.vue'),
|
||||
@@ -57,4 +62,4 @@ export default {
|
||||
component: () => import('@/views/pages/saas/SaasPlaceholder.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
46
src/router/routes.supervisor.js
Normal file
46
src/router/routes.supervisor.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// src/router/routes.supervisor.js
|
||||
import AppLayout from '@/layout/AppLayout.vue'
|
||||
|
||||
export default {
|
||||
path: '/supervisor',
|
||||
component: AppLayout,
|
||||
|
||||
// tenantScope: 'supervisor' → o guard troca automaticamente para o tenant
|
||||
// com kind='supervisor' quando o usuário navega para esta área.
|
||||
meta: {
|
||||
area: 'supervisor',
|
||||
requiresAuth: true,
|
||||
roles: ['supervisor'],
|
||||
tenantScope: 'supervisor'
|
||||
},
|
||||
|
||||
children: [
|
||||
// ======================================================
|
||||
// 📊 DASHBOARD
|
||||
// ======================================================
|
||||
{
|
||||
path: '',
|
||||
name: 'supervisor.dashboard',
|
||||
component: () => import('@/views/pages/supervisor/SupervisorDashboard.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 🎓 SALA DE SUPERVISÃO
|
||||
// ======================================================
|
||||
{
|
||||
path: 'sala',
|
||||
name: 'supervisor.sala',
|
||||
component: () => import('@/views/pages/supervisor/SupervisaoSalaPage.vue'),
|
||||
meta: { feature: 'supervisor.access' }
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 💳 MEU PLANO
|
||||
// ======================================================
|
||||
{
|
||||
path: 'meu-plano',
|
||||
name: 'supervisor.meu-plano',
|
||||
component: () => import('@/views/pages/billing/TherapistMeuPlanoPage.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,24 +5,13 @@ export default {
|
||||
path: '/therapist',
|
||||
component: AppLayout,
|
||||
|
||||
meta: {
|
||||
// 🔐 Tudo aqui dentro exige login
|
||||
requiresAuth: true,
|
||||
|
||||
// 👤 Perfil de acesso (tenant-level)
|
||||
roles: ['therapist']
|
||||
},
|
||||
meta: { area: 'therapist', requiresAuth: true, roles: ['therapist'] },
|
||||
|
||||
children: [
|
||||
// ======================================================
|
||||
// 📊 DASHBOARD
|
||||
// ======================================================
|
||||
{
|
||||
path: '',
|
||||
name: 'therapist-dashboard',
|
||||
component: () => import('@/views/pages/therapist/TherapistDashboard.vue')
|
||||
// herda requiresAuth + roles do pai
|
||||
},
|
||||
{ path: '', name: 'therapist.dashboard', component: () => import('@/views/pages/therapist/TherapistDashboard.vue') },
|
||||
|
||||
// ======================================================
|
||||
// 📅 AGENDA
|
||||
@@ -30,81 +19,81 @@ export default {
|
||||
{
|
||||
path: 'agenda',
|
||||
name: 'therapist-agenda',
|
||||
//component: () => import('@/views/pages/therapist/agenda/MyAppointmentsPage.vue'),
|
||||
component: () => import('@/features/agenda/pages/AgendaTerapeutaPage.vue'),
|
||||
component: () => import('@/features/agenda/pages/AgendaTerapeutaPage.vue'),
|
||||
meta: {
|
||||
feature: 'agenda.view'
|
||||
}
|
||||
},
|
||||
|
||||
// ✅ Compromissos determinísticos
|
||||
{
|
||||
path: 'agenda/adicionar',
|
||||
name: 'therapist-agenda-adicionar',
|
||||
component: () => import('@/views/pages/therapist/agenda/NewAppointmentPage.vue'),
|
||||
path: 'agenda/compromissos',
|
||||
name: 'therapist-agenda-compromissos',
|
||||
component: () => import('@/features/agenda/pages/CompromissosDeterminados.vue'),
|
||||
meta: {
|
||||
feature: 'agenda.manage'
|
||||
feature: 'agenda.view',
|
||||
roles: ['therapist']
|
||||
// ✅ sem tenantScope
|
||||
}
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// ======================================================
|
||||
// 💳 MEU PLANO
|
||||
// ======================================================
|
||||
{
|
||||
path: 'meu-plano',
|
||||
name: 'therapist-meu-plano',
|
||||
component: () => import('@/views/pages/billing/TherapistMeuPlanoPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'upgrade',
|
||||
name: 'therapist-upgrade',
|
||||
component: () => import('@/views/pages/billing/TherapistUpgradePage.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 👥 PATIENTS
|
||||
// ======================================================
|
||||
|
||||
{
|
||||
path: 'patients',
|
||||
name: 'therapist-patients',
|
||||
component: () => import('@/features/patients/PatientsListPage.vue')
|
||||
},
|
||||
|
||||
// ➕ Create patient
|
||||
{
|
||||
path: 'patients/cadastro',
|
||||
name: 'therapist-patients-create',
|
||||
component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue')
|
||||
},
|
||||
|
||||
{
|
||||
path: 'patients/cadastro/:id',
|
||||
name: 'therapist-patients-edit',
|
||||
component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue'),
|
||||
props: true
|
||||
},
|
||||
|
||||
// 👥 Groups
|
||||
{
|
||||
path: 'patients/grupos',
|
||||
name: 'therapist-patients-groups',
|
||||
component: () => import('@/features/patients/grupos/GruposPacientesPage.vue')
|
||||
},
|
||||
|
||||
// 🏷️ Tags
|
||||
{
|
||||
path: 'patients/tags',
|
||||
name: 'therapist-patients-tags',
|
||||
component: () => import('@/features/patients/tags/TagsPage.vue')
|
||||
},
|
||||
|
||||
// 🔗 External Link
|
||||
{
|
||||
path: 'patients/link-externo',
|
||||
name: 'therapist-patients-link-externo',
|
||||
component: () => import('@/features/patients/cadastro/PatientsExternalLinkPage.vue')
|
||||
},
|
||||
|
||||
// 📥 Received Registrations
|
||||
{
|
||||
path: 'patients/cadastro/recebidos',
|
||||
name: 'therapist-patients-recebidos',
|
||||
component: () =>
|
||||
import('@/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue')
|
||||
component: () => import('@/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue')
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 🔒 PRO — Online Scheduling (gestão interna)
|
||||
// 🔒 PRO — Online Scheduling
|
||||
// ======================================================
|
||||
// feature gate via meta.feature:
|
||||
// - bloqueia rota (guard)
|
||||
// - menu pode desabilitar/ocultar (entitlementsStore.has)
|
||||
{
|
||||
path: 'online-scheduling',
|
||||
name: 'therapist-online-scheduling',
|
||||
@@ -115,23 +104,12 @@ export default {
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 🔐 SECURITY (temporário dentro da área)
|
||||
// 🔐 SECURITY
|
||||
// ======================================================
|
||||
// ⚠️ Idealmente mover para /account/security (área global)
|
||||
{
|
||||
path: 'settings/security',
|
||||
name: 'therapist-settings-security',
|
||||
component: () => import('@/views/pages/auth/SecurityPage.vue')
|
||||
}
|
||||
|
||||
// ======================================================
|
||||
// 🔒 PRO — Online Scheduling (configuração pública)
|
||||
// ======================================================
|
||||
// {
|
||||
// path: 'online-scheduling/public',
|
||||
// name: 'therapist-online-scheduling-public',
|
||||
// component: () => import('@/views/pages/therapist/OnlineSchedulingPublicPage.vue'),
|
||||
// meta: { feature: 'online_scheduling.public' }
|
||||
// }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user