Ajuste em Massa - Paciente, Terapeuta, Clinica e Admin - Inicio agenda
This commit is contained in:
@@ -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 v1–v5 (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>
|
||||
Reference in New Issue
Block a user