Ajuste em Massa - Paciente, Terapeuta, Clinica e Admin - Inicio agenda

This commit is contained in:
Leonardo
2026-02-22 17:56:01 -03:00
parent 6eff67bf22
commit 89b4ecaba1
77 changed files with 9433 additions and 1995 deletions
+270
View File
@@ -0,0 +1,270 @@
<template>
<div class="min-h-screen flex items-center justify-center p-6 bg-[var(--surface-ground)] text-[var(--text-color)]">
<div class="w-full max-w-lg rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-6 shadow-sm">
<h1 class="text-xl font-semibold mb-2">Aceitar convite</h1>
<p class="text-sm opacity-80 mb-6">
Vamos validar seu convite e ativar seu acesso ao tenant.
</p>
<div v-if="state.loading" class="text-sm">
Processando convite
</div>
<div v-else-if="state.success" class="space-y-3">
<div class="text-sm">
Convite aceito com sucesso. Redirecionando
</div>
</div>
<div v-else-if="state.error" class="space-y-4">
<div class="rounded-xl border border-red-500/30 bg-red-500/10 p-4 text-sm">
<div class="font-semibold mb-1">Não foi possível aceitar o convite</div>
<div class="opacity-90">{{ state.error }}</div>
</div>
<div class="flex gap-2">
<button
class="px-4 py-2 rounded-xl border border-[var(--surface-border)] hover:opacity-90 text-sm"
@click="retry"
>
Tentar novamente
</button>
<button
class="px-4 py-2 rounded-xl bg-[var(--primary-color)] text-[var(--primary-color-text)] hover:opacity-90 text-sm"
@click="goLogin"
>
Ir para login
</button>
</div>
<p class="text-xs opacity-70">
Se você recebeu um convite, confirme se está logado com o mesmo e-mail do convite.
</p>
</div>
<div v-else class="text-sm opacity-80">
Preparando
</div>
</div>
</div>
</template>
<script setup>
import { reactive, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
/**
* Token persistence (antes do login)
* - sessionStorage: some ao fechar a aba (bom para convite)
* - se você preferir cross-tab, use localStorage
*/
const PENDING_INVITE_TOKEN_KEY = 'pending_invite_token_v1'
function persistPendingToken (token) {
try { sessionStorage.setItem(PENDING_INVITE_TOKEN_KEY, token) } catch (_) {}
}
function readPendingToken () {
try { return sessionStorage.getItem(PENDING_INVITE_TOKEN_KEY) } catch (_) { return null }
}
function clearPendingToken () {
try { sessionStorage.removeItem(PENDING_INVITE_TOKEN_KEY) } catch (_) {}
}
const route = useRoute()
const router = useRouter()
const tenantStore = useTenantStore()
const state = reactive({
loading: true,
success: false,
error: ''
})
const tokenFromQuery = computed(() => {
const t = route.query?.token
return typeof t === 'string' ? t.trim() : ''
})
function isUuid (v) {
// UUID v1v5 (aceita maiúsculas/minúsculas)
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v)
}
function friendlyError (err) {
const msg = (err?.message || err || '').toString()
// Ajuste esses “match” conforme as mensagens/raises do seu SQL.
if (/expired|expirad/i.test(msg)) return 'Este convite expirou. Peça para a clínica reenviar o convite.'
if (/invalid|inval/i.test(msg)) return 'Token inválido. Verifique se você copiou o link corretamente.'
if (/not found|não encontrado|nao encontrado/i.test(msg)) return 'Convite não encontrado ou já utilizado.'
if (/email/i.test(msg) && /mismatch|diferent|different|bate|match/i.test(msg)) {
return 'Você está logado com um e-mail diferente do convite. Faça login com o e-mail correto.'
}
// cobre Postgres raise not_authenticated (P0001) e mensagens de JWT
if (/not_authenticated|not authenticated|jwt|auth/i.test(msg)) {
return 'Você precisa estar logado para aceitar este convite.'
}
return 'Não foi possível concluir o aceite. Tente novamente ou peça para reenviar o convite.'
}
async function goLogin () {
const token = tokenFromQuery.value || readPendingToken()
if (token) persistPendingToken(token)
// ✅ garante troca de conta
await supabase.auth.signOut()
// ✅ volta para o accept com token (ou com o storage pendente)
// (mantém o link “real” para o login conseguir retornar certo)
const returnTo = token ? `/accept-invite?token=${encodeURIComponent(token)}` : '/accept-invite'
await router.replace({ path: '/auth/login', query: { redirect: returnTo } })
}
async function acceptInvite (token) {
state.loading = true
state.error = ''
state.success = false
// 1) sessão
// Obs: getSession lê do storage; não use pra “autorizar” no client,
// mas aqui é só fluxo/UX; o servidor valida de verdade.
const { data: sessionData, error: sessionErr } = await supabase.auth.getSession()
if (sessionErr) {
state.loading = false
state.error = friendlyError(sessionErr)
return
}
const session = sessionData?.session
if (!session) {
// não logado → salva token e vai pro login
persistPendingToken(token)
// ✅ importante: /login dá 404 no seu projeto; use /auth/login
// ✅ preserve o returnTo com querystring (token)
const returnTo = route.fullPath || `/accept-invite?token=${encodeURIComponent(token)}`
await router.replace({ path: '/auth/login', query: { redirect: returnTo } })
// não seta erro: é fluxo normal
state.loading = false
return
}
// (debug útil: garante que a aba anônima realmente tem user/session)
try {
const s = await supabase.auth.getSession()
const u = await supabase.auth.getUser()
console.log('[accept-invite] session user:', s?.data?.session?.user?.id, s?.data?.session?.user?.email)
console.log('[accept-invite] getUser:', u?.data?.user?.id, u?.data?.user?.email)
} catch (_) {}
// 2) chama RPC
// IMPORTANTÍSSIMO: a função deve validar:
// - token existe, status=invited, não expirou
// - email do invite == auth.email do caller
// - cria/ativa tenant_members (status=active)
// - revoga/consome invite
//
// A assinatura de args depende do seu SQL:
// - se for tenant_accept_invite(token uuid) → { token }
// - se for tenant_accept_invite(p_token uuid) → { p_token: token }
//
// ✅ NO SEU CASO: a assinatura existente é p_token (confirmado no SQL Editor).
const { data, error } = await supabase.rpc('tenant_accept_invite', { p_token: token })
if (error) {
state.loading = false
// mostra o motivo real na tela (e não uma mensagem genérica)
state.error = error?.message ? error.message : friendlyError(error)
return
}
// 3) sucesso → limpa token pendente
clearPendingToken()
// 4) atualiza tenantStore (boa prática: refresh completo do “contexto do usuário”)
// Ideal: sua RPC retorna tenant_id (e opcionalmente role/status)
const acceptedTenantId = data?.tenant_id || data?.tenantId || null
try {
await refreshTenantContextAfterInvite(acceptedTenantId)
} catch (e) {
// mesmo que refresh falhe, o aceite ocorreu; ainda redireciona, mas você pode avisar
// (mantive silencioso para não “quebrar” o fluxo).
}
state.loading = false
state.success = true
// 5) redireciona
await router.replace('/admin')
}
/**
* Melhor prática de atualização do tenantStore após aceite:
* - 1) refetch “meus tenants + memberships” (fonte da verdade)
* - 2) setActiveTenantId (se veio no retorno; senão, escolha um padrão)
* - 3) carregar contexto do tenant ativo (permissões/entitlements/branding/etc)
*/
async function refreshTenantContextAfterInvite (acceptedTenantId) {
// Ajuste para os métodos reais do seu tenantStore:
// Exemplo recomendado de API do store:
// - await tenantStore.fetchMyTenants()
// - await tenantStore.fetchMyMemberships()
// - tenantStore.setActiveTenantId(...)
// - await tenantStore.hydrateActiveTenantContext()
if (typeof tenantStore.refreshMyTenantsAndMemberships === 'function') {
await tenantStore.refreshMyTenantsAndMemberships()
} else {
if (typeof tenantStore.fetchMyTenants === 'function') await tenantStore.fetchMyTenants()
if (typeof tenantStore.fetchMyMemberships === 'function') await tenantStore.fetchMyMemberships()
}
if (acceptedTenantId && typeof tenantStore.setActiveTenantId === 'function') {
tenantStore.setActiveTenantId(acceptedTenantId)
}
if (typeof tenantStore.hydrateActiveTenantContext === 'function') {
await tenantStore.hydrateActiveTenantContext()
} else if (typeof tenantStore.refreshActiveTenant === 'function') {
await tenantStore.refreshActiveTenant()
}
}
async function run () {
state.loading = true
state.error = ''
state.success = false
// 1) token: query > pendente (pós-login)
const token = tokenFromQuery.value || readPendingToken()
if (!token) {
state.loading = false
state.error = 'Token ausente. Abra novamente o link do convite.'
return
}
if (!isUuid(token)) {
state.loading = false
state.error = 'Token inválido. Verifique se o link está completo.'
return
}
// Se veio da query, persiste (caso precise atravessar login)
if (tokenFromQuery.value) persistPendingToken(token)
// 2) tenta aceitar
await acceptInvite(token)
}
async function retry () {
await run()
}
onMounted(run)
</script>