Implement secure account lock and certificate-based unlock flow

This commit is contained in:
2026-04-07 15:45:08 -05:00
parent 46a11728b8
commit 9a8e70c943
13 changed files with 528 additions and 10 deletions
+30 -1
View File
@@ -75,6 +75,24 @@ const confirmPasswordChange = async (req, res, next) => {
} }
}; };
const getUnlockAccountRequest = async (req, res, next) => {
try {
const result = await authService.getUnlockAccountRequest(req.params.token);
return sendSuccess(res, result, 200);
} catch (err) {
return next(err);
}
};
const requestUnlockPasswordChange = async (req, res, next) => {
try {
await authService.validatePasswordResetBySignature(req.params.token, req.body, getContext(req));
return sendSuccess(res, { message: 'Te enviamos un correo para confirmar el cambio de contraseña y desbloquear la cuenta.' }, 200);
} catch (err) {
return next(err);
}
};
const verifyEmail = async (req, res, next) => { const verifyEmail = async (req, res, next) => {
try { try {
await authService.verifyEmail(req.params.token); await authService.verifyEmail(req.params.token);
@@ -98,4 +116,15 @@ const me = async (req, res, next) => {
} }
}; };
module.exports = { register, login, refresh, logout, verifyEmail, me, changePassword, confirmPasswordChange }; module.exports = {
register,
login,
refresh,
logout,
verifyEmail,
me,
changePassword,
confirmPasswordChange,
getUnlockAccountRequest,
requestUnlockPasswordChange,
};
+99 -2
View File
@@ -1,8 +1,34 @@
const db = require('../../config/db'); const db = require('../../config/db');
const ensureSchema = async () => {
const columns = [
{ name: 'failed_login_attempts', sql: 'ALTER TABLE users ADD COLUMN failed_login_attempts INT NOT NULL DEFAULT 0' },
{ name: 'locked_at', sql: 'ALTER TABLE users ADD COLUMN locked_at DATETIME NULL DEFAULT NULL' },
{ name: 'unlock_token', sql: 'ALTER TABLE users ADD COLUMN unlock_token VARCHAR(255) NULL DEFAULT NULL' },
{ name: 'unlock_token_expires', sql: 'ALTER TABLE users ADD COLUMN unlock_token_expires DATETIME NULL DEFAULT NULL' },
];
for (const column of columns) {
const [rows] = await db.execute(
`SELECT COUNT(*) AS count
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'users'
AND COLUMN_NAME = ?`,
[column.name]
);
if (!rows[0]?.count) {
await db.execute(column.sql);
}
}
};
const findByEmail = async (email) => { const findByEmail = async (email) => {
const [rows] = await db.execute( const [rows] = await db.execute(
'SELECT id, name, email, password_hash, role, is_verified, is_active FROM users WHERE email = ?', `SELECT id, name, email, password_hash, role, is_verified, is_active,
failed_login_attempts, locked_at, unlock_token, unlock_token_expires
FROM users WHERE email = ?`,
[email] [email]
); );
return rows[0] || null; return rows[0] || null;
@@ -18,12 +44,25 @@ const findById = async (id) => {
const findAuthUserById = async (id) => { const findAuthUserById = async (id) => {
const [rows] = await db.execute( const [rows] = await db.execute(
'SELECT id, name, email, password_hash, role, is_verified, is_active FROM users WHERE id = ?', `SELECT id, name, email, password_hash, role, is_verified, is_active,
failed_login_attempts, locked_at, unlock_token, unlock_token_expires
FROM users WHERE id = ?`,
[id] [id]
); );
return rows[0] || null; return rows[0] || null;
}; };
const findByUnlockToken = async (token) => {
const [rows] = await db.execute(
`SELECT id, name, email, password_hash, role, is_verified, is_active,
failed_login_attempts, locked_at, unlock_token, unlock_token_expires
FROM users
WHERE unlock_token = ? AND unlock_token_expires > NOW()`,
[token]
);
return rows[0] || null;
};
const createUser = async ({ name, email, passwordHash, verificationToken, verificationTokenExpires }) => { const createUser = async ({ name, email, passwordHash, verificationToken, verificationTokenExpires }) => {
const [result] = await db.execute( const [result] = await db.execute(
`INSERT INTO users (name, email, password_hash, verification_token, verification_token_expires) `INSERT INTO users (name, email, password_hash, verification_token, verification_token_expires)
@@ -53,6 +92,57 @@ const updateVerificationToken = async (userId, token, expiresAt) => {
); );
}; };
const recordFailedLoginAttempt = async (userId) => {
await db.execute(
'UPDATE users SET failed_login_attempts = COALESCE(failed_login_attempts, 0) + 1 WHERE id = ?',
[userId]
);
};
const setAccountLocked = async (userId, unlockToken, unlockTokenExpires) => {
await db.execute(
`UPDATE users
SET failed_login_attempts = 3,
locked_at = NOW(),
unlock_token = ?,
unlock_token_expires = ?
WHERE id = ?`,
[unlockToken, unlockTokenExpires, userId]
);
};
const resetFailedLoginAttempts = async (userId) => {
await db.execute(
`UPDATE users
SET failed_login_attempts = 0
WHERE id = ?`,
[userId]
);
};
const clearAccountLock = async (userId) => {
await db.execute(
`UPDATE users
SET failed_login_attempts = 0,
locked_at = NULL,
unlock_token = NULL,
unlock_token_expires = NULL
WHERE id = ?`,
[userId]
);
};
const updateUnlockToken = async (userId, unlockToken, unlockTokenExpires) => {
await db.execute(
`UPDATE users
SET unlock_token = ?,
unlock_token_expires = ?,
locked_at = COALESCE(locked_at, NOW())
WHERE id = ?`,
[unlockToken, unlockTokenExpires, userId]
);
};
const saveRefreshToken = async (userId, token, expiresAt) => { const saveRefreshToken = async (userId, token, expiresAt) => {
await db.execute( await db.execute(
'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)', 'INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)',
@@ -103,12 +193,19 @@ const deleteUser = async (userId) => {
}; };
module.exports = { module.exports = {
ensureSchema,
findByEmail, findByEmail,
findById, findById,
findAuthUserById, findAuthUserById,
findByUnlockToken,
createUser, createUser,
verifyEmail, verifyEmail,
updateVerificationToken, updateVerificationToken,
recordFailedLoginAttempt,
setAccountLocked,
resetFailedLoginAttempts,
clearAccountLock,
updateUnlockToken,
saveRefreshToken, saveRefreshToken,
findRefreshToken, findRefreshToken,
deleteRefreshToken, deleteRefreshToken,
+3 -1
View File
@@ -1,6 +1,6 @@
const express = require('express'); const express = require('express');
const authController = require('./auth.controller'); const authController = require('./auth.controller');
const { validate, validateRegisterMultipart, loginSchema, refreshSchema, changePasswordSchema } = require('./auth.validator'); const { validate, validateRegisterMultipart, loginSchema, refreshSchema, changePasswordSchema, unlockAccountSchema } = require('./auth.validator');
const authenticate = require('../../middlewares/authenticate'); const authenticate = require('../../middlewares/authenticate');
const { uploadTempCertificate } = require('../../config/multer'); const { uploadTempCertificate } = require('../../config/multer');
@@ -12,6 +12,8 @@ router.post('/refresh', validate(refreshSchema), authController.refresh);
router.post('/logout', authController.logout); router.post('/logout', authController.logout);
router.get('/verify/:token', authController.verifyEmail); router.get('/verify/:token', authController.verifyEmail);
router.get('/confirm-password-change/:token', authController.confirmPasswordChange); router.get('/confirm-password-change/:token', authController.confirmPasswordChange);
router.get('/unlock-account/:token', authController.getUnlockAccountRequest);
router.post('/unlock-account/:token/request-password-change', validate(unlockAccountSchema), authController.requestUnlockPasswordChange);
router.get('/me', authenticate, authController.me); router.get('/me', authenticate, authController.me);
router.patch('/change-password', authenticate, validate(changePasswordSchema), authController.changePassword); router.patch('/change-password', authenticate, validate(changePasswordSchema), authController.changePassword);
+151 -2
View File
@@ -13,6 +13,7 @@ const fs = require('fs/promises');
const path = require('path'); const path = require('path');
const SALT_ROUNDS = 12; const SALT_ROUNDS = 12;
const MAX_FAILED_LOGIN_ATTEMPTS = 3;
const buildDisplayNameFromEmail = (email) => { const buildDisplayNameFromEmail = (email) => {
const localPart = String(email).split('@')[0] || 'Usuario'; const localPart = String(email).split('@')[0] || 'Usuario';
@@ -42,6 +43,24 @@ const issueVerificationEmail = async (user) => {
}; };
}; };
const issueUnlockEmail = async (user) => {
const unlockToken = uuidv4();
const unlockTokenExpires = new Date(Date.now() + 60 * 60 * 1000);
await authRepository.updateUnlockToken(user.id, unlockToken, unlockTokenExpires);
const mailResult = await mailService.sendAccountLockedEmail({
name: user.name || buildDisplayNameFromEmail(user.email),
email: user.email,
unlockToken,
});
return {
unlockToken,
unlockUrl: mailResult.unlockUrl,
};
};
const register = async ({ email, password, certPassword, certificateFile, certificateAlias }, context = {}) => { const register = async ({ email, password, certPassword, certificateFile, certificateAlias }, context = {}) => {
const existing = await authRepository.findByEmail(email); const existing = await authRepository.findByEmail(email);
if (existing) { if (existing) {
@@ -122,7 +141,7 @@ const register = async ({ email, password, certPassword, certificateFile, certif
metadata: { metadata: {
verificationEmailSent: true, verificationEmailSent: true,
legalPackageCreated: true, legalPackageCreated: true,
legalVersion: '1.0.1', legalVersion: '1.1.0',
}, },
}); });
@@ -143,14 +162,61 @@ const login = async ({ email, password }, context = {}) => {
throw err; throw err;
} }
if (user.locked_at) {
let unlockUrl = null;
try {
({ unlockUrl } = await issueUnlockEmail(user));
} catch {
const err = new Error('Tu cuenta está bloqueada y no se pudo reenviar el correo de desbloqueo. Contacta soporte.');
err.statusCode = 503;
err.code = 'UNLOCK_MAIL_SEND_FAILED';
throw err;
}
const err = new Error('Tu cuenta está bloqueada temporalmente. Te enviamos un enlace para desbloquear el sistema.');
err.statusCode = 423;
err.code = 'ACCOUNT_LOCKED';
err.data = { unlockUrl };
throw err;
}
const passwordMatch = await bcrypt.compare(password, user.password_hash); const passwordMatch = await bcrypt.compare(password, user.password_hash);
if (!passwordMatch) { if (!passwordMatch) {
await authRepository.recordFailedLoginAttempt(user.id);
const failedAttempts = Number(user.failed_login_attempts || 0) + 1;
await auditService.logEvent({ type: 'LOGIN_FAILED', userId: user.id, email, ip: context.ip, userAgent: context.userAgent }); await auditService.logEvent({ type: 'LOGIN_FAILED', userId: user.id, email, ip: context.ip, userAgent: context.userAgent });
if (failedAttempts >= MAX_FAILED_LOGIN_ATTEMPTS) {
const unlockToken = uuidv4();
const unlockTokenExpires = new Date(Date.now() + 60 * 60 * 1000);
await authRepository.setAccountLocked(user.id, unlockToken, unlockTokenExpires);
try {
await mailService.sendAccountLockedEmail({
name: user.name || buildDisplayNameFromEmail(user.email),
email: user.email,
unlockToken,
});
} catch {
const err = new Error('Tu cuenta fue bloqueada por seguridad, pero no se pudo enviar el correo de desbloqueo. Contacta soporte.');
err.statusCode = 503;
err.code = 'UNLOCK_MAIL_SEND_FAILED';
throw err;
}
const err = new Error('Tu cuenta fue bloqueada por seguridad después de 3 intentos fallidos. Te enviamos un correo para desbloquearla.');
err.statusCode = 423;
err.code = 'ACCOUNT_LOCKED';
throw err;
}
const err = new Error('Invalid credentials.'); const err = new Error('Invalid credentials.');
err.statusCode = 401; err.statusCode = 401;
throw err; throw err;
} }
await authRepository.resetFailedLoginAttempts(user.id);
if (!user.is_verified) { if (!user.is_verified) {
let verificationUrl = null; let verificationUrl = null;
try { try {
@@ -280,6 +346,76 @@ const changePassword = async (userId, { currentPassword, newPassword }, context
return { confirmationToken }; return { confirmationToken };
}; };
const getUnlockAccountRequest = async (unlockToken) => {
const user = await authRepository.findByUnlockToken(unlockToken);
if (!user || !user.is_active) {
const err = new Error('El enlace de desbloqueo no es válido o expiró.');
err.statusCode = 400;
throw err;
}
const activeCertificate = await certificatesService.getLatestActiveForUser(user.id);
return {
email: user.email,
certificateAlias: activeCertificate?.alias || null,
certificateSubject: activeCertificate?.subject_cn || null,
};
};
const validatePasswordResetBySignature = async (unlockToken, { certPassword, newPassword }, context = {}) => {
const user = await authRepository.findByUnlockToken(unlockToken);
if (!user || !user.is_active) {
const err = new Error('El enlace de desbloqueo no es válido o expiró.');
err.statusCode = 400;
throw err;
}
const activeCertificate = await certificatesService.getLatestActiveForUser(user.id);
if (!activeCertificate) {
const err = new Error('No existe un certificado activo asociado a esta cuenta.');
err.statusCode = 404;
throw err;
}
await certificatesService.loadForSigning(activeCertificate.id, user.id, certPassword);
const isSamePassword = await bcrypt.compare(newPassword, user.password_hash);
if (isSamePassword) {
const err = new Error('La nueva contraseña debe ser diferente de la contraseña actual.');
err.statusCode = 400;
throw err;
}
const passwordHash = await bcrypt.hash(newPassword, SALT_ROUNDS);
const confirmationToken = uuidv4();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
await passwordChangeRepository.saveRequest(confirmationToken, {
userId: user.id,
email: user.email,
passwordHash,
expiresAt: expiresAt.toISOString(),
source: 'account_unlock',
});
await mailService.sendPasswordChangeConfirmationEmail({
name: user.name || buildDisplayNameFromEmail(user.email),
email: user.email,
confirmationToken,
});
await auditService.logEvent({
type: 'ACCOUNT_UNLOCK_PASSWORD_CHANGE_REQUESTED',
userId: user.id,
email: user.email,
ip: context.ip,
userAgent: context.userAgent,
});
return { confirmationToken };
};
const confirmPasswordChange = async (token) => { const confirmPasswordChange = async (token) => {
const request = await passwordChangeRepository.findRequest(token); const request = await passwordChangeRepository.findRequest(token);
if (!request) { if (!request) {
@@ -304,6 +440,9 @@ const confirmPasswordChange = async (token) => {
} }
await authRepository.updatePassword(request.userId, request.passwordHash); await authRepository.updatePassword(request.userId, request.passwordHash);
if (request.source === 'account_unlock') {
await authRepository.clearAccountLock(request.userId);
}
await passwordChangeRepository.deleteRequest(token); await passwordChangeRepository.deleteRequest(token);
await auditService.logEvent({ await auditService.logEvent({
@@ -333,4 +472,14 @@ const verifyEmail = async (token) => {
return userId; return userId;
}; };
module.exports = { register, login, refresh, logout, verifyEmail, changePassword, confirmPasswordChange }; module.exports = {
register,
login,
refresh,
logout,
verifyEmail,
changePassword,
confirmPasswordChange,
getUnlockAccountRequest,
validatePasswordResetBySignature,
};
+13 -1
View File
@@ -43,6 +43,11 @@ const changePasswordSchema = Joi.object({
newPassword: Joi.string().max(100).required().custom(isStrongPassword), newPassword: Joi.string().max(100).required().custom(isStrongPassword),
}); });
const unlockAccountSchema = Joi.object({
certPassword: Joi.string().required(),
newPassword: Joi.string().max(100).required().custom(isStrongPassword),
});
const validateRegisterMultipart = (req, res, next) => { const validateRegisterMultipart = (req, res, next) => {
const { email, password, certPassword, consentAccepted } = req.body; const { email, password, certPassword, consentAccepted } = req.body;
const issues = []; const issues = [];
@@ -88,4 +93,11 @@ const validate = (schema) => (req, res, next) => {
next(); next();
}; };
module.exports = { validate, validateRegisterMultipart, loginSchema, refreshSchema, changePasswordSchema }; module.exports = {
validate,
validateRegisterMultipart,
loginSchema,
refreshSchema,
changePasswordSchema,
unlockAccountSchema,
};
@@ -41,6 +41,14 @@ const findById = async (id) => {
return rows[0] || null; return rows[0] || null;
}; };
const findLatestActiveByUser = async (userId) => {
const [rows] = await db.execute(
'SELECT * FROM certificates WHERE user_id = ? AND is_active = 1 ORDER BY created_at DESC, id DESC LIMIT 1',
[userId]
);
return rows[0] || null;
};
const deactivate = async (id) => { const deactivate = async (id) => {
await db.execute('UPDATE certificates SET is_active = 0 WHERE id = ?', [id]); await db.execute('UPDATE certificates SET is_active = 0 WHERE id = ?', [id]);
}; };
@@ -70,6 +78,7 @@ module.exports = {
create, create,
findByUser, findByUser,
findById, findById,
findLatestActiveByUser,
deactivate, deactivate,
remove, remove,
saveChainStatus, saveChainStatus,
@@ -126,6 +126,8 @@ const getOne = async (id, userId) => {
}; };
}; };
const getLatestActiveForUser = async (userId) => certificatesRepository.findLatestActiveByUser(userId);
const remove = async (id, userId, context = {}) => { const remove = async (id, userId, context = {}) => {
const cert = await certificatesRepository.findById(id); const cert = await certificatesRepository.findById(id);
if (!cert) throw makeError('Certificate not found.', 404); if (!cert) throw makeError('Certificate not found.', 404);
@@ -165,4 +167,4 @@ const loadForSigning = async (certId, userId, password) => {
return { privateKey, certificate }; return { privateKey, certificate };
}; };
module.exports = { upload, list, getOne, remove, loadForSigning }; module.exports = { upload, list, getOne, remove, loadForSigning, getLatestActiveForUser };
+45
View File
@@ -125,8 +125,53 @@ const sendPasswordChangeConfirmationEmail = async ({ name, email, confirmationTo
}; };
}; };
const sendAccountLockedEmail = async ({ name, email, unlockToken }) => {
const unlockUrl = `${env.frontendUrl.replace(/\/$/, '')}/auth/unlock-account/${unlockToken}`;
const subject = 'Tu cuenta de LinkedSign fue bloqueada temporalmente';
const text = [
`Hola ${name},`,
'',
'Detectamos 3 intentos fallidos de acceso y tu cuenta fue bloqueada temporalmente por seguridad.',
'Para iniciar el desbloqueo, usa este enlace:',
unlockUrl,
'',
'El sistema te pedirá la contraseña de tu certificado de firma y luego solicitará una nueva contraseña de acceso.',
'Si no reconoces esta actividad, contacta soporte de inmediato.',
].join('\n');
const html = `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
<h2 style="margin-bottom: 12px;">Cuenta bloqueada temporalmente</h2>
<p>Hola ${name},</p>
<p>Detectamos 3 intentos fallidos de acceso y tu cuenta fue bloqueada temporalmente por seguridad.</p>
<p>Para iniciar el desbloqueo, usa este botón:</p>
<p style="margin: 24px 0;">
<a href="${unlockUrl}" style="background: #c62828; color: #ffffff; text-decoration: none; padding: 12px 18px; border-radius: 8px; display: inline-block; font-weight: 600;">
Desbloquear sistema
</a>
</p>
<p>Si el botón no funciona, copia y pega este enlace en tu navegador:</p>
<p><a href="${unlockUrl}">${unlockUrl}</a></p>
<p>El sistema te pedirá la contraseña de tu certificado de firma registrado y luego solicitará una nueva contraseña de acceso.</p>
<p>Si no reconoces esta actividad, contacta soporte de inmediato.</p>
</div>
`;
const info = await mailRepository.sendMail({
to: email,
subject,
html,
text,
});
return {
unlockUrl,
messageId: info.messageId || null,
};
};
module.exports = { module.exports = {
sendVerificationEmail, sendVerificationEmail,
sendPasswordChangedEmail, sendPasswordChangedEmail,
sendPasswordChangeConfirmationEmail, sendPasswordChangeConfirmationEmail,
sendAccountLockedEmail,
}; };
+11 -1
View File
@@ -1,6 +1,16 @@
const app = require('./app'); const app = require('./app');
const env = require('./config/env'); const env = require('./config/env');
const authRepository = require('./modules/auth/auth.repository');
app.listen(env.port, () => { const start = async () => {
await authRepository.ensureSchema();
app.listen(env.port, () => {
console.log(`LinkedSign backend running on port ${env.port}`); console.log(`LinkedSign backend running on port ${env.port}`);
});
};
start().catch((error) => {
console.error('Failed to start backend:', error.message);
process.exit(1);
}); });
+2
View File
@@ -7,6 +7,7 @@ import LoginPage from './pages/auth/LoginPage';
import RegisterPage from './pages/auth/RegisterPage'; import RegisterPage from './pages/auth/RegisterPage';
import VerifyEmailPage from './pages/auth/VerifyEmailPage'; import VerifyEmailPage from './pages/auth/VerifyEmailPage';
import ConfirmPasswordChangePage from './pages/auth/ConfirmPasswordChangePage'; import ConfirmPasswordChangePage from './pages/auth/ConfirmPasswordChangePage';
import UnlockAccountPage from './pages/auth/UnlockAccountPage';
import DashboardPage from './pages/dashboard/DashboardPage'; import DashboardPage from './pages/dashboard/DashboardPage';
import DocumentsPage from './pages/documents/DocumentsPage'; import DocumentsPage from './pages/documents/DocumentsPage';
import CertificatesPage from './pages/certificates/CertificatesPage'; import CertificatesPage from './pages/certificates/CertificatesPage';
@@ -25,6 +26,7 @@ export default function App() {
<Route path="/register" element={<RegisterPage />} /> <Route path="/register" element={<RegisterPage />} />
<Route path="/auth/verify-email/:token" element={<VerifyEmailPage />} /> <Route path="/auth/verify-email/:token" element={<VerifyEmailPage />} />
<Route path="/auth/confirm-password-change/:token" element={<ConfirmPasswordChangePage />} /> <Route path="/auth/confirm-password-change/:token" element={<ConfirmPasswordChangePage />} />
<Route path="/auth/unlock-account/:token" element={<UnlockAccountPage />} />
<Route path="/verify/:code" element={<VerifySignaturePage />} /> <Route path="/verify/:code" element={<VerifySignaturePage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route <Route
+2
View File
@@ -11,3 +11,5 @@ export const getMe = () => client.get('/auth/me');
export const verifyEmail = (token) => client.get(`/auth/verify/${token}`); export const verifyEmail = (token) => client.get(`/auth/verify/${token}`);
export const changePassword = (data) => client.patch('/auth/change-password', data); export const changePassword = (data) => client.patch('/auth/change-password', data);
export const confirmPasswordChange = (token) => client.get(`/auth/confirm-password-change/${token}`); export const confirmPasswordChange = (token) => client.get(`/auth/confirm-password-change/${token}`);
export const getUnlockAccountRequest = (token) => client.get(`/auth/unlock-account/${token}`);
export const requestUnlockPasswordChange = (token, data) => client.post(`/auth/unlock-account/${token}/request-password-change`, data);
+4
View File
@@ -26,8 +26,12 @@ export default function LoginPage() {
if (code === 'EMAIL_NOT_VERIFIED') { if (code === 'EMAIL_NOT_VERIFIED') {
toast.success('Tu cuenta aún no está validada. Te enviamos un nuevo correo de confirmación.'); 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') { } else if (code === 'MAIL_SEND_FAILED') {
toast.error(message); toast.error(message);
} else if (code === 'UNLOCK_MAIL_SEND_FAILED') {
toast.error(message);
} else { } else {
toast.error(message); 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>
);
}