This commit is contained in:
Leonardo
2026-03-06 06:37:13 -03:00
parent d58dc21297
commit f733db8436
146 changed files with 43436 additions and 12779 deletions
+410 -305
View File
@@ -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 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 &copy; {{ 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>