478 lines
18 KiB
Vue
478 lines
18 KiB
Vue
<script setup>
|
|
import FloatingConfigurator from '@/components/FloatingConfigurator.vue'
|
|
import { useTenantStore } from '@/stores/tenantStore'
|
|
|
|
import { ref, onMounted, 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)
|
|
import { initSession, sessionIsSaasAdmin } from '@/app/session'
|
|
|
|
const tenant = useTenantStore()
|
|
const toast = useToast()
|
|
const router = useRouter()
|
|
|
|
const email = ref('')
|
|
const password = ref('')
|
|
const checked = ref(false)
|
|
|
|
const loading = ref(false)
|
|
const authError = ref('')
|
|
|
|
// recovery
|
|
const openRecovery = ref(false)
|
|
const recoveryEmail = ref('')
|
|
const loadingRecovery = ref(false)
|
|
const recoverySent = ref(false)
|
|
|
|
const canSubmit = computed(() => {
|
|
return !!email.value?.trim() && !!password.value && !loading.value && !loadingRecovery.value
|
|
})
|
|
|
|
function isEmail (v) {
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v || '').trim())
|
|
}
|
|
|
|
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'
|
|
return '/'
|
|
}
|
|
|
|
function persistRememberedEmail () {
|
|
const mail = String(email.value || '').trim()
|
|
try {
|
|
if (checked.value && mail) localStorage.setItem('remember_login_email', mail)
|
|
else localStorage.removeItem('remember_login_email')
|
|
} catch {
|
|
// ignora storage bloqueado
|
|
}
|
|
}
|
|
|
|
async function onSubmit () {
|
|
authError.value = ''
|
|
loading.value = true
|
|
|
|
try {
|
|
const mail = String(email.value || '').trim()
|
|
|
|
const res = await supabase.auth.signInWithPassword({
|
|
email: mail,
|
|
password: password.value
|
|
})
|
|
|
|
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) {
|
|
sessionStorage.removeItem('redirect_after_login')
|
|
router.push(redirect)
|
|
return
|
|
}
|
|
router.push('/saas')
|
|
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
|
|
}
|
|
|
|
// ✅ fonte de verdade: tenant_members (rpc my_tenants)
|
|
await tenant.loadSessionAndTenant()
|
|
|
|
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
|
|
}
|
|
|
|
// ✅ se havia redirect, vai pra ele
|
|
if (redirect) {
|
|
sessionStorage.removeItem('redirect_after_login')
|
|
router.push(redirect)
|
|
return
|
|
}
|
|
|
|
const intended = sessionStorage.getItem('intended_area')
|
|
sessionStorage.removeItem('intended_area')
|
|
|
|
const target = roleToPath(tenant.activeRole)
|
|
|
|
if (intended && intended !== tenant.activeRole) {
|
|
router.push(target)
|
|
return
|
|
}
|
|
|
|
router.push(target)
|
|
} catch (e) {
|
|
authError.value = e?.message || 'Não foi possível entrar.'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function openForgot () {
|
|
recoverySent.value = false
|
|
recoveryEmail.value = email.value?.trim() || ''
|
|
openRecovery.value = true
|
|
}
|
|
|
|
async function sendRecoveryEmail () {
|
|
const mail = String(recoveryEmail.value || '').trim()
|
|
if (!mail || !isEmail(mail)) {
|
|
toast.add({ severity: 'warn', summary: 'E-mail', detail: 'Digite um e-mail válido.', life: 3000 })
|
|
return
|
|
}
|
|
|
|
loadingRecovery.value = true
|
|
recoverySent.value = false
|
|
try {
|
|
const redirectTo = `${window.location.origin}/auth/reset-password`
|
|
const { error } = await supabase.auth.resetPasswordForEmail(mail, { redirectTo })
|
|
if (error) throw error
|
|
recoverySent.value = true
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao enviar e-mail.', life: 4500 })
|
|
} finally {
|
|
loadingRecovery.value = false
|
|
}
|
|
}
|
|
|
|
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') || ''
|
|
} catch {}
|
|
|
|
if (preEmail) email.value = preEmail
|
|
else if (remembered) email.value = remembered
|
|
|
|
if (prePass) password.value = prePass
|
|
|
|
checked.value = !!remembered
|
|
|
|
sessionStorage.removeItem('login_prefill_email')
|
|
sessionStorage.removeItem('login_prefill_password')
|
|
})
|
|
</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="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%);
|
|
"
|
|
/>
|
|
<!-- 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>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template> |