feat: autorregistro con verificacion de correo + aprobacion, superadmin y SMTP

- POST /api/auth/register: primer usuario del sistema -> superadmin con sesion
  directa (bootstrap, sin correo ni aprobacion); los siguientes quedan
  pending_verification y reciben un correo con enlace de un solo uso (24h).
- GET /api/auth/verify-email: pasa a pending_approval y notifica in-app a los
  admin/superadmin activos.
- admin/superadmin aprueban (POST /api/users/:id/approve, eligen rol y
  departamento) o rechazan (POST /api/users/:id/reject, borra y audita el
  email) desde Organizacion -> Usuarios (nuevo badge de estado + acciones).
- login() da mensaje especifico segun el status de la cuenta en vez del
  generico "credenciales invalidas".
- Recuperacion de clave (forgot/reset-password): respuesta siempre generica,
  token de 1 hora de un solo uso.
- Nuevo modulo settings: SMTP configurable SOLO por superadmin desde
  Configuracion -> Correo (SMTP); clave cifrada AES-256-GCM (ENCRYPTION_KEY)
  en la tabla system_settings, nunca expuesta por la API. sendMail() degrada
  a modo dev (loguea en vez de enviar) si no hay SMTP configurado, y nunca
  deja que un fallo de envio tumbe al que lo llama.
- Migracion 002_registration_and_smtp.sql: rol superadmin, columnas de ciclo
  de vida en users (status, tokens), tabla system_settings, y promueve al
  usuario mas antiguo del sistema a superadmin.
- Fix: React.StrictMode disparaba el efecto de VerifyEmailPage dos veces; la
  segunda llamada (token ya consumido) mostraba "enlace invalido" pese a que
  la verificacion ya habia funcionado. Se agrega una guarda por token.
- Fix (deuda tecnica v1.0.0): se reordena el montaje de rutas en app.js —
  subtasksRoutes (mount generico en /api) va al final, para no interceptar
  con su requireAuth interno una request sin token dirigida a un router mas
  especifico registrado despues.
- Documentacion actualizada: AGENTS.md, SPEC.md, API.md, BASE_DE_DATOS.md,
  CONFIGURACION.md, ESTADO.md, CHANGELOG.md. Version 1.1.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-05 23:28:46 -05:00
parent 556bbc7314
commit 7557dffc52
49 changed files with 1063 additions and 84 deletions
+138 -2
View File
@@ -1,23 +1,39 @@
import crypto from 'node:crypto';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
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 { sendMail } from '../../utils/mailer.js';
import { createNotification } from '../notifications/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.',
};
function newToken() {
return crypto.randomBytes(32).toString('hex');
}
export async function login(email, password) {
const [rows] = await pool.query(
`SELECT u.id, u.name, u.email, u.password_hash, u.is_active, u.department_id, r.code AS role_code, r.name AS role_name
`SELECT u.id, u.name, u.email, u.password_hash, u.is_active, u.status, u.department_id,
r.code AS role_code, r.name AS role_name
FROM users u JOIN roles r ON r.id = u.role_id
WHERE u.email = ? LIMIT 1`,
[email]
);
const user = rows[0];
if (!user || !user.is_active) throw new HttpError(401, 'Credenciales invalidas');
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 = jwt.sign({ sub: user.id }, env.jwtSecret, { expiresIn: env.jwtExpires });
await logAudit({ userId: user.id, action: 'login', entityType: 'auth', entityId: user.id });
@@ -34,3 +50,123 @@ export async function login(email, password) {
},
};
}
export async function register({ name, email, password }) {
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 [[{ total }]] = await pool.query('SELECT COUNT(*) AS total FROM users');
const passwordHash = await bcrypt.hash(password, 12);
if (total === 0) {
// Primer usuario del sistema: superadmin, sin correo ni aprobacion (no hay quien lo apruebe).
const [superadminRole] = await pool.query("SELECT id FROM roles WHERE code = 'superadmin'");
const [result] = await pool.query(
`INSERT INTO users (name, email, password_hash, role_id, is_active, status, email_verified_at)
VALUES (?, ?, ?, ?, 1, 'active', NOW())`,
[name, email, passwordHash, superadminRole[0].id]
);
await logAudit({ userId: result.insertId, action: 'register_bootstrap_superadmin', entityType: 'user', entityId: result.insertId });
const token = jwt.sign({ sub: result.insertId }, env.jwtSecret, { expiresIn: env.jwtExpires });
return {
bootstrap: true,
token,
user: { id: result.insertId, name, email, roleCode: 'superadmin', roleName: 'Super Administrador', departmentId: null },
};
}
const [collaboratorRole] = await pool.query("SELECT id FROM roles WHERE code = 'collaborator'");
const verificationToken = newToken();
const [result] = await pool.query(
`INSERT INTO users (name, email, password_hash, role_id, is_active, status, verification_token, verification_token_expires)
VALUES (?, ?, ?, ?, 0, 'pending_verification', ?, DATE_ADD(NOW(), INTERVAL 24 HOUR))`,
[name, email, passwordHash, collaboratorRole[0].id, verificationToken]
);
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: `<p>Hola ${name},</p><p>Confirma tu correo para continuar tu registro:</p><p><a href="${verifyUrl}">${verifyUrl}</a></p><p>Este enlace vence en 24 horas.</p>`,
});
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 };
}
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: `<p>Hola ${user.name},</p><p>Para elegir una nueva clave entra a este enlace (vence en 1 hora):</p><p><a href="${resetUrl}">${resetUrl}</a></p><p>Si no pediste esto, ignora el correo.</p>`,
});
}
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 = ?, reset_token = NULL, reset_token_expires = NULL WHERE id = ?`,
[passwordHash, user.id]
);
await logAudit({ userId: user.id, action: 'reset_password', entityType: 'user', entityId: user.id });
}