Ajuste rotas, Menus, Layout, Permissãoes UserRoleGuard
This commit is contained in:
@@ -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'
|
||||||
|
|
||||||
|
|||||||
115
src/composables/useRoleGuard.js
Normal file
115
src/composables/useRoleGuard.js
Normal 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
@@ -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>
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]" />
|
||||||
|
|
||||||
|
|||||||
71
src/router/accessRedirects.js
Normal file
71
src/router/accessRedirects.js
Normal 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 || '/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|
||||||
|
|||||||
51
src/views/pages/misc/AccessDeniedPage.vue
Normal file
51
src/views/pages/misc/AccessDeniedPage.vue
Normal 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>
|
||||||
@@ -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 />
|
||||||
|
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user