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 { ref, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client' 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" v-model="search"
class="w-full" class="w-full"
autocomplete="off" autocomplete="off"
@keyup.enter="openSearchModal"
/> />
</IconField> </IconField>
<label for="agendaSearch">Buscar paciente...</label> <label for="agendaSearch">Buscar paciente...</label>
</FloatLabel> </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>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -131,10 +158,10 @@
</div> </div>
<!-- Layout: 2 colunas --> <!-- Layout: 2 colunas -->
<div class="grid"> <div class="flex flex-col lg:flex-row gap-4">
<!-- Coluna maior: FullCalendar --> <!-- Coluna maior: FullCalendar -->
<div class="col-12 lg:col-8 xl:col-9"> <div class="w-full lg:flex-1 lg:order-2 min-w-0">
<div class="overflow-hidden rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm"> <div class="overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
<div class="p-2"> <div class="p-2">
<!-- key força reaplicar slotMin/Max quando trocar modo --> <!-- key força reaplicar slotMin/Max quando trocar modo -->
<FullCalendar ref="fcRef" :key="fcKey" :options="fcOptions" /> <FullCalendar ref="fcRef" :key="fcKey" :options="fcOptions" />
@@ -143,7 +170,70 @@
</div> </div>
<!-- Coluna menor --> <!-- 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-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"> <div class="mb-2 flex items-center justify-between">
<span class="font-semibold">Calendário</span> <span class="font-semibold">Calendário</span>
@@ -227,6 +317,83 @@
</div> </div>
</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 --> <!-- Month Picker -->
<Dialog v-model:visible="monthPickerVisible" modal header="Escolher mês" :style="{ width: '420px' }"> <Dialog v-model:visible="monthPickerVisible" modal header="Escolher mês" :style="{ width: '420px' }">
<div class="p-2"> <div class="p-2">
@@ -302,6 +469,8 @@ import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon' import InputIcon from 'primevue/inputicon'
import SelectButton from 'primevue/selectbutton' import SelectButton from 'primevue/selectbutton'
import SplitButton from 'primevue/splitbutton' import SplitButton from 'primevue/splitbutton'
import Tag from 'primevue/tag'
import Divider from 'primevue/divider'
import FullCalendar from '@fullcalendar/vue3' import FullCalendar from '@fullcalendar/vue3'
import timeGridPlugin from '@fullcalendar/timegrid' import timeGridPlugin from '@fullcalendar/timegrid'
@@ -348,6 +517,9 @@ const calendarView = ref('day') // day | week | month
const timeMode = ref('my') // 24 | 12 | my const timeMode = ref('my') // 24 | 12 | my
const search = ref('') const search = ref('')
// Modal resultados (mobile)
const searchModalOpen = ref(false)
// Mini calendário // Mini calendário
const miniDate = ref(new Date()) const miniDate = ref(new Date())
@@ -440,23 +612,50 @@ const fcViewName = computed(() => {
return 'dayGridMonth' 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 list = rows.value || []
const q = (search.value || '').trim().toLowerCase()
return list.filter(r => { return list.filter(r => {
const tipo = String(r.tipo || '').toLowerCase() 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 (onlySessions.value && !tipo.includes('sess')) return false
if (q && !(titulo.includes(q) || obs.includes(q))) return false
return true 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 calendarEvents = computed(() => {
const base = mapAgendaEventosToCalendarEvents(filteredRows.value || []) const base = mapAgendaEventosToCalendarEvents(calendarRows.value || [])
const breaks = const breaks =
settings.value && currentRange.value.start && currentRange.value.end settings.value && currentRange.value.start && currentRange.value.end
@@ -537,11 +736,21 @@ const fcOptions = computed(() => ({
eventDrop: (info) => persistMoveOrResize(info, 'Movido'), eventDrop: (info) => persistMoveOrResize(info, 'Movido'),
eventResize: (info) => persistMoveOrResize(info, 'Redimensionado'), eventResize: (info) => persistMoveOrResize(info, 'Redimensionado'),
// ✅ destaque da busca sem remover eventos (melhoria UX)
eventClassNames: (arg) => { eventClassNames: (arg) => {
const tipo = String(arg?.event?.extendedProps?.tipo || arg?.event?.extendedProps?.kind || '').toLowerCase() const tipo = String(arg?.event?.extendedProps?.tipo || arg?.event?.extendedProps?.kind || '').toLowerCase()
if (tipo.includes('sess')) return ['evt-session'] const title = String(arg?.event?.title || '').toLowerCase()
if (tipo.includes('bloq')) return ['evt-block'] const obs = String(arg?.event?.extendedProps?.observacoes || '').toLowerCase()
return []
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) getApi()?.changeView?.(fcViewName.value)
}) })
// se limpou a busca, fecha modal mobile automaticamente
watch(searchTrim, (v) => {
if (!v) searchModalOpen.value = false
})
// ----------------------------- // -----------------------------
// Ações Topbar // Ações Topbar
// ----------------------------- // -----------------------------
@@ -561,6 +775,17 @@ function goToday () { getApi()?.today?.() }
function goPrev () { getApi()?.prev?.() } function goPrev () { getApi()?.prev?.() }
function goNext () { getApi()?.next?.() } 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 () { function toggleMonthPicker () {
monthPickerDate.value = new Date(currentDate.value) monthPickerDate.value = new Date(currentDate.value)
monthPickerVisible.value = true monthPickerVisible.value = true
@@ -580,6 +805,40 @@ function onMiniPick (d) {
function miniPrevMonth () { miniDate.value = shiftMonth(miniDate.value, -1) } function miniPrevMonth () { miniDate.value = shiftMonth(miniDate.value, -1) }
function miniNextMonth () { 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 () { function onCreateFromButton () {
if (!ownerId.value) { if (!ownerId.value) {
toast.add({ severity: 'warn', summary: 'Agenda', detail: 'Aguarde carregar as configurações da agenda.', life: 3000 }) 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) { if (settingsError.value) {
toast.add({ severity: 'warn', summary: 'Agenda', detail: settingsError.value, life: 4500 }) 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 { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
const confirm = useConfirm() 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 // Accordion: abre 1 por vez + scroll
// ------------------------------------------------------ // ------------------------------------------------------
@@ -400,6 +428,8 @@ async function getOwnerId () {
// ------------------------------------------------------ // ------------------------------------------------------
const PACIENTES_COLUNAS_PERMITIDAS = new Set([ const PACIENTES_COLUNAS_PERMITIDAS = new Set([
'owner_id', 'owner_id',
'tenant_id',
'responsible_member_id',
// Sessão 1 // Sessão 1
'nome_completo', 'nome_completo',
@@ -688,12 +718,14 @@ async function createPatient (payload) {
} }
async function updatePatient (id, payload) { async function updatePatient (id, payload) {
const ownerId = await getOwnerId()
const { error } = await supabase const { error } = await supabase
.from('patients') .from('patients')
.update({ ...payload, updated_at: new Date().toISOString() }) .update({
...payload,
updated_at: new Date().toISOString()
})
.eq('id', id) .eq('id', id)
.eq('owner_id', ownerId)
if (error) throw error if (error) throw error
} }
@@ -840,38 +872,55 @@ async function fetchAll () {
watch(() => route.params?.id, fetchAll, { immediate: true }) watch(() => route.params?.id, fetchAll, { immediate: true })
onMounted(fetchAll) 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 // Submit
// ------------------------------------------------------ // ------------------------------------------------------
async function onSubmit () { async function onSubmit () {
if (saving.value) return
// validações...
saving.value = true
try { try {
saving.value = true
const ownerId = await getOwnerId() const ownerId = await getOwnerId()
const { tenantId, memberId } = await resolveTenantContextOrFail()
const payload = sanitizePayload(form.value, ownerId) 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) { if (isEdit.value) {
await updatePatient(id, payload) await updatePatient(patientId.value, payload)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente atualizado.', life: 2500 })
} else { } else {
const created = await createPatient(payload) const created = await createPatient(payload)
id = created?.id toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente cadastrado.', life: 2500 })
if (!id) throw new Error('Falha ao obter ID do paciente criado.') // opcional: router.push(`${getAreaBase()}/patients/${created.id}`)
router.replace(`${getAreaBase()}/patients/cadastro/${id}`)
} }
} catch (e) {
await replacePatientGroups(id, grupoIdSelecionado.value || null) console.error(e)
await replacePatientTags(id, tagIdsSelecionadas.value || []) toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar paciente.', life: 4000 })
// 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 })
} finally { } finally {
saving.value = false saving.value = false
} }

View File

@@ -5,13 +5,19 @@ import { useRouter } from 'vue-router'
import { sessionUser, sessionRole } from '@/app/session' import { sessionUser, sessionRole } from '@/app/session'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
import { useRoleGuard } from '@/composables/useRoleGuard'
const router = useRouter() const router = useRouter()
const pop = ref(null) 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 initials = computed(() => {
const name = sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || '' const name = sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || ''
const parts = String(name).trim().split(/\s+/).filter(Boolean) const parts = String(name).trim().split(/\s+/).filter(Boolean)
@@ -25,15 +31,30 @@ const label = computed(() => {
return name || sessionUser.value?.email || 'Conta' 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 sublabel = computed(() => {
const r = sessionRole.value const r = role.value || sessionRole.value
if (!r) return 'Sessão' 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 === 'therapist') return 'Terapeuta'
if (r === 'patient') return 'Paciente' if (r === 'patient') return 'Paciente'
// fallback (caso venha algo diferente)
return r return r
}) })
// ------------------------------------------------------
// Popover helpers
// ------------------------------------------------------
function toggle (e) { function toggle (e) {
pop.value?.toggle(e) pop.value?.toggle(e)
} }
@@ -44,35 +65,9 @@ function close () {
} catch {} } catch {}
} }
function goMyProfile() { // ------------------------------------------------------
close() // Navegação segura (NAME com fallback)
// ------------------------------------------------------
// 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('/')
}
async function safePush (target, fallback) { async function safePush (target, fallback) {
try { try {
await router.push(target) 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 () { function goSecurity () {
close() close()
@@ -150,6 +174,7 @@ async function signOut () {
<Popover ref="pop" appendTo="body"> <Popover ref="pop" appendTo="body">
<div class="min-w-[220px] p-1"> <div class="min-w-[220px] p-1">
<Button <Button
v-if="canSee('settings.view')"
label="Configurações" label="Configurações"
icon="pi pi-cog" icon="pi pi-cog"
text text
@@ -165,13 +190,13 @@ async function signOut () {
@click="goSecurity" @click="goSecurity"
/> />
<Button <Button
label="Meu Perfil" label="Meu Perfil"
icon="pi pi-user" icon="pi pi-user"
text text
class="w-full justify-start" class="w-full justify-start"
@click="goMyProfile" @click="goMyProfile"
/> />
<div class="my-1 border-t border-[var(--surface-border)]" /> <div class="my-1 border-t border-[var(--surface-border)]" />

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' 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) // cache simples (evita bater no banco em toda navegação)
let sessionUidCache = null let sessionUidCache = null
@@ -234,14 +237,14 @@ export function applyGuards (router) {
if (!tenant.activeTenantId) { if (!tenant.activeTenantId) {
const mem = Array.isArray(tenant.memberships) ? tenant.memberships : [] const mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
// 1) tenta casar role da rota (ex.: therapist) com membership // 1) tenta casar role da rota (ex.: therapist) com membership
const wantedRoles = Array.isArray(to.meta?.roles) ? to.meta.roles : [] const wantedRoles = Array.isArray(to.meta?.roles) ? to.meta.roles : []
const preferred = wantedRoles.length const preferred = wantedRoles.length
? mem.find(m => m && m.status === 'active' && m.tenant_id && wantedRoles.includes(m.role)) ? mem.find(m => m && m.status === 'active' && m.tenant_id && wantedRoles.includes(m.role))
: null : null
// 2) fallback: primeiro active // 2) fallback: primeiro active
const firstActive = preferred || mem.find(m => m && m.status === 'active' && m.tenant_id) const firstActive = preferred || mem.find(m => m && m.status === 'active' && m.tenant_id)
if (!firstActive) { if (!firstActive) {
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true } 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, // ✅ RBAC (roles) — BLOQUEIA se não for compatível
// tenta ajustar o activeRole dentro do mesmo tenant (se houver membership compatível). //
const allowedRoles = Array.isArray(to.meta?.roles) ? to.meta.roles : null // Importante:
if (allowedRoles && allowedRoles.length && !allowedRoles.includes(tenant.activeRole)) { // - Isso é "papel": se falhar, NÃO é caso de upgrade.
const mem = Array.isArray(tenant.memberships) ? tenant.memberships : [] // - Só depois disso checamos feature/plano.
const compatible = mem.find(m => // ------------------------------------------------
m && m.status === 'active' && m.tenant_id === tenantId && allowedRoles.includes(m.role) const allowedRoles = Array.isArray(to.meta?.roles) ? to.meta.roles : null
) if (allowedRoles && allowedRoles.length && !allowedRoles.includes(tenant.activeRole)) {
if (compatible) { const mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
// muda role ativo para o compatível const compatible = mem.find(m =>
tenant.activeRole = compatible.role 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 // role guard (singular) - mantém compatibilidade
const requiredRole = to.meta?.role const requiredRole = to.meta?.role
if (requiredRole && tenant.activeRole !== requiredRole) { if (requiredRole && tenant.activeRole !== requiredRole) {
const fallback = roleToPath(tenant.activeRole) // RBAC singular também é "papel" → cai fora (não é upgrade)
if (to.path === fallback) { console.timeEnd(tlabel); return { path: '/pages/access' } }
console.timeEnd(tlabel) 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 const requiredFeature = to.meta?.feature
if (requiredFeature && ent?.can && !ent.can(requiredFeature)) { if (requiredFeature && ent?.can && !ent.can(requiredFeature)) {
if (to.name === 'upgrade') { console.timeEnd(tlabel); return true } // evita loop
if (to.name === 'upgrade') { console.timeEnd(tlabel); return true }
const url = buildUpgradeUrl({ // Mantém compatibilidade com seu fluxo existente (buildUpgradeUrl)
missingKeys: [requiredFeature], const url = buildUpgradeUrl({
redirectTo: to.fullPath missingKeys: [requiredFeature],
}) redirectTo: to.fullPath
console.timeEnd(tlabel) })
return url
} // 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) console.timeEnd(tlabel)
return true 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({ const router = createRouter({

View File

@@ -6,10 +6,28 @@ export default {
name: 'landing', name: 'landing',
component: () => import('@/views/pages/Landing.vue') component: () => import('@/views/pages/Landing.vue')
}, },
// 404
{ {
path: 'pages/notfound', path: 'pages/notfound',
name: 'notfound', name: 'notfound',
component: () => import('@/views/pages/NotFound.vue') 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

@@ -236,21 +236,21 @@ onMounted(async () => {
<div class="px-8 pb-10"> <div class="px-8 pb-10">
<div class="grid grid-cols-12 gap-6"> <div class="grid grid-cols-12 gap-6">
<!-- CLÍNICA (antigo ADMIN) --> <!-- PACIENTE -->
<div class="col-span-12 md:col-span-3"> <div class="col-span-12 md:col-span-3">
<div <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" 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="flex items-center justify-between mb-4">
<div class="text-xl font-semibold text-[var(--text-color)]"> <div class="text-xl font-semibold text-[var(--text-color)]">
Clínica Paciente
</div> </div>
<i class="pi pi-building text-sm opacity-70" /> <i class="pi pi-user text-sm opacity-70" />
</div> </div>
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed"> <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>
<div class="mt-6 text-sm font-medium text-primary opacity-80 group-hover:opacity-100 transition"> <div class="mt-6 text-sm font-medium text-primary opacity-80 group-hover:opacity-100 transition">
@@ -282,21 +282,21 @@ onMounted(async () => {
</div> </div>
</div> </div>
<!-- PACIENTE --> <!-- CLÍNICA (antigo ADMIN) -->
<div class="col-span-12 md:col-span-3"> <div class="col-span-12 md:col-span-3">
<div <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" 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="flex items-center justify-between mb-4">
<div class="text-xl font-semibold text-[var(--text-color)]"> <div class="text-xl font-semibold text-[var(--text-color)]">
Paciente Clínica
</div> </div>
<i class="pi pi-user text-sm opacity-70" /> <i class="pi pi-building text-sm opacity-70" />
</div> </div>
<div class="text-sm text-[var(--text-color-secondary)] leading-relaxed"> <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>
<div class="mt-6 text-sm font-medium text-primary opacity-80 group-hover:opacity-100 transition"> <div class="mt-6 text-sm font-medium text-primary opacity-80 group-hover:opacity-100 transition">

View File

@@ -7,7 +7,17 @@ import StatsWidget from '@/components/dashboard/StatsWidget.vue';
</script> </script>
<template> <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"> <div class="grid grid-cols-12 gap-8">
<StatsWidget /> <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> </script>
<template> <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"> <div class="grid grid-cols-12 gap-8">
<StatsWidget /> <StatsWidget />

View File

@@ -7,7 +7,17 @@ import StatsWidget from '@/components/dashboard/StatsWidget.vue';
</script> </script>
<template> <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"> <div class="grid grid-cols-12 gap-8">
<StatsWidget /> <StatsWidget />