Implement secure account lock and certificate-based unlock flow
This commit is contained in:
@@ -26,8 +26,12 @@ export default function LoginPage() {
|
||||
|
||||
if (code === 'EMAIL_NOT_VERIFIED') {
|
||||
toast.success('Tu cuenta aún no está validada. Te enviamos un nuevo correo de confirmación.');
|
||||
} else if (code === 'ACCOUNT_LOCKED') {
|
||||
toast.error('Tu cuenta fue bloqueada temporalmente. Revisa tu correo para desbloquear el sistema.');
|
||||
} else if (code === 'MAIL_SEND_FAILED') {
|
||||
toast.error(message);
|
||||
} else if (code === 'UNLOCK_MAIL_SEND_FAILED') {
|
||||
toast.error(message);
|
||||
} else {
|
||||
toast.error(message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { AlertTriangle, CheckCircle2, KeyRound, LoaderCircle, LockKeyhole } from 'lucide-react';
|
||||
import { getUnlockAccountRequest, requestUnlockPasswordChange } from '../../api/auth';
|
||||
|
||||
const getPasswordChecks = (password = '') => ([
|
||||
{ label: 'Al menos 8 caracteres', valid: password.length >= 8 },
|
||||
{ label: 'Una mayúscula', valid: /[A-Z]/.test(password) },
|
||||
{ label: 'Una minúscula', valid: /[a-z]/.test(password) },
|
||||
{ label: 'Un número', valid: /\d/.test(password) },
|
||||
{ label: 'Un carácter especial', valid: /[^A-Za-z0-9]/.test(password) },
|
||||
]);
|
||||
|
||||
export default function UnlockAccountPage() {
|
||||
const { token } = useParams();
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [message, setMessage] = useState('Validando el enlace de desbloqueo...');
|
||||
const [accountInfo, setAccountInfo] = useState(null);
|
||||
const [form, setForm] = useState({
|
||||
certPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const passwordChecks = useMemo(() => getPasswordChecks(form.newPassword), [form.newPassword]);
|
||||
const isPasswordStrong = passwordChecks.every((item) => item.valid);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
getUnlockAccountRequest(token)
|
||||
.then(({ data }) => {
|
||||
if (!active) return;
|
||||
setAccountInfo(data.data);
|
||||
setStatus('ready');
|
||||
setMessage('Valida tu contraseña de firma registrada y define una nueva contraseña de acceso.');
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active) return;
|
||||
setStatus('error');
|
||||
setMessage(error.response?.data?.error || 'El enlace de desbloqueo no es válido o expiró.');
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
const handleChange = (event) => {
|
||||
const { name, value } = event.target;
|
||||
setForm((current) => ({ ...current, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!isPasswordStrong) {
|
||||
setStatus('ready');
|
||||
setMessage('La nueva contraseña no cumple con los requisitos de seguridad.');
|
||||
return;
|
||||
}
|
||||
if (form.newPassword !== form.confirmPassword) {
|
||||
setStatus('ready');
|
||||
setMessage('Las contraseñas no coinciden.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const { data } = await requestUnlockPasswordChange(token, {
|
||||
certPassword: form.certPassword,
|
||||
newPassword: form.newPassword,
|
||||
});
|
||||
setStatus('success');
|
||||
setMessage(data.data.message || 'Te enviamos un correo para confirmar el cambio de contraseña.');
|
||||
} catch (error) {
|
||||
setStatus('ready');
|
||||
setMessage(error.response?.data?.error || 'No se pudo iniciar el proceso de desbloqueo.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<div className="auth-logo">
|
||||
{status === 'loading' && <LoaderCircle size={32} className="spin-soft" />}
|
||||
{status === 'ready' && <LockKeyhole size={32} />}
|
||||
{status === 'success' && <CheckCircle2 size={32} />}
|
||||
{status === 'error' && <AlertTriangle size={32} />}
|
||||
<h1>Desbloquear sistema</h1>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
|
||||
{status === 'ready' && (
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<div className="sign-confirm-details">
|
||||
<strong>{accountInfo?.email}</strong>
|
||||
<span>Certificado: {accountInfo?.certificateAlias || accountInfo?.certificateSubject || 'Registrado'}</span>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Contraseña de la firma registrada</label>
|
||||
<input type="password" name="certPassword" value={form.certPassword} onChange={handleChange} required />
|
||||
<small className="field-hint">Usaremos esta contraseña solo para validar tu certificado activo.</small>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Nueva contraseña</label>
|
||||
<input type="password" name="newPassword" value={form.newPassword} onChange={handleChange} required />
|
||||
<div className="password-rules">
|
||||
{passwordChecks.map((check) => (
|
||||
<div
|
||||
key={check.label}
|
||||
className={`password-rule ${check.valid ? 'password-rule--ok' : 'password-rule--pending'}`}
|
||||
>
|
||||
<span>{check.valid ? '✓' : '•'}</span>
|
||||
<span>{check.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Confirmar nueva contraseña</label>
|
||||
<input type="password" name="confirmPassword" value={form.confirmPassword} onChange={handleChange} required />
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary btn-full" disabled={submitting}>
|
||||
<KeyRound size={16} />
|
||||
{submitting ? 'Procesando...' : 'Solicitar desbloqueo y cambio'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div className="auth-form">
|
||||
<Link to="/login" className="btn btn-primary btn-full">
|
||||
Ir a iniciar sesión
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="auth-form">
|
||||
<Link to="/login" className="btn btn-secondary btn-full">
|
||||
Volver al login
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user