ZERADO
This commit is contained in:
+410
-305
@@ -2,15 +2,12 @@
|
||||
import FloatingConfigurator from '@/components/FloatingConfigurator.vue'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { supabase } from '../../../lib/supabase/client'
|
||||
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
// ✅ sessão (fonte de verdade p/ saas admin)
|
||||
@@ -33,6 +30,47 @@ const recoveryEmail = ref('')
|
||||
const loadingRecovery = ref(false)
|
||||
const recoverySent = ref(false)
|
||||
|
||||
// carrossel
|
||||
const slides = [
|
||||
{
|
||||
title: 'Gestão clínica simplificada',
|
||||
body: 'Agendamentos, prontuários e sessões em um único painel. Foco no que importa: seus pacientes.',
|
||||
icon: 'pi-calendar-clock',
|
||||
},
|
||||
{
|
||||
title: 'Múltiplos profissionais',
|
||||
body: 'Adicione terapeutas, gerencie vínculos e mantenha tudo organizado por clínica.',
|
||||
icon: 'pi-users',
|
||||
},
|
||||
{
|
||||
title: 'Agendamento online',
|
||||
body: 'Seus pacientes marcam sessões diretamente pelo portal, com confirmação automática.',
|
||||
icon: 'pi-globe',
|
||||
},
|
||||
{
|
||||
title: 'Seguro e privado',
|
||||
body: 'Dados protegidos com autenticação robusta e controle de acesso por perfil.',
|
||||
icon: 'pi-shield',
|
||||
},
|
||||
]
|
||||
|
||||
const currentSlide = ref(0)
|
||||
let slideInterval = null
|
||||
|
||||
function goToSlide (i) {
|
||||
currentSlide.value = i
|
||||
}
|
||||
|
||||
function startCarousel () {
|
||||
slideInterval = setInterval(() => {
|
||||
currentSlide.value = (currentSlide.value + 1) % slides.length
|
||||
}, 4500)
|
||||
}
|
||||
|
||||
function stopCarousel () {
|
||||
if (slideInterval) clearInterval(slideInterval)
|
||||
}
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return !!email.value?.trim() && !!password.value && !loading.value && !loadingRecovery.value
|
||||
})
|
||||
@@ -42,10 +80,11 @@ function isEmail (v) {
|
||||
}
|
||||
|
||||
function roleToPath (role) {
|
||||
// ✅ aceita os dois nomes (seu banco está devolvendo tenant_admin)
|
||||
if (role === 'clinic_admin' || role === 'tenant_admin' || role === 'admin') return '/admin'
|
||||
if (role === 'therapist') return '/therapist'
|
||||
if (role === 'patient') return '/portal'
|
||||
if (role === 'supervisor') return '/supervisor'
|
||||
if (role === 'portal_user' || role === 'patient') return '/portal'
|
||||
if (role === 'saas_admin') return '/saas'
|
||||
return '/'
|
||||
}
|
||||
|
||||
@@ -64,6 +103,14 @@ async function onSubmit () {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 🔥 HARD RESET do contexto tenant ANTES de autenticar
|
||||
try { tenant.reset() } catch {}
|
||||
try {
|
||||
localStorage.removeItem('tenant_id')
|
||||
localStorage.removeItem('tenant')
|
||||
localStorage.removeItem('currentTenantId')
|
||||
} catch {}
|
||||
|
||||
const mail = String(email.value || '').trim()
|
||||
|
||||
const res = await supabase.auth.signInWithPassword({
|
||||
@@ -74,19 +121,14 @@ async function onSubmit () {
|
||||
if (res.error) throw res.error
|
||||
|
||||
// ✅ garante que sessionIsSaasAdmin esteja hidratado após login
|
||||
// (evita cair no fluxo de tenant quando o usuário é SaaS master)
|
||||
try {
|
||||
await initSession({ initial: false })
|
||||
} catch (e) {
|
||||
console.warn('[Login] initSession pós-login falhou:', e)
|
||||
// não aborta login por isso
|
||||
}
|
||||
|
||||
// lembrar e-mail (não senha)
|
||||
persistRememberedEmail()
|
||||
|
||||
// ✅ prioridade: redirect_after_login (se existir)
|
||||
// mas antes, se for SaaS admin, NÃO exigir tenant.
|
||||
const redirect = sessionStorage.getItem('redirect_after_login')
|
||||
if (sessionIsSaasAdmin.value) {
|
||||
if (redirect) {
|
||||
@@ -98,46 +140,94 @@ async function onSubmit () {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ agora que está autenticado, garante tenant pessoal (Modelo B)
|
||||
try {
|
||||
await supabase.rpc('ensure_personal_tenant')
|
||||
} catch (e) {
|
||||
console.warn('[Login] ensure_personal_tenant falhou:', e)
|
||||
// não aborta login por isso
|
||||
}
|
||||
// 🔥 identidade global (profiles.role) define área macro
|
||||
const { data: profile, error: pErr } = await supabase
|
||||
.from('profiles')
|
||||
.select('role')
|
||||
.eq('id', res.data.user.id)
|
||||
.single()
|
||||
|
||||
// ✅ fonte de verdade: tenant_members (rpc my_tenants)
|
||||
await tenant.loadSessionAndTenant()
|
||||
|
||||
console.log('[LOGIN] tenant.user', tenant.user)
|
||||
console.log('[LOGIN] memberships', tenant.memberships)
|
||||
console.log('[LOGIN] activeTenantId', tenant.activeTenantId)
|
||||
console.log('[LOGIN] activeRole', tenant.activeRole)
|
||||
if (pErr) throw pErr
|
||||
|
||||
if (!tenant.user) {
|
||||
authError.value = 'Não foi possível obter a sessão após login.'
|
||||
const globalRole = profile?.role || null
|
||||
if (!globalRole) {
|
||||
authError.value = 'Perfil não configurado corretamente.'
|
||||
return
|
||||
}
|
||||
|
||||
if (!tenant.activeRole) {
|
||||
authError.value = 'Sua conta não tem vínculo ativo com uma clínica (tenant_members).'
|
||||
await supabase.auth.signOut()
|
||||
return
|
||||
const safeRedirect = (path) => {
|
||||
const p = String(path || '')
|
||||
if (!p) return null
|
||||
if (globalRole === 'saas_admin') return (p === '/saas' || p.startsWith('/saas/')) ? p : '/saas'
|
||||
if (globalRole === 'portal_user') return (p === '/portal' || p.startsWith('/portal/')) ? p : '/portal'
|
||||
if (globalRole === 'tenant_member') {
|
||||
return (p.startsWith('/admin') || p.startsWith('/therapist') || p.startsWith('/supervisor')) ? p : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
let tenantTarget = null
|
||||
|
||||
if (globalRole === 'tenant_member') {
|
||||
try {
|
||||
await supabase.rpc('ensure_personal_tenant')
|
||||
} catch (e) {
|
||||
console.warn('[Login] ensure_personal_tenant falhou:', e)
|
||||
}
|
||||
|
||||
await tenant.loadSessionAndTenant()
|
||||
|
||||
console.log('[LOGIN] tenant.user', tenant.user)
|
||||
console.log('[LOGIN] memberships', tenant.memberships)
|
||||
console.log('[LOGIN] activeTenantId', tenant.activeTenantId)
|
||||
console.log('[LOGIN] activeRole', tenant.activeRole)
|
||||
|
||||
if (!tenant.user) {
|
||||
authError.value = 'Não foi possível obter a sessão após login.'
|
||||
return
|
||||
}
|
||||
|
||||
if (!tenant.activeRole) {
|
||||
authError.value = 'Sua conta não tem vínculo ativo com uma clínica (tenant_members).'
|
||||
await supabase.auth.signOut()
|
||||
return
|
||||
}
|
||||
|
||||
tenantTarget = roleToPath(tenant.activeRole)
|
||||
} else {
|
||||
try {
|
||||
localStorage.removeItem('tenant_id')
|
||||
localStorage.removeItem('tenant')
|
||||
localStorage.removeItem('currentTenantId')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ✅ se havia redirect, vai pra ele
|
||||
if (redirect) {
|
||||
sessionStorage.removeItem('redirect_after_login')
|
||||
router.push(redirect)
|
||||
const sr = safeRedirect(redirect)
|
||||
|
||||
if (sr) {
|
||||
router.push(sr)
|
||||
return
|
||||
}
|
||||
if (globalRole === 'tenant_member' && tenantTarget) {
|
||||
router.push(tenantTarget)
|
||||
return
|
||||
}
|
||||
router.push(globalRole === 'portal_user' ? '/portal' : globalRole === 'saas_admin' ? '/saas' : '/')
|
||||
return
|
||||
}
|
||||
|
||||
const intended = sessionStorage.getItem('intended_area')
|
||||
sessionStorage.removeItem('intended_area')
|
||||
|
||||
const target = roleToPath(tenant.activeRole)
|
||||
let target = '/'
|
||||
|
||||
if (intended && intended !== tenant.activeRole) {
|
||||
if (globalRole === 'tenant_member') target = tenantTarget || '/therapist'
|
||||
else if (globalRole === 'portal_user') target = '/portal'
|
||||
else if (globalRole === 'saas_admin') target = '/saas'
|
||||
|
||||
if (intended && intended !== globalRole) {
|
||||
router.push(target)
|
||||
return
|
||||
}
|
||||
@@ -178,11 +268,9 @@ async function sendRecoveryEmail () {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// legado: prefill via sessionStorage (mantive)
|
||||
const preEmail = sessionStorage.getItem('login_prefill_email')
|
||||
const prePass = sessionStorage.getItem('login_prefill_password')
|
||||
|
||||
// lembrar e-mail via localStorage (novo)
|
||||
let remembered = ''
|
||||
try {
|
||||
remembered = localStorage.getItem('remember_login_email') || ''
|
||||
@@ -197,287 +285,304 @@ onMounted(() => {
|
||||
|
||||
sessionStorage.removeItem('login_prefill_email')
|
||||
sessionStorage.removeItem('login_prefill_password')
|
||||
|
||||
startCarousel()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopCarousel()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FloatingConfigurator />
|
||||
|
||||
<div class="relative min-h-screen w-full overflow-hidden bg-[var(--surface-ground)]">
|
||||
<!-- fundo conceitual -->
|
||||
<div class="pointer-events-none absolute inset-0">
|
||||
<!-- grid muito sutil -->
|
||||
<div class="min-h-screen w-full flex">
|
||||
|
||||
<!-- ===== ESQUERDA: CARROSSEL ===== -->
|
||||
<div class="hidden lg:flex lg:w-1/2 relative overflow-hidden flex-col">
|
||||
<!-- Fundo gradiente -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-indigo-600 via-violet-600 to-purple-700" />
|
||||
|
||||
<!-- Grade decorativa -->
|
||||
<div
|
||||
class="absolute inset-0 opacity-70"
|
||||
style="
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(255,255,255,.05) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255,255,255,.05) 1px, transparent 1px);
|
||||
background-size: 38px 38px;
|
||||
mask-image: radial-gradient(ellipse at 50% 20%, rgba(0,0,0,.95), transparent 70%);
|
||||
"
|
||||
class="absolute inset-0 opacity-[0.08]"
|
||||
style="background-image: linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px); background-size: 48px 48px;"
|
||||
/>
|
||||
<!-- halos -->
|
||||
<div class="absolute -top-28 -right-28 h-[26rem] w-[26rem] rounded-full blur-3xl bg-indigo-400/10" />
|
||||
<div class="absolute top-20 -left-28 h-[30rem] w-[30rem] rounded-full blur-3xl bg-emerald-400/10" />
|
||||
<div class="absolute -bottom-32 right-24 h-[26rem] w-[26rem] rounded-full blur-3xl bg-fuchsia-400/10" />
|
||||
</div>
|
||||
|
||||
<div class="relative grid min-h-screen place-items-center p-4 md:p-8">
|
||||
<div class="w-full max-w-5xl">
|
||||
<div class="relative overflow-hidden rounded-[2.75rem] border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-2xl">
|
||||
<!-- header -->
|
||||
<div class="relative px-6 pt-7 pb-5 md:px-10 md:pt-10 md:pb-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="grid h-12 w-12 place-items-center rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)]">
|
||||
<i class="pi pi-eye text-lg opacity-80" />
|
||||
</div>
|
||||
<!-- Orbs -->
|
||||
<div class="absolute -top-40 -left-40 h-[32rem] w-[32rem] rounded-full bg-white/10 blur-3xl pointer-events-none" />
|
||||
<div class="absolute bottom-0 right-0 h-80 w-80 rounded-full bg-violet-300/20 blur-3xl pointer-events-none" />
|
||||
<div class="absolute top-1/2 -right-20 h-64 w-64 rounded-full bg-indigo-300/10 blur-3xl pointer-events-none" />
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="text-2xl md:text-3xl font-semibold leading-tight text-[var(--text-color)]">
|
||||
Entrar
|
||||
</div>
|
||||
<div class="mt-1 text-sm md:text-base text-[var(--text-color-secondary)]">
|
||||
Acesso seguro ao seu painel.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Conteúdo -->
|
||||
<div class="relative z-10 flex flex-col h-full p-10 xl:p-14">
|
||||
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-xs font-medium text-[var(--text-color-secondary)] hover:opacity-80"
|
||||
title="Atalho para a página de logins de desenvolvimento"
|
||||
>
|
||||
<i class="pi pi-code text-xs opacity-80" />
|
||||
Desenvolvedor Logins
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
:to="{ name: 'resetPassword' }"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-xs font-medium text-[var(--text-color-secondary)] hover:opacity-80"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400/70" />
|
||||
Trocar senha
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="col-span-12 md:hidden">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3 text-sm font-medium text-[var(--text-color-secondary)] hover:opacity-80"
|
||||
>
|
||||
<i class="pi pi-code opacity-80" />
|
||||
Desenvolvedor Logins
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- corpo -->
|
||||
<div class="relative px-6 pb-7 md:px-10 md:pb-10">
|
||||
<div class="grid grid-cols-12 gap-4 md:gap-6">
|
||||
<!-- FORM -->
|
||||
<div class="col-span-12 md:col-span-7">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 md:p-6">
|
||||
<form class="grid grid-cols-12 gap-4" @submit.prevent="onSubmit">
|
||||
<!-- email -->
|
||||
<div class="col-span-12">
|
||||
<label class="block text-sm font-semibold text-[var(--text-color)] mb-2">
|
||||
E-mail
|
||||
</label>
|
||||
<InputText
|
||||
v-model="email"
|
||||
class="w-full"
|
||||
placeholder="seuemail@dominio.com"
|
||||
autocomplete="email"
|
||||
:disabled="loading || loadingRecovery"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- senha -->
|
||||
<div class="col-span-12">
|
||||
<label class="block text-sm font-semibold text-[var(--text-color)] mb-2">
|
||||
Senha
|
||||
</label>
|
||||
<Password
|
||||
v-model="password"
|
||||
placeholder="Sua senha"
|
||||
toggleMask
|
||||
:feedback="false"
|
||||
class="w-full"
|
||||
inputClass="w-full"
|
||||
autocomplete="current-password"
|
||||
:disabled="loading || loadingRecovery"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- lembrar + esqueci -->
|
||||
<div class="col-span-12 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center">
|
||||
<Checkbox
|
||||
v-model="checked"
|
||||
inputId="rememberme1"
|
||||
binary
|
||||
class="mr-2"
|
||||
:disabled="loading || loadingRecovery"
|
||||
/>
|
||||
<label for="rememberme1" class="text-sm text-[var(--text-color-secondary)]">
|
||||
Lembrar meu e-mail neste dispositivo
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-[var(--primary-color)] hover:opacity-80 text-left"
|
||||
:disabled="loading || loadingRecovery"
|
||||
@click="openForgot"
|
||||
>
|
||||
Esqueceu sua senha?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- erro -->
|
||||
<div v-if="authError" class="col-span-12">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3 text-sm text-red-500">
|
||||
<i class="pi pi-exclamation-triangle mr-2 opacity-80" />
|
||||
{{ authError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- submit -->
|
||||
<div class="col-span-12">
|
||||
<Button
|
||||
type="submit"
|
||||
label="Entrar"
|
||||
class="w-full"
|
||||
icon="pi pi-sign-in"
|
||||
:loading="loading"
|
||||
:disabled="!canSubmit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 text-xs text-[var(--text-color-secondary)]">
|
||||
Ao entrar, você será direcionado para sua área conforme seu perfil e vínculo com a clínica.
|
||||
</div>
|
||||
|
||||
<!-- detalhe minimalista -->
|
||||
<div class="col-span-12">
|
||||
<div class="h-px w-full bg-[var(--surface-border)] opacity-70" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 text-xs text-[var(--text-color-secondary)]">
|
||||
Se você estiver testando perfis e cair na mensagem de vínculo, é porque o acesso depende de <span class="font-semibold">tenant_members</span>.
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LADO DIREITO: editorial / conceito -->
|
||||
<div class="col-span-12 md:col-span-5">
|
||||
<div class="h-full rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4 md:p-6">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-[var(--text-color)]">Acesso com lastro</div>
|
||||
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
|
||||
A sessão é validada e o vínculo com a clínica define sua área.
|
||||
</div>
|
||||
</div>
|
||||
<i class="pi pi-shield text-sm opacity-70" />
|
||||
</div>
|
||||
|
||||
<div class="mt-5 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="text-xs text-[var(--text-color-secondary)] leading-relaxed">
|
||||
<span class="font-semibold text-[var(--text-color)]">Como funciona:</span>
|
||||
você autentica, o sistema carrega seu tenant ativo e só então libera o painel correspondente.
|
||||
Isso evita acesso “solto” e organiza permissões no lugar certo.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="mt-5 space-y-2 text-xs text-[var(--text-color-secondary)]">
|
||||
<li class="flex gap-2">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400/70"></span>
|
||||
Recuperação de senha via link (e-mail).
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-400/70"></span>
|
||||
Se o link não chegar, cheque spam/lixo eletrônico.
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-400/70"></span>
|
||||
O redirecionamento depende da role ativa: admin/therapist/patient.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-5 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-xs text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-info-circle mr-2 opacity-70" />
|
||||
Garanta que o Supabase tenha Redirect URLs incluindo
|
||||
<span class="font-semibold">/auth/reset-password</span>.
|
||||
</div>
|
||||
|
||||
<div class="mt-6 hidden md:flex items-center justify-between text-xs text-[var(--text-color-secondary)] opacity-80">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-primary/60" />
|
||||
Agência Psi Quasar
|
||||
</span>
|
||||
<span class="opacity-80">Acesso clínico</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog recovery -->
|
||||
<Dialog
|
||||
v-model:visible="openRecovery"
|
||||
modal
|
||||
header="Recuperar acesso"
|
||||
:draggable="false"
|
||||
:style="{ width: '28rem', maxWidth: '92vw' }"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm text-[var(--text-color-secondary)]">
|
||||
Informe seu e-mail. Vamos enviar um link para redefinir sua senha.
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-semibold">E-mail</label>
|
||||
<InputText
|
||||
v-model="recoveryEmail"
|
||||
class="w-full"
|
||||
placeholder="seuemail@dominio.com"
|
||||
:disabled="loadingRecovery"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:justify-end pt-2">
|
||||
<Button
|
||||
label="Cancelar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:disabled="loadingRecovery"
|
||||
@click="openRecovery = false"
|
||||
/>
|
||||
<Button
|
||||
label="Enviar link"
|
||||
icon="pi pi-envelope"
|
||||
:loading="loadingRecovery"
|
||||
@click="sendRecoveryEmail"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="recoverySent"
|
||||
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-3 text-xs text-[var(--text-color-secondary)]"
|
||||
>
|
||||
<i class="pi pi-check mr-2 text-emerald-500"></i>
|
||||
Se o e-mail existir, você receberá o link em instantes. Verifique também spam/lixo eletrônico.
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="grid h-10 w-10 place-items-center rounded-xl bg-white/20 backdrop-blur-sm border border-white/20 shadow-lg">
|
||||
<i class="pi pi-heart-fill text-white text-sm" />
|
||||
</div>
|
||||
<span class="text-white font-bold text-lg tracking-tight">Agência PSI</span>
|
||||
</div>
|
||||
|
||||
<!-- Slides -->
|
||||
<div class="flex-1 flex flex-col justify-center">
|
||||
<Transition name="slide-fade" mode="out-in">
|
||||
<div :key="currentSlide" class="space-y-6">
|
||||
<div class="grid h-16 w-16 place-items-center rounded-2xl bg-white/15 backdrop-blur-sm border border-white/20 shadow-lg">
|
||||
<i :class="['pi', slides[currentSlide].icon, 'text-white text-2xl']" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-3xl xl:text-4xl font-bold text-white leading-tight">
|
||||
{{ slides[currentSlide].title }}
|
||||
</h2>
|
||||
<p class="text-base xl:text-lg text-white/70 leading-relaxed max-w-sm">
|
||||
{{ slides[currentSlide].body }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Dots -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-for="(_, i) in slides"
|
||||
:key="i"
|
||||
class="transition-all duration-300 rounded-full focus:outline-none"
|
||||
:class="i === currentSlide ? 'w-7 h-2 bg-white shadow' : 'w-2 h-2 bg-white/35 hover:bg-white/60'"
|
||||
@click="goToSlide(i)"
|
||||
/>
|
||||
<span class="ml-3 text-xs text-white/40 tabular-nums">{{ currentSlide + 1 }}/{{ slides.length }}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== DIREITA: FORMULÁRIO ===== -->
|
||||
<div class="flex-1 lg:w-1/2 flex flex-col min-h-screen bg-[var(--surface-ground)] overflow-y-auto relative">
|
||||
|
||||
<!-- Halos sutis de fundo -->
|
||||
<div class="pointer-events-none absolute inset-0">
|
||||
<div class="absolute top-10 right-10 h-64 w-64 rounded-full blur-3xl bg-indigo-400/5" />
|
||||
<div class="absolute bottom-10 left-10 h-56 w-56 rounded-full blur-3xl bg-violet-400/5" />
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-col flex-1 justify-center px-6 py-10 sm:px-10 lg:px-12 xl:px-16 w-full max-w-lg mx-auto">
|
||||
|
||||
<!-- Mobile: Brand -->
|
||||
<div class="flex lg:hidden items-center gap-2 mb-8">
|
||||
<div class="grid h-8 w-8 place-items-center rounded-lg bg-indigo-500/10 border border-indigo-500/20">
|
||||
<i class="pi pi-heart-fill text-indigo-500 text-xs" />
|
||||
</div>
|
||||
<span class="text-[var(--text-color)] font-bold text-base tracking-tight">Agência PSI</span>
|
||||
</div>
|
||||
|
||||
<!-- Cabeçalho -->
|
||||
<div class="mb-7">
|
||||
<h1 class="text-3xl font-bold text-[var(--text-color)] leading-tight">Bem-vindo de volta</h1>
|
||||
<p class="mt-1.5 text-sm text-[var(--text-color-secondary)]">Entre com sua conta para continuar</p>
|
||||
</div>
|
||||
|
||||
<!-- Login social (marcação — em breve) -->
|
||||
<div class="grid grid-cols-2 gap-3 mb-5">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
title="Em breve"
|
||||
class="flex items-center justify-center gap-2 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-2.5 text-sm font-medium text-[var(--text-color-secondary)] opacity-50 cursor-not-allowed"
|
||||
>
|
||||
<svg class="h-4 w-4 flex-shrink-0" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Google
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
title="Em breve"
|
||||
class="flex items-center justify-center gap-2 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-2.5 text-sm font-medium text-[var(--text-color-secondary)] opacity-50 cursor-not-allowed"
|
||||
>
|
||||
<svg class="h-4 w-4 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
|
||||
</svg>
|
||||
Apple
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Divisor -->
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="h-px flex-1 bg-[var(--surface-border)]" />
|
||||
<span class="text-xs text-[var(--text-color-secondary)] font-medium whitespace-nowrap">ou continue com e-mail</span>
|
||||
<div class="h-px flex-1 bg-[var(--surface-border)]" />
|
||||
</div>
|
||||
|
||||
<!-- Formulário -->
|
||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">E-mail</label>
|
||||
<InputText
|
||||
v-model="email"
|
||||
class="w-full"
|
||||
placeholder="seuemail@dominio.com"
|
||||
autocomplete="email"
|
||||
:disabled="loading || loadingRecovery"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">Senha</label>
|
||||
<Password
|
||||
v-model="password"
|
||||
placeholder="Sua senha"
|
||||
toggleMask
|
||||
:feedback="false"
|
||||
class="w-full"
|
||||
inputClass="w-full"
|
||||
autocomplete="current-password"
|
||||
:disabled="loading || loadingRecovery"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
v-model="checked"
|
||||
inputId="rememberme1"
|
||||
binary
|
||||
:disabled="loading || loadingRecovery"
|
||||
/>
|
||||
<label for="rememberme1" class="text-sm text-[var(--text-color-secondary)] cursor-pointer select-none">
|
||||
Lembrar e-mail
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-indigo-500 hover:text-indigo-600 transition-colors"
|
||||
:disabled="loading || loadingRecovery"
|
||||
@click="openForgot"
|
||||
>
|
||||
Esqueceu a senha?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Erro -->
|
||||
<div
|
||||
v-if="authError"
|
||||
class="rounded-xl border border-red-200 bg-red-50 dark:border-red-900/30 dark:bg-red-950/20 px-4 py-3 text-sm text-red-600 dark:text-red-400 flex items-center gap-2"
|
||||
>
|
||||
<i class="pi pi-exclamation-triangle flex-shrink-0" />
|
||||
{{ authError }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
label="Entrar"
|
||||
class="w-full"
|
||||
icon="pi pi-sign-in"
|
||||
:loading="loading"
|
||||
:disabled="!canSubmit"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<!-- Rodapé -->
|
||||
<div class="mt-8 pt-6 border-t border-[var(--surface-border)] flex flex-wrap items-center justify-between gap-3 text-xs text-[var(--text-color-secondary)]">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="flex items-center gap-1.5 hover:text-[var(--text-color)] transition-colors"
|
||||
>
|
||||
<i class="pi pi-code text-[10px]" />
|
||||
Dev logins
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
:to="{ name: 'resetPassword' }"
|
||||
class="flex items-center gap-1.5 hover:text-[var(--text-color)] transition-colors"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400 inline-block" />
|
||||
Trocar senha
|
||||
</RouterLink>
|
||||
|
||||
<span class="text-[var(--text-color-secondary)]/60">
|
||||
Agência PSI © {{ new Date().getFullYear() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Dialog: Recuperar acesso -->
|
||||
<Dialog
|
||||
v-model:visible="openRecovery"
|
||||
modal
|
||||
header="Recuperar acesso"
|
||||
:draggable="false"
|
||||
:style="{ width: '28rem', maxWidth: '92vw' }"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm text-[var(--text-color-secondary)]">
|
||||
Informe seu e-mail. Vamos enviar um link para redefinir sua senha.
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-semibold">E-mail</label>
|
||||
<InputText
|
||||
v-model="recoveryEmail"
|
||||
class="w-full"
|
||||
placeholder="seuemail@dominio.com"
|
||||
:disabled="loadingRecovery"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:justify-end pt-2">
|
||||
<Button
|
||||
label="Cancelar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:disabled="loadingRecovery"
|
||||
@click="openRecovery = false"
|
||||
/>
|
||||
<Button
|
||||
label="Enviar link"
|
||||
icon="pi pi-envelope"
|
||||
:loading="loadingRecovery"
|
||||
@click="sendRecoveryEmail"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="recoverySent"
|
||||
class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-3 text-xs text-[var(--text-color-secondary)]"
|
||||
>
|
||||
<i class="pi pi-check mr-2 text-emerald-500" />
|
||||
Se o e-mail existir, você receberá o link em instantes. Verifique também spam/lixo eletrônico.
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-fade-enter-active,
|
||||
.slide-fade-leave-active {
|
||||
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
}
|
||||
|
||||
.slide-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,226 +1,110 @@
|
||||
<template>
|
||||
<div class="min-h-screen p-4 md:p-6 grid place-items-center">
|
||||
<div class="w-full max-w-lg">
|
||||
<Card class="overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
|
||||
<template #title>
|
||||
<div class="relative">
|
||||
<!-- blobs sutis -->
|
||||
<div class="pointer-events-none absolute inset-0 opacity-90">
|
||||
<div class="absolute -top-20 -right-16 h-60 w-60 rounded-full bg-indigo-400/10 blur-3xl" />
|
||||
<div class="absolute top-10 -left-20 h-72 w-72 rounded-full bg-emerald-400/10 blur-3xl" />
|
||||
<div class="absolute -bottom-24 right-10 h-64 w-64 rounded-full bg-fuchsia-400/10 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div class="relative p-5 md:p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="grid h-10 w-10 place-items-center rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)]"
|
||||
>
|
||||
<i class="pi pi-key text-lg opacity-80" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="text-xl md:text-2xl font-semibold leading-tight">
|
||||
Redefinir senha
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-[var(--text-color-secondary)]">
|
||||
Escolha uma nova senha para sua conta. Depois, você fará login novamente.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="bannerText"
|
||||
class="mt-3 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-2 text-xs text-[var(--text-color-secondary)]"
|
||||
>
|
||||
<i class="pi pi-info-circle mr-2 opacity-70" />
|
||||
{{ bannerText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="p-5 md:p-6 pt-0">
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<!-- Nova senha -->
|
||||
<div class="col-span-12">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold">Nova senha</div>
|
||||
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
|
||||
Mínimo: 8 caracteres, maiúscula, minúscula e número.
|
||||
</div>
|
||||
</div>
|
||||
<i class="pi pi-lock text-sm opacity-70" />
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<Password
|
||||
v-model="newPassword"
|
||||
toggleMask
|
||||
:feedback="false"
|
||||
inputClass="w-full"
|
||||
:disabled="loading"
|
||||
placeholder="Crie uma nova senha"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs">
|
||||
<span v-if="!newPassword" class="text-[var(--text-color-secondary)]">
|
||||
Dica: use uma frase curta + número (ex.: “NoiteCalma7”).
|
||||
</span>
|
||||
<span v-else :class="strengthOk ? 'text-emerald-500' : 'text-yellow-500'">
|
||||
{{ strengthOk ? 'Senha forte o suficiente.' : 'Ainda está fraca — ajuste os critérios.' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmar -->
|
||||
<div class="col-span-12">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold">Confirmar nova senha</div>
|
||||
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
|
||||
Evita erro de digitação.
|
||||
</div>
|
||||
</div>
|
||||
<i class="pi pi-check-circle text-sm opacity-70" />
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<Password
|
||||
v-model="confirmPassword"
|
||||
toggleMask
|
||||
:feedback="false"
|
||||
inputClass="w-full"
|
||||
:disabled="loading"
|
||||
placeholder="Repita a nova senha"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs">
|
||||
<span v-if="!confirmPassword" class="text-[var(--text-color-secondary)]">
|
||||
Digite novamente para confirmar.
|
||||
</span>
|
||||
<span v-else :class="matchOk ? 'text-emerald-500' : 'text-yellow-500'">
|
||||
{{ matchOk ? 'Confere.' : 'Não confere com a nova senha.' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="mt-5 flex flex-col gap-2">
|
||||
<Button
|
||||
class="w-full"
|
||||
label="Atualizar senha"
|
||||
icon="pi pi-check"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
@click="submit"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-2.5 text-sm font-medium text-[var(--text-color-secondary)] hover:opacity-90"
|
||||
:disabled="loading"
|
||||
@click="goLogin"
|
||||
>
|
||||
Voltar para login
|
||||
</button>
|
||||
|
||||
<div class="mt-1 text-center text-xs text-[var(--text-color-secondary)]">
|
||||
Se você não solicitou essa redefinição, ignore o e-mail e faça logout em dispositivos desconhecidos.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import Card from 'primevue/card'
|
||||
import Password from 'primevue/password'
|
||||
import Button from 'primevue/button'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import FloatingConfigurator from '@/components/FloatingConfigurator.vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import Password from 'primevue/password'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
|
||||
const newPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const loading = ref(false)
|
||||
const bannerText = ref('')
|
||||
const loading = ref(false)
|
||||
const done = ref(false)
|
||||
|
||||
// estado do link de recovery
|
||||
const recoveryReady = ref(false)
|
||||
const linkInvalid = ref(false)
|
||||
|
||||
function isStrongEnough (p) {
|
||||
return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(p || '')
|
||||
}
|
||||
|
||||
const strengthOk = computed(() => isStrongEnough(newPassword.value))
|
||||
const matchOk = computed(() => !!confirmPassword.value && newPassword.value === confirmPassword.value)
|
||||
const matchOk = computed(() => !!confirmPassword.value && newPassword.value === confirmPassword.value)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 1) força leitura da sessão (supabase-js já captura hash automaticamente)
|
||||
const { data } = await supabase.auth.getSession()
|
||||
const canSubmit = computed(() =>
|
||||
recoveryReady.value && strengthOk.value && matchOk.value && !loading.value
|
||||
)
|
||||
|
||||
if (!data?.session) {
|
||||
bannerText.value =
|
||||
'Este link parece inválido ou expirado. Solicite um novo e-mail de redefinição.'
|
||||
} else {
|
||||
bannerText.value =
|
||||
'Link validado. Defina sua nova senha abaixo.'
|
||||
}
|
||||
|
||||
// 2) escuta evento específico de recovery
|
||||
const { data: listener } = supabase.auth.onAuthStateChange((event) => {
|
||||
if (event === 'PASSWORD_RECOVERY') {
|
||||
bannerText.value =
|
||||
'Link validado. Defina sua nova senha abaixo.'
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
listener?.subscription?.unsubscribe()
|
||||
}
|
||||
|
||||
} catch {
|
||||
bannerText.value =
|
||||
'Erro ao validar o link. Solicite um novo e-mail.'
|
||||
}
|
||||
// barra de força: 0–4
|
||||
const strengthScore = computed(() => {
|
||||
const p = newPassword.value
|
||||
if (!p) return 0
|
||||
let s = 0
|
||||
if (p.length >= 8) s++
|
||||
if (/[A-Z]/.test(p)) s++
|
||||
if (/[a-z]/.test(p)) s++
|
||||
if (/\d/.test(p)) s++
|
||||
return s
|
||||
})
|
||||
|
||||
const strengthLabel = computed(() => {
|
||||
if (!newPassword.value) return ''
|
||||
const labels = ['', 'Muito fraca', 'Fraca', 'Boa', 'Forte']
|
||||
return labels[strengthScore.value] || 'Forte'
|
||||
})
|
||||
|
||||
const strengthColor = computed(() => {
|
||||
const colors = ['', 'bg-red-500', 'bg-yellow-500', 'bg-blue-500', 'bg-emerald-500']
|
||||
return colors[strengthScore.value] || 'bg-emerald-500'
|
||||
})
|
||||
|
||||
let unsubscribeFn = null
|
||||
|
||||
async function syncRecoveryState () {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.getSession()
|
||||
if (error) throw error
|
||||
if (data?.session) {
|
||||
recoveryReady.value = true
|
||||
} else {
|
||||
linkInvalid.value = true
|
||||
}
|
||||
} catch {
|
||||
linkInvalid.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await syncRecoveryState()
|
||||
|
||||
const { data: listener } = supabase.auth.onAuthStateChange((event) => {
|
||||
if (event === 'PASSWORD_RECOVERY' || event === 'SIGNED_IN') {
|
||||
recoveryReady.value = true
|
||||
linkInvalid.value = false
|
||||
}
|
||||
if (event === 'SIGNED_OUT') {
|
||||
recoveryReady.value = false
|
||||
}
|
||||
})
|
||||
|
||||
unsubscribeFn = () => listener?.subscription?.unsubscribe()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
try { unsubscribeFn?.() } catch {}
|
||||
})
|
||||
|
||||
function goLogin () {
|
||||
router.replace('/auth/login')
|
||||
}
|
||||
|
||||
async function submit () {
|
||||
if (!newPassword.value || !confirmPassword.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Campos', detail: 'Preencha todos os campos.', life: 3000 })
|
||||
if (!recoveryReady.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Link inválido', detail: 'Solicite um novo e-mail de redefinição.', life: 3500 })
|
||||
return
|
||||
}
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Confirmação', detail: 'A confirmação não confere.', life: 3000 })
|
||||
if (!matchOk.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Confirmação', detail: 'As senhas não conferem.', life: 3000 })
|
||||
return
|
||||
}
|
||||
if (!isStrongEnough(newPassword.value)) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Senha fraca',
|
||||
detail: 'Use no mínimo 8 caracteres com maiúscula, minúscula e número.',
|
||||
life: 4500
|
||||
})
|
||||
if (!strengthOk.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Senha fraca', detail: 'Use no mínimo 8 caracteres com maiúscula, minúscula e número.', life: 4000 })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -229,25 +113,254 @@ async function submit () {
|
||||
const { error } = await supabase.auth.updateUser({ password: newPassword.value })
|
||||
if (error) throw error
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Pronto',
|
||||
detail: 'Senha redefinida. Faça login novamente.',
|
||||
life: 3500
|
||||
})
|
||||
|
||||
// encerra sessão do recovery
|
||||
await supabase.auth.signOut()
|
||||
router.replace('/auth/login')
|
||||
done.value = true
|
||||
try { await supabase.auth.signOut({ scope: 'global' }) } catch {}
|
||||
} catch (e) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro',
|
||||
detail: e?.message || 'Não foi possível redefinir a senha.',
|
||||
life: 4500
|
||||
})
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível redefinir a senha.', life: 4500 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FloatingConfigurator />
|
||||
|
||||
<div class="min-h-screen w-full flex">
|
||||
|
||||
<!-- ===== ESQUERDA: Painel de segurança ===== -->
|
||||
<div class="hidden lg:flex lg:w-1/2 relative overflow-hidden flex-col">
|
||||
<!-- Fundo gradiente -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-indigo-600 via-violet-600 to-purple-700" />
|
||||
|
||||
<!-- Grade decorativa -->
|
||||
<div
|
||||
class="absolute inset-0 opacity-[0.08]"
|
||||
style="background-image: linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px); background-size: 48px 48px;"
|
||||
/>
|
||||
|
||||
<!-- Orbs -->
|
||||
<div class="absolute -top-40 -left-40 h-[32rem] w-[32rem] rounded-full bg-white/10 blur-3xl pointer-events-none" />
|
||||
<div class="absolute bottom-0 right-0 h-80 w-80 rounded-full bg-violet-300/20 blur-3xl pointer-events-none" />
|
||||
|
||||
<div class="relative z-10 flex flex-col h-full p-10 xl:p-14">
|
||||
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="grid h-10 w-10 place-items-center rounded-xl bg-white/20 backdrop-blur-sm border border-white/20 shadow-lg">
|
||||
<i class="pi pi-heart-fill text-white text-sm" />
|
||||
</div>
|
||||
<span class="text-white font-bold text-lg tracking-tight">Agência PSI</span>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo central -->
|
||||
<div class="flex-1 flex flex-col justify-center space-y-8">
|
||||
<div class="grid h-16 w-16 place-items-center rounded-2xl bg-white/15 backdrop-blur-sm border border-white/20 shadow-lg">
|
||||
<i class="pi pi-lock text-white text-2xl" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-3xl xl:text-4xl font-bold text-white leading-tight">
|
||||
Crie uma senha<br>que você não vai<br>esquecer.
|
||||
</h2>
|
||||
<p class="text-base text-white/70 leading-relaxed max-w-sm">
|
||||
Escolha algo único e seguro. Uma boa senha protege seus dados e os de seus pacientes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Dicas -->
|
||||
<ul class="space-y-3">
|
||||
<li class="flex items-start gap-3 text-sm text-white/70">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
|
||||
Mínimo de 8 caracteres
|
||||
</li>
|
||||
<li class="flex items-start gap-3 text-sm text-white/70">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-300 flex-shrink-0" />
|
||||
Combine letras maiúsculas, minúsculas e números
|
||||
</li>
|
||||
<li class="flex items-start gap-3 text-sm text-white/70">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-300 flex-shrink-0" />
|
||||
Não reutilize a mesma senha de outros serviços
|
||||
</li>
|
||||
<li class="flex items-start gap-3 text-sm text-white/70">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-amber-300 flex-shrink-0" />
|
||||
Exemplo seguro: <span class="font-semibold text-white/90">"Noite#Calma7"</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Rodapé esquerdo -->
|
||||
<div class="flex items-center justify-between text-xs text-white/40">
|
||||
<span>Agência PSI</span>
|
||||
<span>Acesso seguro</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== DIREITA: Formulário ===== -->
|
||||
<div class="flex-1 lg:w-1/2 flex flex-col min-h-screen bg-[var(--surface-ground)] overflow-y-auto relative">
|
||||
|
||||
<!-- Halos sutis -->
|
||||
<div class="pointer-events-none absolute inset-0">
|
||||
<div class="absolute top-10 right-10 h-64 w-64 rounded-full blur-3xl bg-indigo-400/5" />
|
||||
<div class="absolute bottom-10 left-10 h-56 w-56 rounded-full blur-3xl bg-violet-400/5" />
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-col flex-1 justify-center px-6 py-10 sm:px-10 lg:px-12 xl:px-16 w-full max-w-lg mx-auto">
|
||||
|
||||
<!-- Mobile: Brand -->
|
||||
<div class="flex lg:hidden items-center gap-2 mb-8">
|
||||
<div class="grid h-8 w-8 place-items-center rounded-lg bg-indigo-500/10 border border-indigo-500/20">
|
||||
<i class="pi pi-heart-fill text-indigo-500 text-xs" />
|
||||
</div>
|
||||
<span class="text-[var(--text-color)] font-bold text-base tracking-tight">Agência PSI</span>
|
||||
</div>
|
||||
|
||||
<!-- ── Estado: SUCESSO ── -->
|
||||
<template v-if="done">
|
||||
<div class="text-center space-y-6">
|
||||
<div class="mx-auto grid h-20 w-20 place-items-center rounded-full bg-emerald-500/10 border border-emerald-500/20">
|
||||
<i class="pi pi-check text-emerald-500 text-3xl" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[var(--text-color)]">Senha redefinida!</h1>
|
||||
<p class="mt-2 text-sm text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Sua senha foi atualizada com sucesso.<br>Faça login novamente para continuar.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
label="Ir para o login"
|
||||
icon="pi pi-sign-in"
|
||||
class="w-full"
|
||||
@click="goLogin"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Estado: LINK INVÁLIDO ── -->
|
||||
<template v-else-if="linkInvalid">
|
||||
<div class="text-center space-y-6">
|
||||
<div class="mx-auto grid h-20 w-20 place-items-center rounded-full bg-red-500/10 border border-red-500/20">
|
||||
<i class="pi pi-times text-red-500 text-3xl" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[var(--text-color)]">Link inválido</h1>
|
||||
<p class="mt-2 text-sm text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Este link expirou ou já foi utilizado.<br>Solicite um novo e-mail de redefinição.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
label="Voltar para o login"
|
||||
icon="pi pi-arrow-left"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="w-full"
|
||||
@click="goLogin"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Estado: FORMULÁRIO ── -->
|
||||
<template v-else>
|
||||
<!-- Cabeçalho -->
|
||||
<div class="mb-7">
|
||||
<h1 class="text-3xl font-bold text-[var(--text-color)] leading-tight">Redefinir senha</h1>
|
||||
<p class="mt-1.5 text-sm text-[var(--text-color-secondary)]">
|
||||
Escolha uma senha nova e segura para sua conta.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form class="space-y-5" @submit.prevent="submit">
|
||||
|
||||
<!-- Nova senha -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">Nova senha</label>
|
||||
<Password
|
||||
v-model="newPassword"
|
||||
placeholder="Mínimo 8 caracteres"
|
||||
toggleMask
|
||||
:feedback="false"
|
||||
class="w-full"
|
||||
inputClass="w-full"
|
||||
:disabled="loading"
|
||||
/>
|
||||
|
||||
<!-- Barra de força -->
|
||||
<div v-if="newPassword" class="mt-2 space-y-1">
|
||||
<div class="flex gap-1">
|
||||
<div
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
class="h-1 flex-1 rounded-full transition-all duration-300"
|
||||
:class="i <= strengthScore ? strengthColor : 'bg-[var(--surface-border)]'"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs" :class="{
|
||||
'text-red-500': strengthScore === 1,
|
||||
'text-yellow-500': strengthScore === 2,
|
||||
'text-blue-500': strengthScore === 3,
|
||||
'text-emerald-500': strengthScore === 4,
|
||||
}">
|
||||
{{ strengthLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-else class="mt-1.5 text-xs text-[var(--text-color-secondary)]">
|
||||
Dica: combine letras, números e símbolos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirmar senha -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">Confirmar senha</label>
|
||||
<Password
|
||||
v-model="confirmPassword"
|
||||
placeholder="Repita a nova senha"
|
||||
toggleMask
|
||||
:feedback="false"
|
||||
class="w-full"
|
||||
inputClass="w-full"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<div v-if="confirmPassword" class="mt-1.5 flex items-center gap-1.5 text-xs"
|
||||
:class="matchOk ? 'text-emerald-500' : 'text-yellow-500'"
|
||||
>
|
||||
<i :class="matchOk ? 'pi pi-check' : 'pi pi-times'" />
|
||||
{{ matchOk ? 'As senhas conferem.' : 'As senhas não conferem.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botão -->
|
||||
<Button
|
||||
type="submit"
|
||||
label="Atualizar senha"
|
||||
icon="pi pi-check"
|
||||
class="w-full"
|
||||
:loading="loading"
|
||||
:disabled="!canSubmit"
|
||||
/>
|
||||
|
||||
</form>
|
||||
|
||||
<!-- Rodapé -->
|
||||
<div class="mt-8 pt-6 border-t border-[var(--surface-border)]">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)] hover:text-[var(--text-color)] transition-colors"
|
||||
@click="goLogin"
|
||||
>
|
||||
<i class="pi pi-arrow-left text-xs" />
|
||||
Voltar para o login
|
||||
</button>
|
||||
|
||||
<p class="mt-4 text-xs text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Se você não solicitou essa redefinição, ignore o e-mail e certifique-se de que sua conta está segura.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,370 +1,142 @@
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-8rem)] p-4 md:p-6">
|
||||
<div class="mx-auto w-full max-w-4xl">
|
||||
<Card class="overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
|
||||
<template #title>
|
||||
<div class="relative">
|
||||
<!-- blobs sutis -->
|
||||
<div class="pointer-events-none absolute inset-0 opacity-90">
|
||||
<div class="absolute -top-20 -right-16 h-60 w-60 rounded-full bg-indigo-400/10 blur-3xl" />
|
||||
<div class="absolute top-6 -left-20 h-72 w-72 rounded-full bg-emerald-400/10 blur-3xl" />
|
||||
<div class="absolute -bottom-24 right-10 h-64 w-64 rounded-full bg-fuchsia-400/10 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-col gap-2 p-5 md:p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="grid h-10 w-10 place-items-center rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)]"
|
||||
>
|
||||
<i class="pi pi-shield text-lg opacity-80" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xl md:text-2xl font-semibold leading-tight">
|
||||
Segurança
|
||||
</div>
|
||||
<div class="mt-0.5 text-sm md:text-base text-[var(--text-color-secondary)]">
|
||||
Troque sua senha com cuidado. Depois, você será deslogado por segurança.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-xs text-[var(--text-color-secondary)]"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400/70" />
|
||||
sessão ativa
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="p-5 md:p-6 pt-0">
|
||||
<!-- GRID -->
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<!-- Senha atual -->
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold">Senha atual</div>
|
||||
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
|
||||
Necessária para confirmar que é você.
|
||||
</div>
|
||||
</div>
|
||||
<i class="pi pi-lock text-sm opacity-70" />
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<Password
|
||||
v-model="currentPassword"
|
||||
toggleMask
|
||||
:feedback="false"
|
||||
inputClass="w-full"
|
||||
:disabled="loading || loadingReset"
|
||||
placeholder="Digite sua senha atual"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dica lateral -->
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="h-full rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold">Boas práticas</div>
|
||||
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
|
||||
Senhas fortes são menos “lembráveis”, mas mais seguras.
|
||||
</div>
|
||||
</div>
|
||||
<i class="pi pi-info-circle text-sm opacity-70" />
|
||||
</div>
|
||||
|
||||
<ul class="mt-3 space-y-2 text-xs text-[var(--text-color-secondary)]">
|
||||
<li class="flex gap-2">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-400/70"></span>
|
||||
Use pelo menos 8 caracteres, com maiúscula, minúscula e número.
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400/70"></span>
|
||||
Evite datas, nomes e padrões (1234, qwerty).
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-400/70"></span>
|
||||
Se estiver em computador público, finalize a sessão depois.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nova senha -->
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold">Nova senha</div>
|
||||
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
|
||||
Deve atender aos critérios mínimos.
|
||||
</div>
|
||||
</div>
|
||||
<i class="pi pi-key text-sm opacity-70" />
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<Password
|
||||
v-model="newPassword"
|
||||
toggleMask
|
||||
:feedback="false"
|
||||
inputClass="w-full"
|
||||
:disabled="loading || loadingReset"
|
||||
placeholder="Crie uma nova senha"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs">
|
||||
<span
|
||||
v-if="!newPassword"
|
||||
class="text-[var(--text-color-secondary)]"
|
||||
>
|
||||
Critérios: 8+ caracteres, maiúscula, minúscula e número.
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
:class="passwordStrengthOk ? 'text-emerald-500' : 'text-yellow-500'"
|
||||
>
|
||||
{{ passwordStrengthOk ? 'Senha forte o suficiente.' : 'Ainda está fraca — ajuste os critérios.' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmar -->
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold">Confirmar nova senha</div>
|
||||
<div class="mt-1 text-xs text-[var(--text-color-secondary)]">
|
||||
Evita erro de digitação.
|
||||
</div>
|
||||
</div>
|
||||
<i class="pi pi-check-circle text-sm opacity-70" />
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<Password
|
||||
v-model="confirmPassword"
|
||||
toggleMask
|
||||
:feedback="false"
|
||||
inputClass="w-full"
|
||||
:disabled="loading || loadingReset"
|
||||
placeholder="Repita a nova senha"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs">
|
||||
<span v-if="!confirmPassword" class="text-[var(--text-color-secondary)]">
|
||||
Digite novamente para confirmar.
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
:class="passwordMatchOk ? 'text-emerald-500' : 'text-yellow-500'"
|
||||
>
|
||||
{{ passwordMatchOk ? 'Confere.' : 'Não confere com a nova senha.' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="mt-5 flex flex-col-reverse gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">
|
||||
Ao trocar sua senha, você será desconectado de forma global.
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
label="Esqueci minha senha"
|
||||
severity="secondary"
|
||||
outlined
|
||||
icon="pi pi-envelope"
|
||||
:loading="loadingReset"
|
||||
:disabled="loading || loadingReset"
|
||||
@click="sendResetEmail"
|
||||
/>
|
||||
<Button
|
||||
label="Trocar senha"
|
||||
icon="pi pi-check"
|
||||
:loading="loading"
|
||||
:disabled="loading || loadingReset"
|
||||
@click="changePassword"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import Card from 'primevue/card'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
import Password from 'primevue/password'
|
||||
import Button from 'primevue/button'
|
||||
import Button from 'primevue/button'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { sessionUser, sessionRole } from '@/app/session'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const loading = ref(false)
|
||||
const loadingReset = ref(false)
|
||||
// ── Hero sticky ────────────────────────────────────────────
|
||||
const headerEl = ref(null)
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
let _observer = null
|
||||
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const loading = ref(false)
|
||||
const loadingReset = ref(false)
|
||||
const done = ref(false)
|
||||
|
||||
// ── validações ────────────────────────────────────────────────────────────
|
||||
|
||||
function isStrongEnough (p) {
|
||||
return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(p || '')
|
||||
}
|
||||
|
||||
const passwordStrengthOk = computed(() => isStrongEnough(newPassword.value))
|
||||
const passwordMatchOk = computed(() => !!confirmPassword.value && newPassword.value === confirmPassword.value)
|
||||
const strengthScore = computed(() => {
|
||||
const p = newPassword.value
|
||||
if (!p) return 0
|
||||
let s = 0
|
||||
if (p.length >= 8) s++
|
||||
if (/[A-Z]/.test(p)) s++
|
||||
if (/[a-z]/.test(p)) s++
|
||||
if (/\d/.test(p)) s++
|
||||
return s
|
||||
})
|
||||
|
||||
const strengthLabel = computed(() => {
|
||||
const labels = ['', 'Muito fraca', 'Fraca', 'Boa', 'Forte']
|
||||
return labels[strengthScore.value] || ''
|
||||
})
|
||||
|
||||
const strengthColor = computed(() => {
|
||||
const colors = ['', 'bg-red-500', 'bg-yellow-500', 'bg-blue-500', 'bg-emerald-500']
|
||||
return colors[strengthScore.value] || ''
|
||||
})
|
||||
|
||||
const strengthTextColor = computed(() => {
|
||||
const colors = ['', 'text-red-500', 'text-yellow-500', 'text-blue-500', 'text-emerald-500']
|
||||
return colors[strengthScore.value] || ''
|
||||
})
|
||||
|
||||
const strengthOk = computed(() => isStrongEnough(newPassword.value))
|
||||
const matchOk = computed(() => !!confirmPassword.value && newPassword.value === confirmPassword.value)
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
!!currentPassword.value && strengthOk.value && matchOk.value && !loading.value && !loadingReset.value
|
||||
)
|
||||
|
||||
// ── ações ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function clearFields () {
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
}
|
||||
|
||||
async function hardLogout () {
|
||||
// 1) tenta logout normal (se falhar, seguimos)
|
||||
try { await supabase.auth.signOut({ scope: 'global' }) } catch {}
|
||||
try {
|
||||
// DEBUG LOGOUT
|
||||
console.log('ANTES', (await supabase.auth.getSession()).data.session)
|
||||
await supabase.auth.signOut({ scope: 'global' })
|
||||
console.log('DEPOIS', (await supabase.auth.getSession()).data.session)
|
||||
} catch (e) {
|
||||
console.warn('[signOut failed]', e)
|
||||
}
|
||||
|
||||
// 2) zera estado reativo global
|
||||
sessionUser.value = null
|
||||
sessionRole.value = null
|
||||
|
||||
// 3) remove token persistido do supabase-js v2 (sb-*-auth-token)
|
||||
try {
|
||||
const keysToRemove = []
|
||||
const keys = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i)
|
||||
if (!k) continue
|
||||
if (k.startsWith('sb-') && k.includes('auth-token')) keysToRemove.push(k)
|
||||
if (k?.startsWith('sb-') && k.includes('auth-token')) keys.push(k)
|
||||
}
|
||||
keysToRemove.forEach((k) => localStorage.removeItem(k))
|
||||
} catch (e) {
|
||||
console.warn('[storage cleanup failed]', e)
|
||||
}
|
||||
|
||||
// 4) remove redirect pendente
|
||||
try {
|
||||
sessionStorage.removeItem('redirect_after_login')
|
||||
keys.forEach(k => localStorage.removeItem(k))
|
||||
} catch {}
|
||||
|
||||
// 5) redireciona de forma "hard"
|
||||
try { sessionStorage.removeItem('redirect_after_login') } catch {}
|
||||
window.location.replace('/auth/login')
|
||||
}
|
||||
|
||||
async function changePassword () {
|
||||
const user = sessionUser.value
|
||||
|
||||
if (!user?.email) {
|
||||
toast.add({ severity: 'error', summary: 'Sessão', detail: 'Usuário não encontrado na sessão.', life: 3500 })
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentPassword.value || !newPassword.value || !confirmPassword.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Campos', detail: 'Preencha todos os campos.', life: 3000 })
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Confirmação', detail: 'A confirmação não confere.', life: 3000 })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStrongEnough(newPassword.value)) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Senha fraca',
|
||||
detail: 'Use no mínimo 8 caracteres com maiúscula, minúscula e número.',
|
||||
life: 4500
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// Reautentica (padrão mais previsível)
|
||||
const { error: signError } = await supabase.auth.signInWithPassword({
|
||||
email: user.email,
|
||||
password: currentPassword.value
|
||||
})
|
||||
if (signError) throw signError
|
||||
const { data: uData, error: uErr } = await supabase.auth.getUser()
|
||||
if (uErr) throw uErr
|
||||
|
||||
const { error: upError } = await supabase.auth.updateUser({
|
||||
password: newPassword.value
|
||||
})
|
||||
const email = uData?.user?.email
|
||||
if (!email) throw new Error('Sessão inválida. Faça login novamente.')
|
||||
|
||||
// reautentica para confirmar senha atual
|
||||
const { error: signError } = await supabase.auth.signInWithPassword({ email, password: currentPassword.value })
|
||||
if (signError) throw new Error('Senha atual incorreta.')
|
||||
|
||||
// atualiza
|
||||
const { error: upError } = await supabase.auth.updateUser({ password: newPassword.value })
|
||||
if (upError) throw upError
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Senha atualizada',
|
||||
detail: 'Por segurança, você será deslogado.',
|
||||
life: 2500
|
||||
})
|
||||
|
||||
clearFields()
|
||||
await hardLogout()
|
||||
done.value = true
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Senha atualizada', detail: 'Por segurança, você será deslogado.', life: 2500 })
|
||||
setTimeout(() => hardLogout(), 2600)
|
||||
} catch (e) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro',
|
||||
detail: e?.message || 'Não foi possível trocar a senha.',
|
||||
life: 4000
|
||||
})
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível trocar a senha.', life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendResetEmail () {
|
||||
const user = sessionUser.value
|
||||
if (!user?.email) {
|
||||
toast.add({ severity: 'error', summary: 'Sessão', detail: 'Usuário não encontrado na sessão.', life: 3500 })
|
||||
return
|
||||
}
|
||||
onMounted(() => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ threshold: 0, rootMargin }
|
||||
)
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
|
||||
async function sendResetEmail () {
|
||||
loadingReset.value = true
|
||||
try {
|
||||
const { data: uData, error: uErr } = await supabase.auth.getUser()
|
||||
if (uErr) throw uErr
|
||||
|
||||
const email = uData?.user?.email
|
||||
if (!email) throw new Error('Sessão inválida. Faça login novamente.')
|
||||
|
||||
const redirectTo = `${window.location.origin}/auth/reset-password`
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(user.email, { redirectTo })
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email, { redirectTo })
|
||||
if (error) throw error
|
||||
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'E-mail enviado',
|
||||
detail: 'Verifique sua caixa de entrada para redefinir a senha.',
|
||||
life: 5000
|
||||
})
|
||||
toast.add({ severity: 'info', summary: 'E-mail enviado', detail: 'Verifique sua caixa de entrada para redefinir a senha.', life: 5000 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao enviar e-mail.', life: 4500 })
|
||||
} finally {
|
||||
@@ -372,3 +144,244 @@ async function sendResetEmail () {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="sec-sentinel" />
|
||||
|
||||
<!-- Hero sticky -->
|
||||
<div ref="headerEl" class="sec-hero w-full max-w-2xl mx-auto px-3 md:px-5 mb-4" :class="{ 'sec-hero--stuck': headerStuck }">
|
||||
<div class="sec-hero__blobs" aria-hidden="true">
|
||||
<div class="sec-hero__blob sec-hero__blob--1" />
|
||||
<div class="sec-hero__blob sec-hero__blob--2" />
|
||||
</div>
|
||||
|
||||
<div class="sec-hero__row1">
|
||||
<div class="sec-hero__brand">
|
||||
<div class="sec-hero__icon"><i class="pi pi-shield text-lg" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="sec-hero__title">Segurança</div>
|
||||
<div class="sec-hero__sub">Gerencie o acesso e a senha da sua conta</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="hidden xl:inline-flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border border-emerald-200 text-emerald-700 bg-emerald-50 shrink-0">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
Sessão ativa
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-3 md:px-5 pb-8 flex justify-center">
|
||||
<div class="w-full max-w-2xl space-y-4">
|
||||
|
||||
<!-- Card principal -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
||||
|
||||
<!-- Seção: Trocar senha -->
|
||||
<div class="px-6 py-5 border-b border-[var(--surface-border)]">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-[var(--text-color)]">Trocar senha</p>
|
||||
<p class="text-xs text-[var(--text-color-secondary)] mt-0.5">
|
||||
Confirme sua senha atual e defina uma nova.
|
||||
</p>
|
||||
</div>
|
||||
<span class="hidden sm:inline-flex items-center gap-1.5 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-xs text-[var(--text-color-secondary)]">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
sessão ativa
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Estado: concluído -->
|
||||
<div v-if="done" class="px-6 py-10 text-center space-y-4">
|
||||
<div class="mx-auto grid h-16 w-16 place-items-center rounded-full bg-emerald-500/10 border border-emerald-500/20">
|
||||
<i class="pi pi-check text-emerald-500 text-2xl" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-[var(--text-color)]">Senha atualizada!</p>
|
||||
<p class="text-sm text-[var(--text-color-secondary)] mt-1">Redirecionando para o login…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Estado: formulário -->
|
||||
<div v-else class="px-6 py-6 space-y-5">
|
||||
|
||||
<!-- Senha atual -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">
|
||||
Senha atual
|
||||
</label>
|
||||
<Password
|
||||
v-model="currentPassword"
|
||||
placeholder="Digite sua senha atual"
|
||||
toggleMask
|
||||
:feedback="false"
|
||||
class="w-full"
|
||||
inputClass="w-full"
|
||||
:disabled="loading || loadingReset"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-[var(--text-color-secondary)]">
|
||||
Necessária para confirmar que é você.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-[var(--surface-border)]" />
|
||||
|
||||
<!-- Nova senha -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">
|
||||
Nova senha
|
||||
</label>
|
||||
<Password
|
||||
v-model="newPassword"
|
||||
placeholder="Mínimo 8 caracteres"
|
||||
toggleMask
|
||||
:feedback="false"
|
||||
class="w-full"
|
||||
inputClass="w-full"
|
||||
:disabled="loading || loadingReset"
|
||||
/>
|
||||
|
||||
<!-- Barra de força -->
|
||||
<div v-if="newPassword" class="mt-2 space-y-1">
|
||||
<div class="flex gap-1">
|
||||
<div
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
class="h-1 flex-1 rounded-full transition-all duration-300"
|
||||
:class="i <= strengthScore ? strengthColor : 'bg-[var(--surface-border)]'"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs" :class="strengthTextColor">{{ strengthLabel }}</span>
|
||||
</div>
|
||||
<p v-else class="mt-1.5 text-xs text-[var(--text-color-secondary)]">
|
||||
Critérios: 8+ caracteres, maiúscula, minúscula e número.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirmar senha -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">
|
||||
Confirmar nova senha
|
||||
</label>
|
||||
<Password
|
||||
v-model="confirmPassword"
|
||||
placeholder="Repita a nova senha"
|
||||
toggleMask
|
||||
:feedback="false"
|
||||
class="w-full"
|
||||
inputClass="w-full"
|
||||
:disabled="loading || loadingReset"
|
||||
/>
|
||||
<div v-if="confirmPassword" class="mt-1.5 flex items-center gap-1.5 text-xs"
|
||||
:class="matchOk ? 'text-emerald-500' : 'text-yellow-500'"
|
||||
>
|
||||
<i :class="matchOk ? 'pi pi-check' : 'pi pi-times'" />
|
||||
{{ matchOk ? 'As senhas conferem.' : 'As senhas não conferem.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aviso -->
|
||||
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-3 flex items-start gap-3">
|
||||
<i class="pi pi-info-circle text-[var(--text-color-secondary)] text-sm mt-0.5 flex-shrink-0" />
|
||||
<p class="text-xs text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Ao trocar sua senha, você será desconectado de todos os dispositivos por segurança.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-between sm:items-center pt-1">
|
||||
<Button
|
||||
label="Enviar link por e-mail"
|
||||
severity="secondary"
|
||||
outlined
|
||||
icon="pi pi-envelope"
|
||||
:loading="loadingReset"
|
||||
:disabled="loading || loadingReset"
|
||||
@click="sendResetEmail"
|
||||
/>
|
||||
<Button
|
||||
label="Trocar senha"
|
||||
icon="pi pi-check"
|
||||
:loading="loading"
|
||||
:disabled="!canSubmit"
|
||||
@click="changePassword"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Card informativo: dicas -->
|
||||
<div class="mt-4 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-6 py-5">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<i class="pi pi-lightbulb text-sm text-[var(--text-color-secondary)]" />
|
||||
<span class="text-sm font-semibold text-[var(--text-color)]">Boas práticas</span>
|
||||
</div>
|
||||
<ul class="space-y-2">
|
||||
<li class="flex items-start gap-2.5 text-xs text-[var(--text-color-secondary)]">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-400 flex-shrink-0" />
|
||||
Use pelo menos 8 caracteres com maiúscula, minúscula e número.
|
||||
</li>
|
||||
<li class="flex items-start gap-2.5 text-xs text-[var(--text-color-secondary)]">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
|
||||
Evite datas, nomes e sequências óbvias (1234, qwerty).
|
||||
</li>
|
||||
<li class="flex items-start gap-2.5 text-xs text-[var(--text-color-secondary)]">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-400 flex-shrink-0" />
|
||||
Se estiver em computador compartilhado, encerre a sessão depois.
|
||||
</li>
|
||||
<li class="flex items-start gap-2.5 text-xs text-[var(--text-color-secondary)]">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-amber-400 flex-shrink-0" />
|
||||
Não reutilize a mesma senha de outros serviços.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sec-sentinel { height: 1px; }
|
||||
|
||||
.sec-hero {
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top, 56px);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.sec-hero--stuck {
|
||||
border-top-left-radius: 0; border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.sec-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.sec-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
|
||||
.sec-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.10); }
|
||||
.sec-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(16,185,129,0.08); }
|
||||
|
||||
.sec-hero__row1 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
}
|
||||
.sec-hero__brand {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.sec-hero__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
.sec-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
|
||||
.sec-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
<!-- src/views/pages/auth/WelcomePage.vue -->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
import Card from 'primevue/card'
|
||||
import Message from 'primevue/message'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import Tag from 'primevue/tag'
|
||||
import Chip from 'primevue/chip'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
|
||||
@@ -20,7 +17,7 @@ const router = useRouter()
|
||||
const planFromQuery = computed(() => String(route.query.plan || '').trim().toLowerCase())
|
||||
const intervalFromQuery = computed(() => String(route.query.interval || '').trim().toLowerCase())
|
||||
|
||||
function normalizeInterval(v) {
|
||||
function normalizeInterval (v) {
|
||||
if (v === 'monthly') return 'month'
|
||||
if (v === 'annual' || v === 'yearly') return 'year'
|
||||
return v
|
||||
@@ -34,6 +31,22 @@ const intervalLabel = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
const hasPlanQuery = computed(() => !!planFromQuery.value)
|
||||
|
||||
// ============================
|
||||
// Session (opcional para CTA melhor)
|
||||
// ============================
|
||||
const hasSession = ref(false)
|
||||
|
||||
async function checkSession () {
|
||||
try {
|
||||
const { data } = await supabase.auth.getSession()
|
||||
hasSession.value = !!data?.session
|
||||
} catch {
|
||||
hasSession.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Pricing
|
||||
// ============================
|
||||
@@ -43,29 +56,36 @@ const planRow = ref(null)
|
||||
const planName = computed(() => planRow.value?.public_name || planRow.value?.plan_name || null)
|
||||
const planDescription = computed(() => planRow.value?.public_description || null)
|
||||
|
||||
const amountCents = computed(() => {
|
||||
if (!planRow.value) return null
|
||||
return intervalNormalized.value === 'year'
|
||||
? planRow.value.yearly_cents
|
||||
: planRow.value.monthly_cents
|
||||
})
|
||||
function amountForInterval (row, interval) {
|
||||
if (!row) return null
|
||||
const cents = interval === 'year' ? row.yearly_cents : row.monthly_cents
|
||||
// fallback inteligente: se não houver preço nesse intervalo, tenta o outro
|
||||
if (cents == null) return interval === 'year' ? row.monthly_cents : row.yearly_cents
|
||||
return cents
|
||||
}
|
||||
|
||||
const currency = computed(() => {
|
||||
if (!planRow.value) return 'BRL'
|
||||
return intervalNormalized.value === 'year'
|
||||
? (planRow.value.yearly_currency || 'BRL')
|
||||
: (planRow.value.monthly_currency || 'BRL')
|
||||
})
|
||||
function currencyForInterval (row, interval) {
|
||||
if (!row) return 'BRL'
|
||||
const cur = interval === 'year' ? (row.yearly_currency || 'BRL') : (row.monthly_currency || 'BRL')
|
||||
return cur || 'BRL'
|
||||
}
|
||||
|
||||
const amountCents = computed(() => amountForInterval(planRow.value, intervalNormalized.value))
|
||||
const currency = computed(() => currencyForInterval(planRow.value, intervalNormalized.value))
|
||||
|
||||
const formattedPrice = computed(() => {
|
||||
if (amountCents.value == null) return null
|
||||
return new Intl.NumberFormat('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: currency.value || 'BRL'
|
||||
}).format(amountCents.value / 100)
|
||||
try {
|
||||
return new Intl.NumberFormat('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: currency.value || 'BRL'
|
||||
}).format(Number(amountCents.value) / 100)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
async function loadPlan() {
|
||||
async function loadPlan () {
|
||||
planRow.value = null
|
||||
if (!planFromQuery.value) return
|
||||
|
||||
@@ -99,16 +119,31 @@ async function loadPlan() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadPlan)
|
||||
watch(() => planFromQuery.value, () => loadPlan())
|
||||
|
||||
function goLogin() {
|
||||
function goLogin () {
|
||||
router.push('/auth/login')
|
||||
}
|
||||
|
||||
function goBackLanding() {
|
||||
function goBackLanding () {
|
||||
router.push('/lp')
|
||||
}
|
||||
|
||||
function goDashboard () {
|
||||
router.push('/admin')
|
||||
}
|
||||
|
||||
function goPricing () {
|
||||
router.push('/lp#pricing')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await checkSession()
|
||||
await loadPlan()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => planFromQuery.value,
|
||||
() => loadPlan()
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -123,7 +158,7 @@ function goBackLanding() {
|
||||
<div class="relative w-full max-w-6xl">
|
||||
<div class="rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm overflow-hidden">
|
||||
<div class="grid grid-cols-12">
|
||||
<!-- LEFT: boas-vindas (PrimeBlocks-like) -->
|
||||
<!-- LEFT -->
|
||||
<div
|
||||
class="col-span-12 lg:col-span-6 p-6 md:p-10 bg-[color-mix(in_srgb,var(--surface-card),transparent_6%)] border-b lg:border-b-0 lg:border-r border-[var(--surface-border)]"
|
||||
>
|
||||
@@ -145,8 +180,8 @@ function goBackLanding() {
|
||||
Bem-vindo(a).
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-sm md:text-base text-[var(--text-color-secondary)] max-w-lg">
|
||||
Sua conta foi criada e a sua intenção de assinatura foi registrada.
|
||||
<div class="mt-3 text-sm md:text-base text-[var(--text-color-secondary)] max-w-lg leading-relaxed">
|
||||
Sua conta foi criada e sua intenção de assinatura foi registrada.
|
||||
Agora o caminho é simples: instruções de pagamento → confirmação → ativação do plano.
|
||||
</div>
|
||||
|
||||
@@ -171,7 +206,9 @@ function goBackLanding() {
|
||||
<div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">3) Plano ativo</div>
|
||||
<div class="font-semibold mt-1">Recursos liberados</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">entitlements PRO quando pago</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
|
||||
entitlements PRO quando confirmado
|
||||
</div>
|
||||
</div>
|
||||
<i class="pi pi-verified opacity-60" />
|
||||
</div>
|
||||
@@ -182,25 +219,25 @@ function goBackLanding() {
|
||||
<div class="mt-6 flex flex-wrap gap-2">
|
||||
<Tag severity="secondary" value="Sem cobrança automática" />
|
||||
<Tag severity="secondary" value="Ativação após confirmação" />
|
||||
<Tag severity="secondary" value="Fluxo pronto para gateway depois" />
|
||||
<Tag severity="secondary" value="Gateway depois, sem retrabalho" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
|
||||
* Página de boas-vindas inspirada em layouts PrimeBlocks.
|
||||
* Boas-vindas inspirada em layouts PrimeBlocks.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: resumo + botões -->
|
||||
<!-- RIGHT -->
|
||||
<div class="col-span-12 lg:col-span-6 p-6 md:p-10">
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="text-2xl font-semibold">Conta criada 🎉</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1 leading-relaxed">
|
||||
Você já pode entrar. Se o seu plano for PRO, ele será ativado após confirmação do pagamento.
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<Message severity="success" class="mb-3">
|
||||
Sua intenção de assinatura foi registrada.
|
||||
Intenção de assinatura registrada.
|
||||
</Message>
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)]">
|
||||
@@ -208,13 +245,13 @@ function goBackLanding() {
|
||||
Carregando detalhes do plano…
|
||||
</div>
|
||||
|
||||
<Card v-else class="overflow-hidden">
|
||||
<Card v-else class="overflow-hidden rounded-[2rem]">
|
||||
<template #content>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Resumo do plano</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Resumo</div>
|
||||
|
||||
<div class="mt-1 flex items-center gap-2 flex-wrap">
|
||||
<div v-if="hasPlanQuery" class="mt-1 flex items-center gap-2 flex-wrap">
|
||||
<div class="text-lg font-semibold truncate">
|
||||
{{ planName || 'Plano' }}
|
||||
</div>
|
||||
@@ -223,18 +260,25 @@ function goBackLanding() {
|
||||
<Chip v-if="intervalLabel" :label="intervalLabel" />
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-2xl font-semibold leading-none">
|
||||
<div v-else class="mt-1">
|
||||
<div class="text-lg font-semibold">Sem plano selecionado</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
|
||||
Você pode escolher um plano agora ou seguir no FREE.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasPlanQuery" class="mt-2 text-2xl font-semibold leading-none">
|
||||
{{ formattedPrice || '—' }}
|
||||
<span v-if="intervalLabel" class="text-sm font-normal text-[var(--text-color-secondary)]">
|
||||
/{{ intervalNormalized === 'month' ? 'mês' : 'ano' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="planDescription" class="mt-2 text-sm text-[var(--text-color-secondary)]">
|
||||
<div v-if="planDescription" class="mt-2 text-sm text-[var(--text-color-secondary)] leading-relaxed">
|
||||
{{ planDescription }}
|
||||
</div>
|
||||
|
||||
<Message v-if="planFromQuery && !planRow" severity="warn" class="mt-3">
|
||||
<Message v-if="hasPlanQuery && !planRow" severity="warn" class="mt-3">
|
||||
Não encontrei esse plano na vitrine pública. Você pode continuar normalmente.
|
||||
</Message>
|
||||
</div>
|
||||
@@ -243,15 +287,40 @@ function goBackLanding() {
|
||||
<Divider class="my-4" />
|
||||
|
||||
<Message severity="info" class="mb-0">
|
||||
Próximo passo: você receberá instruções de pagamento (PIX ou boleto).
|
||||
Próximo passo: você receberá instruções de pagamento (PIX/boleto).
|
||||
Assim que confirmado, sua assinatura será ativada.
|
||||
</Message>
|
||||
|
||||
<div v-if="!hasPlanQuery" class="mt-3">
|
||||
<Button
|
||||
label="Escolher um plano"
|
||||
icon="pi pi-credit-card"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="w-full"
|
||||
@click="goPricing"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 gap-2">
|
||||
<Button label="Ir para login" class="w-full mb-2" icon="pi pi-sign-in" @click="goLogin" />
|
||||
<Button
|
||||
v-if="hasSession"
|
||||
label="Ir para o painel"
|
||||
class="w-full mb-2"
|
||||
icon="pi pi-arrow-right"
|
||||
@click="goDashboard"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
label="Ir para login"
|
||||
class="w-full mb-2"
|
||||
icon="pi pi-sign-in"
|
||||
@click="goLogin"
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Voltar para a página inicial"
|
||||
severity="secondary"
|
||||
@@ -271,4 +340,4 @@ function goBackLanding() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
Reference in New Issue
Block a user