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

@@ -1,3 +1,50 @@
/**
* ---------------------------------------------------------
* useAuth()
* ---------------------------------------------------------
*
* Stack do projeto:
* - Vue 3 (Composition API)
* - PrimeVue (UI)
* - Supabase (Auth + Database)
*
* Responsabilidade:
* Camada global de AUTENTICAÇÃO baseada no Supabase.
*
* O que este composable faz:
* - Obtém a sessão atual do Supabase (auth.getSession)
* - Mantém o estado reativo do usuário autenticado
* - Escuta mudanças de autenticação (login, logout, refresh de token)
* - Expõe apenas a identidade do usuário (user)
*
* O que ele NÃO faz:
* - Não controla permissões
* - Não valida roles
* - Não decide acesso a telas ou botões
* - Não aplica regras de plano (Free/Pro)
*
* Conceito arquitetural:
* Este arquivo trata apenas de IDENTIDADE (Auth).
*
* Auth → "Quem é o usuário autenticado?"
* AuthZ → "O que esse usuário pode acessar ou executar?"
*
* A AUTORIZAÇÃO deve ser tratada em outra camada,
* como por exemplo:
* - useAuthz()
* - tenantStore (membership.role)
* - entitlementsStore (features do plano)
*
* Observação importante:
* O role do usuário NÃO vem do Supabase Auth.
* Ele é definido na tabela de membership (multi-tenant).
*
* Portanto:
* Nunca utilizar apenas `user` para controle de acesso.
*
* Esse composable é apenas a base de identidade do sistema.
*/
import { ref, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'

View File

@@ -0,0 +1,115 @@
import { computed } from 'vue'
import { useTenantStore } from '@/stores/tenantStore'
/**
* ---------------------------------------------------------
* useRoleGuard() — RBAC puro (somente PAPEL do tenant)
* ---------------------------------------------------------
*
* Objetivo:
* Controlar visibilidade/ações por PAPEL dentro do tenant (clínica).
* Aqui NÃO entra plano, módulos ou features pagas.
*
* Fonte da verdade do papel (tenant role):
* - public.tenant_members.role → 'tenant_admin' | 'therapist' | 'patient'
* - no frontend: tenantStore.membership.role (ou fallback tenantStore.activeRole)
*
* O que este composable resolve:
* - "Esse papel pode ver/usar este elemento?"
* Ex:
* - paciente não vê botão Configurações
* - therapist e tenant_admin veem
*
* O que ele NÃO resolve (de propósito):
* - liberar feature por plano (Free/Pro)
* - limitar módulos / recursos contratados
*
* Para controle por plano, use o entStore:
* - entStore.can('feature_key')
*
* Padrão recomendado (RBAC + Plano):
* Quando algo depende do PLANO e do PAPEL, combine no template:
*
* v-if="entStore.can('online_scheduling.manage') && canSee('settings.view')"
*
* Interpretação:
* - Gate A (Plano): o tenant tem a feature liberada?
* - Gate B (Papel): o usuário, pelo papel, pode ver/usar isso?
*
* Nota de segurança:
* Isso controla UI/rotas (experiência). Segurança real deve existir no backend (RLS).
* ---------------------------------------------------------
*/
export function useRoleGuard () {
const tenantStore = useTenantStore()
// Roles confirmados no seu banco (tenant_members.role)
const ROLES = Object.freeze({
ADMIN: 'tenant_admin',
THERAPIST: 'therapist',
PATIENT: 'patient'
})
// Papel atual no tenant ativo
const role = computed(() => tenantStore.membership?.role ?? tenantStore.activeRole ?? null)
// Opcional: útil se você quiser segurar render até carregar
const isReady = computed(() => !!role.value)
// Helpers semânticos
const isTenantAdmin = computed(() => role.value === ROLES.ADMIN)
const isTherapist = computed(() => role.value === ROLES.THERAPIST)
const isPatient = computed(() => role.value === ROLES.PATIENT)
const isStaff = computed(() => [ROLES.ADMIN, ROLES.THERAPIST].includes(role.value))
// Matriz RBAC (somente por papel)
// Dica: mantenha chaves no padrão "modulo.acao"
const rbac = Object.freeze({
// Botões/telas de configuração do tenant
'settings.view': [ROLES.ADMIN, ROLES.THERAPIST],
// Perfil/conta (normalmente todos)
'profile.view': [ROLES.ADMIN, ROLES.THERAPIST, ROLES.PATIENT],
// Segurança (normalmente todos; ajuste se quiser restringir)
'security.view': [ROLES.ADMIN, ROLES.THERAPIST, ROLES.PATIENT]
// Exemplos futuros:
// 'agenda.view': [ROLES.ADMIN, ROLES.THERAPIST, ROLES.PATIENT],
// 'agenda.manage': [ROLES.ADMIN, ROLES.THERAPIST],
})
/**
* canSee(key)
* Retorna true se o PAPEL atual estiver autorizado para a chave RBAC.
*
* Política segura:
* - se não carregou role → false
* - se não existe mapeamento pra key → false
*/
function canSee (key) {
const r = role.value
if (!r) return false
const allowed = rbac[key]
if (!allowed) return false
return allowed.includes(r)
}
return {
// estado
role,
isReady,
// constantes & helpers
ROLES,
isTenantAdmin,
isTherapist,
isPatient,
isStaff,
// API
canSee
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -47,10 +47,37 @@
v-model="search"
class="w-full"
autocomplete="off"
@keyup.enter="openSearchModal"
/>
</IconField>
<label for="agendaSearch">Buscar paciente...</label>
</FloatLabel>
<!-- Mobile: botão para abrir resultados (não duplica sidebar) -->
<div class="sm:hidden mt-2">
<Button
v-if="searchTrim"
:label="`Resultados (${searchResults.length})`"
icon="pi pi-list"
severity="secondary"
outlined
class="w-full rounded-full"
@click="openSearchModal"
/>
</div>
<!-- Chip/atalho rápido (desktop + mobile) -->
<div v-if="searchTrim" class="mt-2 flex items-center gap-2 flex-wrap">
<Tag :value="`Busca: ${searchTrim}`" severity="secondary" />
<Button
label="Limpar"
icon="pi pi-times"
text
severity="secondary"
class="p-0"
@click="clearSearch"
/>
</div>
</div>
<div class="flex items-center gap-2">
@@ -131,10 +158,10 @@
</div>
<!-- Layout: 2 colunas -->
<div class="grid">
<div class="flex flex-col lg:flex-row gap-4">
<!-- Coluna maior: FullCalendar -->
<div class="col-12 lg:col-8 xl:col-9">
<div class="overflow-hidden rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
<div class="w-full lg:flex-1 lg:order-2 min-w-0">
<div class="overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
<div class="p-2">
<!-- key força reaplicar slotMin/Max quando trocar modo -->
<FullCalendar ref="fcRef" :key="fcKey" :options="fcOptions" />
@@ -143,7 +170,70 @@
</div>
<!-- Coluna menor -->
<div class="col-12 lg:col-4 xl:col-3">
<div class="w-full lg:basis-[24%] lg:max-w-[24%] lg:order-1">
<!-- Resultados (DESKTOP): não aparece no mobile para evitar duplicação -->
<div
v-if="searchTrim"
class="hidden sm:block mb-3 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 shadow-sm"
>
<div class="mb-2 flex items-center justify-between gap-2">
<div class="min-w-0">
<div class="font-semibold truncate">Resultados</div>
<small class="text-color-secondary truncate">
para {{ searchTrim }}
</small>
</div>
<div class="flex items-center gap-2">
<Tag
v-if="!searchLoading"
:value="`${searchResults.length}`"
severity="secondary"
/>
<Button
icon="pi pi-times"
severity="secondary"
text
class="h-9 w-9 rounded-full"
v-tooltip.top="'Limpar busca'"
@click="clearSearch"
/>
</div>
</div>
<div v-if="searchLoading" class="text-color-secondary text-sm">
Buscando
</div>
<div v-else-if="searchResults.length === 0" class="text-color-secondary text-sm">
Nenhum resultado encontrado.
</div>
<div v-else class="flex flex-col gap-2 max-h-[360px] overflow-auto pr-1">
<button
v-for="r in searchResults"
:key="r.id"
class="text-left rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-0)]/40 p-3 transition hover:shadow-sm"
@click="gotoResult(r)"
>
<div class="font-medium truncate">
{{ r.titulo || 'Sem título' }}
</div>
<div class="mt-1 flex items-center justify-between gap-2 text-xs opacity-70">
<span class="truncate">
{{ fmtDateTime(r.inicio_em) }}
</span>
<Tag :value="labelTipo(r.tipo)" severity="info" />
</div>
<div v-if="r.observacoes" class="mt-1 text-xs text-color-secondary truncate">
{{ r.observacoes }}
</div>
</button>
</div>
</div>
<div class="mb-3 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 shadow-sm">
<div class="mb-2 flex items-center justify-between">
<span class="font-semibold">Calendário</span>
@@ -227,6 +317,83 @@
</div>
</div>
<!-- Dialog Resultados (MOBILE) -->
<Dialog
v-model:visible="searchModalOpen"
modal
header="Resultados da busca"
:style="{ width: '96vw', maxWidth: '720px' }"
:breakpoints="{ '960px': '92vw', '640px': '96vw' }"
:draggable="false"
>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<div class="font-semibold truncate">Para {{ searchTrim }}</div>
<small class="text-color-secondary">
{{ searchResults.length }} resultado(s)
</small>
</div>
<Button
icon="pi pi-eraser"
severity="secondary"
text
class="h-9 w-9 rounded-full"
v-tooltip.top="'Limpar busca'"
@click="clearSearchAndClose"
/>
</div>
<Divider />
<div v-if="searchLoading" class="text-color-secondary text-sm">
Buscando
</div>
<div v-else-if="searchResults.length === 0" class="text-color-secondary text-sm">
Nenhum resultado encontrado.
</div>
<div v-else class="flex flex-col gap-2 max-h-[65vh] overflow-auto pr-1">
<button
v-for="r in searchResults"
:key="r.id"
class="text-left rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-0)]/40 p-3 transition hover:shadow-sm"
@click="gotoResultFromModal(r)"
>
<div class="font-medium truncate">
{{ r.titulo || 'Sem título' }}
</div>
<div class="mt-1 flex items-center justify-between gap-2 text-xs opacity-70">
<span class="truncate">
{{ fmtDateTime(r.inicio_em) }}
</span>
<Tag :value="labelTipo(r.tipo)" severity="info" />
</div>
<div v-if="r.observacoes" class="mt-1 text-xs text-color-secondary truncate">
{{ r.observacoes }}
</div>
</button>
</div>
</div>
<template #footer>
<Button label="Fechar" icon="pi pi-times" text @click="searchModalOpen = false" />
<Button
v-if="searchTrim"
label="Limpar"
icon="pi pi-eraser"
severity="secondary"
outlined
class="rounded-full"
@click="clearSearchAndClose"
/>
</template>
</Dialog>
<!-- Month Picker -->
<Dialog v-model:visible="monthPickerVisible" modal header="Escolher mês" :style="{ width: '420px' }">
<div class="p-2">
@@ -302,6 +469,8 @@ import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import SelectButton from 'primevue/selectbutton'
import SplitButton from 'primevue/splitbutton'
import Tag from 'primevue/tag'
import Divider from 'primevue/divider'
import FullCalendar from '@fullcalendar/vue3'
import timeGridPlugin from '@fullcalendar/timegrid'
@@ -348,6 +517,9 @@ const calendarView = ref('day') // day | week | month
const timeMode = ref('my') // 24 | 12 | my
const search = ref('')
// Modal resultados (mobile)
const searchModalOpen = ref(false)
// Mini calendário
const miniDate = ref(new Date())
@@ -440,23 +612,50 @@ const fcViewName = computed(() => {
return 'dayGridMonth'
})
const filteredRows = computed(() => {
/* -------------------------------------------------
✅ Correção:
- calendário NÃO filtra por search
- search vira lista de resultados (desktop sidebar / mobile dialog)
-------------------------------------------------- */
const calendarRows = computed(() => {
const list = rows.value || []
const q = (search.value || '').trim().toLowerCase()
return list.filter(r => {
const tipo = String(r.tipo || '').toLowerCase()
const titulo = String(r.titulo || '').toLowerCase()
const obs = String(r.observacoes || '').toLowerCase()
if (onlySessions.value && !tipo.includes('sess')) return false
if (q && !(titulo.includes(q) || obs.includes(q))) return false
return true
})
})
const searchTrim = computed(() => String(search.value || '').trim())
const searchLoading = computed(() => false) // placeholder (se quiser debounce/async)
const searchResults = computed(() => {
const q = searchTrim.value.toLowerCase()
if (!q) return []
return (calendarRows.value || []).filter(r => {
const tipo = String(r.tipo || '').toLowerCase()
const titulo = String(r.titulo || '').toLowerCase()
const obs = String(r.observacoes || '').toLowerCase()
// Se seu row tiver campos do paciente, plugue aqui:
const pacienteNome = String(r.paciente_nome || r.patient_name || r.nome_paciente || '').toLowerCase()
const pacienteEmail = String(r.paciente_email || r.patient_email || '').toLowerCase()
const pacienteTel = String(r.paciente_phone || r.patient_phone || '').toLowerCase()
return (
titulo.includes(q) ||
obs.includes(q) ||
tipo.includes(q) ||
pacienteNome.includes(q) ||
pacienteEmail.includes(q) ||
pacienteTel.includes(q)
)
})
})
const calendarEvents = computed(() => {
const base = mapAgendaEventosToCalendarEvents(filteredRows.value || [])
const base = mapAgendaEventosToCalendarEvents(calendarRows.value || [])
const breaks =
settings.value && currentRange.value.start && currentRange.value.end
@@ -537,11 +736,21 @@ const fcOptions = computed(() => ({
eventDrop: (info) => persistMoveOrResize(info, 'Movido'),
eventResize: (info) => persistMoveOrResize(info, 'Redimensionado'),
// ✅ destaque da busca sem remover eventos (melhoria UX)
eventClassNames: (arg) => {
const tipo = String(arg?.event?.extendedProps?.tipo || arg?.event?.extendedProps?.kind || '').toLowerCase()
if (tipo.includes('sess')) return ['evt-session']
if (tipo.includes('bloq')) return ['evt-block']
return []
const title = String(arg?.event?.title || '').toLowerCase()
const obs = String(arg?.event?.extendedProps?.observacoes || '').toLowerCase()
const q = searchTrim.value.toLowerCase()
const hit = q && (title.includes(q) || obs.includes(q) || tipo.includes(q))
const classes = []
if (tipo.includes('sess')) classes.push('evt-session')
if (tipo.includes('bloq')) classes.push('evt-block')
if (q && hit) classes.push('evt-hit')
if (q && !hit) classes.push('evt-dim')
return classes
}
}))
@@ -554,6 +763,11 @@ watch(calendarView, async () => {
getApi()?.changeView?.(fcViewName.value)
})
// se limpou a busca, fecha modal mobile automaticamente
watch(searchTrim, (v) => {
if (!v) searchModalOpen.value = false
})
// -----------------------------
// Ações Topbar
// -----------------------------
@@ -561,6 +775,17 @@ function goToday () { getApi()?.today?.() }
function goPrev () { getApi()?.prev?.() }
function goNext () { getApi()?.next?.() }
function clearSearch () { search.value = '' }
function clearSearchAndClose () {
search.value = ''
searchModalOpen.value = false
}
function openSearchModal () {
if (!searchTrim.value) return
searchModalOpen.value = true
}
function toggleMonthPicker () {
monthPickerDate.value = new Date(currentDate.value)
monthPickerVisible.value = true
@@ -580,6 +805,40 @@ function onMiniPick (d) {
function miniPrevMonth () { miniDate.value = shiftMonth(miniDate.value, -1) }
function miniNextMonth () { miniDate.value = shiftMonth(miniDate.value, +1) }
/* -----------------------------
Clique no resultado:
- vai para o dia do evento
- abre o dialog (edit)
------------------------------ */
function gotoResult (row) {
const api = getApi()
if (api && row?.inicio_em) api.gotoDate(new Date(row.inicio_em))
dialogEventRow.value = row
dialogStartISO.value = ''
dialogEndISO.value = ''
dialogOpen.value = true
}
function gotoResultFromModal (row) {
searchModalOpen.value = false
nextTick(() => gotoResult(row))
}
function fmtDateTime (iso) {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' })
}
function labelTipo (tipo) {
const t = String(tipo || '').toLowerCase()
if (t.includes('sess')) return 'Sessão'
if (t.includes('bloq')) return 'Bloqueio'
if (t.includes('avali')) return 'Avaliação'
return (tipo || 'Evento')
}
function onCreateFromButton () {
if (!ownerId.value) {
toast.add({ severity: 'warn', summary: 'Agenda', detail: 'Aguarde carregar as configurações da agenda.', life: 3000 })
@@ -776,10 +1035,16 @@ onMounted(async () => {
if (settingsError.value) {
toast.add({ severity: 'warn', summary: 'Agenda', detail: settingsError.value, life: 4500 })
}
// opcional: refletir modo salvo no banco
// if (settings.value?.agenda_view_mode) {
// timeMode.value = settings.value.agenda_view_mode === 'full_24h' ? '24' : 'my'
// }
})
</script>
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active { transition: opacity .14s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
/* ✅ destaque da busca sem remover eventos */
.evt-dim { opacity: .25; }
.evt-hit { opacity: 1; }
</style>

View File

@@ -7,11 +7,39 @@ import { useConfirm } from 'primevue/useconfirm'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const tenantStore = useTenantStore()
async function getCurrentTenantId () {
// ajuste para o nome real no seu store
return tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
}
async function getCurrentMemberId (tenantId) {
const { data: authData, error: authError } = await supabase.auth.getUser()
if (authError) throw authError
const uid = authData?.user?.id
if (!uid) throw new Error('Sessão inválida.')
const { data, error } = await supabase
.from('tenant_members')
.select('id')
.eq('tenant_id', tenantId)
.eq('user_id', uid)
.eq('status', 'active')
.single()
if (error) throw error
if (!data?.id) throw new Error('Responsible member not found')
return data.id
}
// ------------------------------------------------------
// Accordion: abre 1 por vez + scroll
// ------------------------------------------------------
@@ -400,6 +428,8 @@ async function getOwnerId () {
// ------------------------------------------------------
const PACIENTES_COLUNAS_PERMITIDAS = new Set([
'owner_id',
'tenant_id',
'responsible_member_id',
// Sessão 1
'nome_completo',
@@ -688,12 +718,14 @@ async function createPatient (payload) {
}
async function updatePatient (id, payload) {
const ownerId = await getOwnerId()
const { error } = await supabase
.from('patients')
.update({ ...payload, updated_at: new Date().toISOString() })
.update({
...payload,
updated_at: new Date().toISOString()
})
.eq('id', id)
.eq('owner_id', ownerId)
if (error) throw error
}
@@ -840,38 +872,55 @@ async function fetchAll () {
watch(() => route.params?.id, fetchAll, { immediate: true })
onMounted(fetchAll)
async function resolveTenantContextOrFail () {
const { data: authData, error: authError } = await supabase.auth.getUser()
if (authError) throw authError
const uid = authData?.user?.id
if (!uid) throw new Error('Sessão inválida.')
const { data, error } = await supabase
.from('tenant_members')
.select('id, tenant_id')
.eq('user_id', uid)
.eq('status', 'active')
.order('created_at', { ascending: false }) // se existir
.limit(1)
.single()
if (error) throw error
if (!data?.tenant_id || !data?.id) throw new Error('Responsible member not found')
return { tenantId: data.tenant_id, memberId: data.id }
}
// ------------------------------------------------------
// Submit
// ------------------------------------------------------
async function onSubmit () {
if (saving.value) return
// validações...
saving.value = true
try {
saving.value = true
const ownerId = await getOwnerId()
const { tenantId, memberId } = await resolveTenantContextOrFail()
const payload = sanitizePayload(form.value, ownerId)
let id = patientId.value
// multi-tenant obrigatório
payload.tenant_id = tenantId
payload.responsible_member_id = memberId
if (isEdit.value) {
await updatePatient(id, payload)
await updatePatient(patientId.value, payload)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente atualizado.', life: 2500 })
} else {
const created = await createPatient(payload)
id = created?.id
if (!id) throw new Error('Falha ao obter ID do paciente criado.')
router.replace(`${getAreaBase()}/patients/cadastro/${id}`)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente cadastrado.', life: 2500 })
// opcional: router.push(`${getAreaBase()}/patients/${created.id}`)
}
await replacePatientGroups(id, grupoIdSelecionado.value || null)
await replacePatientTags(id, tagIdsSelecionadas.value || [])
// Avatar por último, mas dentro do mesmo fluxo (sem toast de sucesso)
await maybeUploadAvatar(ownerId, id)
// ✅ um sucesso só
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente salvo.', life: 2500 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao salvar paciente', life: 4000 })
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar paciente.', life: 4000 })
} finally {
saving.value = false
}

View File

@@ -5,13 +5,19 @@ import { useRouter } from 'vue-router'
import { sessionUser, sessionRole } from '@/app/session'
import { supabase } from '@/lib/supabase/client'
import { useRoleGuard } from '@/composables/useRoleGuard'
const router = useRouter()
const pop = ref(null)
function isAdminRole (r) {
return r === 'admin' || r === 'tenant_admin'
}
// ------------------------------------------------------
// RBAC (Tenant): fonte da verdade para permissões por papel
// ------------------------------------------------------
const { role, canSee, isPatient } = useRoleGuard()
// ------------------------------------------------------
// UI labels (nome/iniciais)
// ------------------------------------------------------
const initials = computed(() => {
const name = sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || ''
const parts = String(name).trim().split(/\s+/).filter(Boolean)
@@ -25,15 +31,30 @@ const label = computed(() => {
return name || sessionUser.value?.email || 'Conta'
})
/**
* sublabel:
* Aqui eu recomendo exibir o papel do TENANT (role do useRoleGuard),
* porque é ele que realmente governa a UI dentro da clínica.
*
* Se você preferir manter sessionRole como rótulo "global", ok,
* mas isso pode confundir quando o usuário estiver em contextos diferentes.
*/
const sublabel = computed(() => {
const r = sessionRole.value
const r = role.value || sessionRole.value
if (!r) return 'Sessão'
if (isAdminRole(r)) return 'Administrador'
// tenant roles (confirmados no banco): tenant_admin | therapist | patient
if (r === 'tenant_admin') return 'Administrador'
if (r === 'therapist') return 'Terapeuta'
if (r === 'patient') return 'Paciente'
// fallback (caso venha algo diferente)
return r
})
// ------------------------------------------------------
// Popover helpers
// ------------------------------------------------------
function toggle (e) {
pop.value?.toggle(e)
}
@@ -44,35 +65,9 @@ function close () {
} catch {}
}
function goMyProfile() {
close()
// Navegação segura para Account → Profile
safePush(
{ name: 'account-profile' },
'/account/profile'
)
}
function goSettings () {
close()
const r = sessionRole.value
if (isAdminRole(r) || r === 'therapist') {
// rota por name (como você já usa)
router.push({ name: 'ConfiguracoesAgenda' })
return
}
if (r === 'patient') {
router.push('/patient/portal')
return
}
router.push('/')
}
// ------------------------------------------------------
// Navegação segura (NAME com fallback)
// ------------------------------------------------------
async function safePush (target, fallback) {
try {
await router.push(target)
@@ -90,6 +85,35 @@ async function safePush (target, fallback) {
}
}
function goMyProfile () {
close()
// Navegação segura para Account → Profile
safePush(
{ name: 'account-profile' },
'/account/profile'
)
}
function goSettings () {
close()
// ✅ Decide por RBAC (tenant role), não por sessionRole
if (canSee('settings.view')) {
router.push({ name: 'ConfiguracoesAgenda' })
return
}
// Se não pode ver configurações, manda paciente pro portal.
// (Se amanhã você criar outro papel, esta regra continua segura.)
if (isPatient.value) {
router.push('/patient/portal')
return
}
router.push('/')
}
function goSecurity () {
close()
@@ -150,6 +174,7 @@ async function signOut () {
<Popover ref="pop" appendTo="body">
<div class="min-w-[220px] p-1">
<Button
v-if="canSee('settings.view')"
label="Configurações"
icon="pi pi-cog"
text
@@ -164,14 +189,14 @@ async function signOut () {
class="w-full justify-start"
@click="goSecurity"
/>
<Button
label="Meu Perfil"
icon="pi pi-user"
text
class="w-full justify-start"
@click="goMyProfile"
/>
<Button
label="Meu Perfil"
icon="pi pi-user"
text
class="w-full justify-start"
@click="goMyProfile"
/>
<div class="my-1 border-t border-[var(--surface-border)]" />
@@ -186,4 +211,4 @@ async function signOut () {
</div>
</Popover>
</div>
</template>
</template>

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' }
}
]
}
}

View File

@@ -235,22 +235,22 @@ onMounted(async () => {
<!-- CARDS -->
<div class="px-8 pb-10">
<div class="grid grid-cols-12 gap-6">
<!-- CLÍNICA (antigo ADMIN) -->
<!-- PACIENTE -->
<div class="col-span-12 md:col-span-3">
<div
class="group h-full cursor-pointer rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-6 transition-all hover:shadow-xl hover:-translate-y-1"
@click="go('clinic_admin')"
@click="go('patient')"
>
<div class="flex items-center justify-between mb-4">
<div class="text-xl font-semibold text-[var(--text-color)]">
Clínica
Paciente
</div>
<i class="pi pi-building text-sm opacity-70" />
<i class="pi pi-user text-sm opacity-70" />
</div>
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Gestão da clínica, controle de usuários, permissões, planos e configurações globais.
Visualização de informações pessoais, documentos e interações com a clínica.
</div>
<div class="mt-6 text-sm font-medium text-primary opacity-80 group-hover:opacity-100 transition">
@@ -281,22 +281,22 @@ onMounted(async () => {
</div>
</div>
</div>
<!-- PACIENTE -->
<!-- CLÍNICA (antigo ADMIN) -->
<div class="col-span-12 md:col-span-3">
<div
class="group h-full cursor-pointer rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-6 transition-all hover:shadow-xl hover:-translate-y-1"
@click="go('patient')"
@click="go('clinic_admin')"
>
<div class="flex items-center justify-between mb-4">
<div class="text-xl font-semibold text-[var(--text-color)]">
Paciente
Clínica
</div>
<i class="pi pi-user text-sm opacity-70" />
<i class="pi pi-building text-sm opacity-70" />
</div>
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed">
Visualização de informações pessoais, documentos e interações com a clínica.
Gestão da clínica, controle de usuários, permissões, planos e configurações globais.
</div>
<div class="mt-6 text-sm font-medium text-primary opacity-80 group-hover:opacity-100 transition">
@@ -304,7 +304,7 @@ onMounted(async () => {
</div>
</div>
</div>
<!-- SAAS MASTER -->
<div class="col-span-12 md:col-span-3">
<div

View File

@@ -7,7 +7,17 @@ import StatsWidget from '@/components/dashboard/StatsWidget.vue';
</script>
<template>
ADMIN DASHBOARD
<div class="card mb-0">
<div class="flex justify-between mb-4">
<div>
<span class="text-primary font-medium">Área</span>
<span class="text-muted-color"> da Clínica</span>
</div>
<div class="flex items-center justify-center bg-blue-100 dark:bg-blue-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
<i class="pi pi-user text-blue-500 text-xl!"></i>
</div>
</div>
</div>
<div class="grid grid-cols-12 gap-8">
<StatsWidget />

View File

@@ -0,0 +1,51 @@
<script setup>
import { useRouter } from 'vue-router'
import { useTenantStore } from '@/stores/tenantStore'
const router = useRouter()
const tenant = useTenantStore()
function goHome () {
const role = tenant.activeRole
if (role === 'tenant_admin' || role === 'clinic_admin' || role === 'admin') {
router.push('/admin')
return
}
if (role === 'therapist') {
router.push('/therapist')
return
}
if (role === 'patient') {
router.push('/portal')
return
}
router.push('/')
}
</script>
<template>
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center px-6">
<div class="text-6xl font-bold text-[var(--primary-color)] mb-4">
403
</div>
<h1 class="text-2xl font-semibold mb-2">
Acesso negado
</h1>
<p class="text-[var(--text-color-secondary)] max-w-md mb-6">
Você está autenticado, mas não possui permissão para acessar esta área.
Caso acredite que isso seja um erro, entre em contato com o administrador da clínica.
</p>
<Button
label="Voltar para minha área"
icon="pi pi-home"
@click="goHome"
/>
</div>
</template>

View File

@@ -7,7 +7,17 @@ import StatsWidget from '@/components/dashboard/StatsWidget.vue';
</script>
<template>
PATIENT DASHBOARD
<div class="card mb-0">
<div class="flex justify-between mb-4">
<div>
<span class="text-primary font-medium">Portal</span>
<span class="text-muted-color"> = Área do Paciente</span>
</div>
<div class="flex items-center justify-center bg-blue-100 dark:bg-blue-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
<i class="pi pi-user text-blue-500 text-xl!"></i>
</div>
</div>
</div>
<div class="grid grid-cols-12 gap-8">
<StatsWidget />

View File

@@ -7,7 +7,17 @@ import StatsWidget from '@/components/dashboard/StatsWidget.vue';
</script>
<template>
THERAPIST DASHBOARD
<div class="card mb-0">
<div class="flex justify-between mb-4">
<div>
<span class="text-primary font-medium">Área</span>
<span class="text-muted-color"> do Terapeuta</span>
</div>
<div class="flex items-center justify-center bg-blue-100 dark:bg-blue-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
<i class="pi pi-user text-blue-500 text-xl!"></i>
</div>
</div>
</div>
<div class="grid grid-cols-12 gap-8">
<StatsWidget />