Ajuste rotas, Menus, Layout, Permissãoes UserRoleGuard

This commit is contained in:
Leonardo
2026-02-24 12:04:59 -03:00
parent b1c0cb47c0
commit d58dc21297
15 changed files with 1925 additions and 259 deletions

View File

@@ -0,0 +1,71 @@
// src/router/accessRedirects.js
/**
* Redirecionamentos padronizados para:
* - RBAC (papel): usuário NÃO deveria acessar essa área nem pagando → manda pra 403 (ou home do papel)
* - Entitlements (plano): usuário poderia acessar se tivesse feature → manda pro /upgrade
*
* Por que isso existe?
* - Evitar o bug clássico: “pessoa sem permissão caiu no /upgrade”
* - Padronizar o comportamento do app em um único lugar
* - Deixar claro: RBAC ≠ Plano
*
* Convenção recomendada:
* - RBAC (role): sempre é bloqueio (403) OU home do papel (UX mais “suave”)
* - Plano (feature): sempre é upgrade (porque o usuário *poderia* ter acesso pagando)
*/
/**
* Home por role (tenant_members.role).
* Obs: 'admin' é role GLOBAL (profiles.role). Aqui é guard de tenant,
* mas mantemos um fallback seguro para legado.
*/
export function roleHomePath (role) {
// ✅ clínica: aceita nomes canônicos e legado
if (role === 'clinic_admin' || role === 'tenant_admin') return '/admin'
if (role === 'therapist') return '/therapist'
if (role === 'patient') return '/portal'
// ✅ fallback (não deveria acontecer em tenant)
if (role === 'admin') return '/admin'
return '/'
}
/**
* RBAC (papel) → padrão: acesso negado (403).
*
* Se você preferir UX “suave”, pode mandar para a home do papel.
* Eu deixei as duas opções:
* - use403 = true → sempre /pages/access (recomendado para clareza)
* - use403 = false → home do papel (útil quando você quer “auto-corrigir” navegação)
*/
export function denyByRole ({ to, currentRole, use403 = true } = {}) {
// ✅ padrão forte: 403 (não é caso de upgrade)
if (use403) return { path: '/pages/access' }
// modo “suave”: manda pra home do papel
const fallback = roleHomePath(currentRole)
// evita loop: se já está no fallback, manda pra página de acesso negado
if (to?.path && to.path === fallback) {
return { path: '/pages/access' }
}
return { path: fallback }
}
/**
* Entitlements (plano) → upgrade.
* missingFeature: feature key (ex: 'online_scheduling.manage')
* redirectTo: para onde voltar após upgrade
*/
export function denyByPlan ({ to, missingFeature, redirectTo } = {}) {
return {
path: '/upgrade',
query: {
feature: missingFeature || '',
redirectTo: redirectTo || to?.fullPath || '/'
}
}
}

View File

@@ -11,6 +11,9 @@ import { buildUpgradeUrl } from '@/utils/upgradeContext'
import { sessionUser, sessionReady, sessionRefreshing, initSession } from '@/app/session'
// ✅ separa RBAC (papel) vs Plano (upgrade)
import { denyByRole, denyByPlan } from '@/router/accessRedirects'
// cache simples (evita bater no banco em toda navegação)
let sessionUidCache = null
@@ -234,14 +237,14 @@ export function applyGuards (router) {
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))
: null
// 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))
: null
// 2) fallback: primeiro active
const firstActive = preferred || mem.find(m => m && m.status === 'active' && m.tenant_id)
// 2) fallback: primeiro active
const firstActive = preferred || mem.find(m => m && m.status === 'active' && m.tenant_id)
if (!firstActive) {
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
@@ -299,42 +302,60 @@ export function applyGuards (router) {
}
}
// roles guard (plural)
// Se a rota pede roles específicas e o role ativo não bate,
// tenta ajustar o activeRole dentro do mesmo tenant (se houver membership compatível).
const allowedRoles = Array.isArray(to.meta?.roles) ? to.meta.roles : null
if (allowedRoles && allowedRoles.length && !allowedRoles.includes(tenant.activeRole)) {
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)
)
if (compatible) {
// muda role ativo para o compatível
tenant.activeRole = compatible.role
}
}
// ------------------------------------------------
// ✅ 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 mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
const compatible = mem.find(m =>
m && m.status === 'active' && m.tenant_id === tenantId && allowedRoles.includes(m.role)
)
if (compatible) {
// muda role ativo para o compatível (mesmo tenant)
tenant.activeRole = compatible.role
} 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) {
const fallback = roleToPath(tenant.activeRole)
if (to.path === fallback) { console.timeEnd(tlabel); return { path: '/pages/access' } }
// RBAC singular também é "papel" → cai fora (não é upgrade)
console.timeEnd(tlabel)
return { path: fallback }
return denyByRole({ to, currentRole: tenant.activeRole })
}
// feature guard (entitlements/plano → upgrade)
// ------------------------------------------------
// ✅ 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)) {
if (to.name === 'upgrade') { console.timeEnd(tlabel); return true }
if (requiredFeature && ent?.can && !ent.can(requiredFeature)) {
// evita loop
if (to.name === 'upgrade') { console.timeEnd(tlabel); return true }
const url = buildUpgradeUrl({
missingKeys: [requiredFeature],
redirectTo: to.fullPath
})
console.timeEnd(tlabel)
return url
}
// Mantém compatibilidade com seu fluxo existente (buildUpgradeUrl)
const url = buildUpgradeUrl({
missingKeys: [requiredFeature],
redirectTo: to.fullPath
})
// Se quiser padronizar no futuro, você pode trocar por:
// return denyByPlan({ to, missingFeature: requiredFeature, redirectTo: to.fullPath })
console.timeEnd(tlabel)
return url
}
console.timeEnd(tlabel)
return true

View File

@@ -38,7 +38,7 @@ const routes = [
})
},
{ path: '/:pathMatch(.*)*', name: 'notfound', component: () => import('@/views/pages/NotFound.vue') }
// inserido no routes.misc { path: '/:pathMatch(.*)*', name: 'notfound', component: () => import('@/views/pages/NotFound.vue') }
];
const router = createRouter({

View File

@@ -6,10 +6,28 @@ export default {
name: 'landing',
component: () => import('@/views/pages/Landing.vue')
},
// 404
{
path: 'pages/notfound',
name: 'notfound',
component: () => import('@/views/pages/NotFound.vue')
},
// 403 (Acesso negado - RBAC)
{
path: 'pages/access', // ❗ SEM barra inicial aqui
name: 'AccessDenied',
component: () => import('@/views/pages/misc/AccessDeniedPage.vue'),
meta: {
requiresAuth: true
}
},
// Catch-all (SEMPRE o último)
{
path: ':pathMatch(.*)*',
redirect: { name: 'notfound' }
}
]
}
}