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:
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user