import crypto from 'node:crypto'; import bcrypt from 'bcryptjs'; import { pool } from '../../config/db.js'; import { env } from '../../config/env.js'; import { HttpError } from '../../utils/http.js'; import { logAudit } from '../../utils/audit.js'; import { loadModules } from '../../middleware/modules.js'; import { sendMail } from '../../utils/mailer.js'; import { createNotification } from '../notifications/service.js'; import { verificationEmail, recoveryEmail } from '../../utils/emailHtml.js'; import { issueSession, revokeAll } from './sessions-service.js'; const STATUS_MESSAGES = { pending_verification: 'Debes confirmar tu correo antes de iniciar sesion. Revisa tu bandeja de entrada.', pending_approval: 'Tu cuenta esta pendiente de aprobacion por un administrador.', invited: 'Tienes una invitacion pendiente. Revisa tu correo para completar tu registro.', }; function newToken() { return crypto.randomBytes(32).toString('hex'); } export async function login(email, password, req) { const [rows] = await pool.query( `SELECT u.id, u.name, u.email, u.password_hash, u.is_active, u.status, u.department_id, u.session_version, r.code AS role_code, r.name AS role_name, d.name AS department_name FROM users u JOIN roles r ON r.id = u.role_id LEFT JOIN departments d ON d.id = u.department_id WHERE u.email = ? LIMIT 1`, [email] ); const user = rows[0]; if (!user) throw new HttpError(401, 'Credenciales invalidas'); const validPassword = await bcrypt.compare(password, user.password_hash); if (!validPassword) throw new HttpError(401, 'Credenciales invalidas'); if (STATUS_MESSAGES[user.status]) throw new HttpError(403, STATUS_MESSAGES[user.status]); if (!user.is_active) throw new HttpError(403, 'Cuenta inactiva. Contacta a un administrador.'); const token = await issueSession(user.id, user.session_version, req); await logAudit({ userId: user.id, action: 'login', entityType: 'auth', entityId: user.id }); return { token, user: { id: user.id, name: user.name, email: user.email, roleCode: user.role_code, roleName: user.role_name, departmentId: user.department_id, departmentName: user.department_name, modules: await loadModules(user.id, user.role_code), }, }; } export async function register({ name, email, password }, req) { if (!name || !email || !password || password.length < 8) { throw new HttpError(400, 'Nombre, correo y una clave de al menos 8 caracteres son obligatorios'); } const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]); if (existing.length) throw new HttpError(400, 'Ya existe una cuenta con ese correo'); const passwordHash = await bcrypt.hash(password, 12); const connection = await pool.getConnection(); let bootstrap; let result; let verificationToken; try { await connection.beginTransaction(); // El bloqueo de la fila de roles serializa incluso los registros con correos distintos. const [roles] = await connection.query("SELECT id, code FROM roles WHERE code IN ('superadmin', 'collaborator') FOR UPDATE"); const [[{ total }]] = await connection.query('SELECT COUNT(*) AS total FROM users'); bootstrap = total === 0; const roleId = roles.find((role) => role.code === (bootstrap ? 'superadmin' : 'collaborator'))?.id; if (!roleId) throw new Error('Rol de registro no configurado'); verificationToken = bootstrap ? null : newToken(); [result] = await connection.query( `INSERT INTO users (name, email, password_hash, role_id, is_active, status, email_verified_at, verification_token, verification_token_expires) VALUES (?, ?, ?, ?, ?, ?, ${bootstrap ? 'NOW()' : 'NULL'}, ?, ${bootstrap ? 'NULL' : 'DATE_ADD(NOW(), INTERVAL 24 HOUR)'})`, [name, email, passwordHash, roleId, bootstrap ? 1 : 0, bootstrap ? 'active' : 'pending_verification', verificationToken] ); await connection.commit(); } catch (err) { await connection.rollback(); if (err.code === 'ER_DUP_ENTRY') throw new HttpError(400, 'Ya existe una cuenta con ese correo'); throw err; } finally { connection.release(); } if (bootstrap) { await logAudit({ userId: result.insertId, action: 'register_bootstrap_superadmin', entityType: 'user', entityId: result.insertId }); const token = await issueSession(result.insertId, 1, req); return { bootstrap: true, token, user: { id: result.insertId, name, email, roleCode: 'superadmin', roleName: 'Super Administrador', departmentId: null, modules: await loadModules(result.insertId, 'superadmin') } }; } await logAudit({ userId: result.insertId, action: 'register', entityType: 'user', entityId: result.insertId }); const verifyUrl = `${env.corsOrigin}/verificar-correo?token=${verificationToken}`; await sendMail({ to: email, subject: 'Confirma tu correo — project.linkedcorp.net', html: verificationEmail(name, verifyUrl), }); return { bootstrap: false }; } export async function verifyEmail(token) { if (!token) throw new HttpError(400, 'Token invalido'); const [rows] = await pool.query( `SELECT id, name, email FROM users WHERE verification_token = ? AND status = 'pending_verification' AND verification_token_expires > NOW()`, [token] ); const user = rows[0]; if (!user) throw new HttpError(400, 'El enlace de verificacion es invalido o expiro'); await pool.query( `UPDATE users SET status = 'pending_approval', email_verified_at = NOW(), verification_token = NULL, verification_token_expires = NULL WHERE id = ?`, [user.id] ); await logAudit({ userId: user.id, action: 'verify_email', entityType: 'user', entityId: user.id }); const [admins] = await pool.query( `SELECT u.id FROM users u JOIN roles r ON r.id = u.role_id WHERE r.code IN ('admin','superadmin') AND u.is_active = 1` ); for (const admin of admins) { await createNotification({ userId: admin.id, type: 'registration_pending', message: `Nueva cuenta pendiente de aprobacion: ${user.name} (${user.email})`, }); } return { email: user.email }; } // Info publica minima para prellenar el formulario de "aceptar invitacion" (solo el correo). export async function getInviteInfo(token) { if (!token) throw new HttpError(400, 'Token invalido'); const [rows] = await pool.query( `SELECT email FROM users WHERE verification_token = ? AND status = 'invited' AND verification_token_expires > NOW()`, [token] ); const user = rows[0]; if (!user) throw new HttpError(400, 'La invitacion es invalida o expiro'); return { email: user.email }; } // Completa la cuenta creada por una invitacion a un proyecto (ver modules/users/service.js // inviteUser): la persona elige su nombre y clave. A diferencia del autorregistro publico, no // pasa por aprobacion de un admin — quien la invito ya la vetó al agregarla a su proyecto. Devuelve // sesion iniciada de una vez, igual que el bootstrap del primer usuario. export async function acceptInvite(token, name, password, req) { if (!token || !name || !password || password.length < 8) { throw new HttpError(400, 'Nombre, token y una clave de al menos 8 caracteres son obligatorios'); } const [rows] = await pool.query( `SELECT id FROM users WHERE verification_token = ? AND status = 'invited' AND verification_token_expires > NOW()`, [token] ); const user = rows[0]; if (!user) throw new HttpError(400, 'La invitacion es invalida o expiro'); const passwordHash = await bcrypt.hash(password, 12); await pool.query( `UPDATE users SET name = ?, password_hash = ?, is_active = 1, status = 'active', edit_version = edit_version + 1, email_verified_at = NOW(), verification_token = NULL, verification_token_expires = NULL WHERE id = ?`, [name, passwordHash, user.id] ); await logAudit({ userId: user.id, action: 'accept_invite', entityType: 'user', entityId: user.id }); const [full] = await pool.query( `SELECT u.id, u.name, u.email, u.department_id, u.session_version, r.code AS role_code, r.name AS role_name, d.name AS department_name FROM users u JOIN roles r ON r.id = u.role_id LEFT JOIN departments d ON d.id = u.department_id WHERE u.id = ?`, [user.id] ); const jwtToken = await issueSession(user.id, full[0].session_version, req); return { token: jwtToken, user: { id: full[0].id, name: full[0].name, email: full[0].email, roleCode: full[0].role_code, roleName: full[0].role_name, departmentId: full[0].department_id, departmentName: full[0].department_name, modules: await loadModules(full[0].id, full[0].role_code), }, }; } export async function forgotPassword(email) { const [rows] = await pool.query( `SELECT id, name FROM users WHERE email = ? AND status = 'active' AND is_active = 1`, [email] ); const user = rows[0]; // Respuesta siempre generica — no revela si el correo existe (regla de SEGURIDAD-Y-CALIDAD.md). if (!user) return; const resetToken = newToken(); await pool.query( `UPDATE users SET reset_token = ?, reset_token_expires = DATE_ADD(NOW(), INTERVAL 1 HOUR) WHERE id = ?`, [resetToken, user.id] ); const resetUrl = `${env.corsOrigin}/restablecer-clave?token=${resetToken}`; await sendMail({ to: email, subject: 'Recuperar contraseña — project.linkedcorp.net', html: recoveryEmail(user.name, resetUrl), }); } export async function resetPassword(token, newPassword) { if (!token || !newPassword || newPassword.length < 8) { throw new HttpError(400, 'Token y una clave nueva de al menos 8 caracteres son obligatorios'); } const [rows] = await pool.query( `SELECT id FROM users WHERE reset_token = ? AND reset_token_expires > NOW()`, [token] ); const user = rows[0]; if (!user) throw new HttpError(400, 'El enlace de recuperacion es invalido o expiro'); const passwordHash = await bcrypt.hash(newPassword, 12); await pool.query( `UPDATE users SET password_hash = ?, session_version = session_version + 1, reset_token = NULL, reset_token_expires = NULL WHERE id = ?`, [passwordHash, user.id] ); await revokeAll(user.id, user.id, 'password_reset'); await logAudit({ userId: user.id, action: 'reset_password', entityType: 'user', entityId: user.id }); } // Cambio de clave desde la app, con sesion activa (distinto del olvide-clave: exige la clave actual). export async function changePassword(userId, currentPassword, newPassword, currentSessionId) { if (!currentPassword || !newPassword || newPassword.length < 8) { throw new HttpError(400, 'La clave actual y una clave nueva de al menos 8 caracteres son obligatorias'); } const [rows] = await pool.query('SELECT password_hash FROM users WHERE id = ?', [userId]); const user = rows[0]; if (!user) throw new HttpError(404, 'Usuario no encontrado'); const validPassword = await bcrypt.compare(currentPassword, user.password_hash); if (!validPassword) throw new HttpError(400, 'La clave actual no es correcta'); const passwordHash = await bcrypt.hash(newPassword, 12); await pool.query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, userId]); await revokeAll(userId, userId, 'password_change', currentSessionId); await logAudit({ userId, action: 'change_password', entityType: 'user', entityId: userId }); }