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
+334 -321
View File
@@ -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>