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
+6 -1
View File
@@ -16,6 +16,7 @@ import attachmentsRoutes from './modules/attachments/routes.js';
import notificationsRoutes from './modules/notifications/routes.js';
import dashboardRoutes from './modules/dashboard/routes.js';
import auditRoutes from './modules/audit/routes.js';
import settingsRoutes from './modules/settings/routes.js';
export const app = express();
@@ -31,12 +32,16 @@ app.use('/api/departments', departmentsRoutes);
app.use('/api/catalogs', catalogsRoutes);
app.use('/api/projects', projectsRoutes);
app.use('/api/activities', activitiesRoutes);
app.use('/api', subtasksRoutes);
app.use('/api/comments', commentsRoutes);
app.use('/api/attachments', attachmentsRoutes);
app.use('/api/notifications', notificationsRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/audit', auditRoutes);
app.use('/api/settings', settingsRoutes);
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
app.use('/api', subtasksRoutes);
app.use(notFoundHandler);
app.use(errorHandler);
+5 -1
View File
@@ -1,6 +1,6 @@
import 'dotenv/config';
const required = ['DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'JWT_SECRET'];
const required = ['DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'JWT_SECRET', 'ENCRYPTION_KEY'];
for (const key of required) {
if (!process.env[key]) {
@@ -15,6 +15,10 @@ if (process.env.NODE_ENV === 'production' && (process.env.JWT_SECRET || '').leng
throw new Error('JWT_SECRET debe tener al menos 32 caracteres en producción');
}
if (process.env.NODE_ENV === 'production' && (process.env.ENCRYPTION_KEY || '').length !== 64) {
throw new Error('ENCRYPTION_KEY debe ser hex de 64 caracteres (32 bytes) en producción — generar con openssl rand -hex 32');
}
export const env = {
port: Number(process.env.PORT || 4019),
nodeEnv: process.env.NODE_ENV || 'development',
+5 -3
View File
@@ -46,6 +46,7 @@ export function requireRole(...roleCodes) {
}
export const ROLES = {
SUPERADMIN: 'superadmin',
ADMIN: 'admin',
IT_MANAGER: 'it_manager',
PROJECT_LEAD: 'project_lead',
@@ -53,6 +54,7 @@ export const ROLES = {
VIEWER: 'viewer',
};
export const CAN_MANAGE_CATALOGS = [ROLES.ADMIN];
export const CAN_MANAGE_USERS = [ROLES.ADMIN];
export const CAN_WRITE = [ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD, ROLES.COLLABORATOR];
export const CAN_MANAGE_CATALOGS = [ROLES.SUPERADMIN, ROLES.ADMIN];
export const CAN_MANAGE_USERS = [ROLES.SUPERADMIN, ROLES.ADMIN];
export const CAN_MANAGE_SMTP = [ROLES.SUPERADMIN];
export const CAN_WRITE = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD, ROLES.COLLABORATOR];
+1 -1
View File
@@ -15,7 +15,7 @@ async function getProjectOr404(projectId) {
// admin/it_manager siempre; project_lead si tiene acceso al proyecto; collaborator solo si es
// el responsable de la actividad (no puede crear actividades nuevas ni reasignarlas a otros).
export async function canWriteActivity(user, project, activity) {
if (user.roleCode === ROLES.ADMIN || user.roleCode === ROLES.IT_MANAGER) return true;
if ([ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER].includes(user.roleCode)) return true;
if (user.roleCode === ROLES.PROJECT_LEAD) return canWriteProject(user, project);
if (user.roleCode === ROLES.COLLABORATOR && activity) return activity.responsible_user_id === user.id;
return false;
@@ -40,7 +40,7 @@ export const downloadAttachment = asyncHandler(async (req, res) => {
export const deleteAttachment = asyncHandler(async (req, res) => {
const attachment = await repo.findById(Number(req.params.id));
if (!attachment) throw new HttpError(404, 'Adjunto no encontrado');
const canDelete = [ROLES.ADMIN, ROLES.IT_MANAGER].includes(req.currentUser.roleCode) || attachment.uploaded_by === req.currentUser.id;
const canDelete = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER].includes(req.currentUser.roleCode) || attachment.uploaded_by === req.currentUser.id;
if (!canDelete) throw new HttpError(403, 'No puedes eliminar este adjunto');
await repo.remove(attachment.id);
+1 -1
View File
@@ -4,7 +4,7 @@ import { getAudit } from './controller.js';
const router = Router();
router.use(requireAuth, requireRole(ROLES.ADMIN, ROLES.IT_MANAGER));
router.use(requireAuth, requireRole(ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER));
router.get('/', getAudit);
export default router;
+23
View File
@@ -16,3 +16,26 @@ export const postLogout = asyncHandler(async (req, res) => {
export const getMe = asyncHandler(async (req, res) => {
ok(res, req.currentUser);
});
export const postRegister = asyncHandler(async (req, res) => {
const result = await authService.register(req.body || {});
ok(res, result, 201);
});
export const getVerifyEmail = asyncHandler(async (req, res) => {
const result = await authService.verifyEmail(req.query.token);
ok(res, result);
});
export const postForgotPassword = asyncHandler(async (req, res) => {
const { email } = req.body || {};
if (email) await authService.forgotPassword(email);
// Respuesta siempre generica, exista o no el correo.
ok(res, { message: 'Si el correo existe, te enviamos instrucciones para recuperar tu clave.' });
});
export const postResetPassword = asyncHandler(async (req, res) => {
const { token, password } = req.body || {};
await authService.resetPassword(token, password);
ok(res, { reset: true });
});
+8 -1
View File
@@ -1,7 +1,9 @@
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { requireAuth } from '../../middleware/auth.js';
import { postLogin, postLogout, getMe } from './controller.js';
import {
postLogin, postLogout, getMe, postRegister, getVerifyEmail, postForgotPassword, postResetPassword,
} from './controller.js';
const router = Router();
@@ -12,7 +14,12 @@ const loginLimiter = rateLimit({
legacyHeaders: false,
});
// Mismo limiter para registro/recuperacion: son los otros puntos de entrada publicos sensibles.
router.post('/login', loginLimiter, postLogin);
router.post('/register', loginLimiter, postRegister);
router.get('/verify-email', loginLimiter, getVerifyEmail);
router.post('/forgot-password', loginLimiter, postForgotPassword);
router.post('/reset-password', loginLimiter, postResetPassword);
router.post('/logout', requireAuth, postLogout);
router.get('/me', requireAuth, getMe);
+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 });
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { ROLES } from '../../middleware/auth.js';
// Alcance de escritura: admin/it_manager siempre; project_lead solo si es dueño o miembro.
export async function canWriteProject(user, project) {
if (user.roleCode === ROLES.ADMIN || user.roleCode === ROLES.IT_MANAGER) return true;
if ([ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER].includes(user.roleCode)) return true;
if (user.roleCode !== ROLES.PROJECT_LEAD) return false;
if (project.owner_user_id === user.id) return true;
return repo.isMember(project.id, user.id);
@@ -24,7 +24,7 @@ export async function createProject(payload, currentUser) {
if (!payload.name || !payload.priorityId || !payload.statusId) {
throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios');
}
if (![ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD].includes(currentUser.roleCode)) {
if (![ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD].includes(currentUser.roleCode)) {
throw new HttpError(403, 'No tienes permisos para crear proyectos');
}
const project = await repo.create({
@@ -0,0 +1,8 @@
import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js';
export const getSmtp = asyncHandler(async (req, res) => ok(res, await service.getSmtpConfig()));
export const putSmtp = asyncHandler(async (req, res) => {
ok(res, await service.updateSmtpConfig(req.body, req.currentUser.id));
});
@@ -0,0 +1,18 @@
import { pool } from '../../config/db.js';
export async function getAll(keys) {
const placeholders = keys.map(() => '?').join(', ');
const [rows] = await pool.query(`SELECT \`key\`, \`value\` FROM system_settings WHERE \`key\` IN (${placeholders})`, keys);
const map = {};
for (const row of rows) map[row.key] = row.value;
return map;
}
export async function setMany(entries) {
for (const [key, value] of Object.entries(entries)) {
await pool.query(
'INSERT INTO system_settings (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)',
[key, value]
);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Router } from 'express';
import { requireAuth, requireRole, CAN_MANAGE_SMTP } from '../../middleware/auth.js';
import { getSmtp, putSmtp } from './controller.js';
const router = Router();
router.use(requireAuth, requireRole(...CAN_MANAGE_SMTP));
router.get('/smtp', getSmtp);
router.put('/smtp', putSmtp);
export default router;
+44
View File
@@ -0,0 +1,44 @@
import * as repo from './repository.js';
import { encrypt, decrypt } from '../../utils/crypto.js';
import { logAudit } from '../../utils/audit.js';
import { HttpError } from '../../utils/http.js';
const SMTP_KEYS = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_from', 'smtp_pass_enc'];
export async function getSmtpConfig() {
const values = await repo.getAll(SMTP_KEYS);
return {
host: values.smtp_host || '',
port: values.smtp_port ? Number(values.smtp_port) : 587,
user: values.smtp_user || '',
from: values.smtp_from || '',
configured: !!(values.smtp_host && values.smtp_user && values.smtp_pass_enc),
};
}
// Version interna (con la clave descifrada) — solo la usa el mailer, nunca se expone via API.
export async function getSmtpConfigInternal() {
const values = await repo.getAll(SMTP_KEYS);
if (!values.smtp_host || !values.smtp_user || !values.smtp_pass_enc) return null;
return {
host: values.smtp_host,
port: values.smtp_port ? Number(values.smtp_port) : 587,
user: values.smtp_user,
from: values.smtp_from || values.smtp_user,
pass: decrypt(values.smtp_pass_enc),
};
}
export async function updateSmtpConfig(payload, actorId) {
if (!payload.host || !payload.user) throw new HttpError(400, 'Host y usuario son obligatorios');
const entries = {
smtp_host: payload.host,
smtp_port: String(payload.port || 587),
smtp_user: payload.user,
smtp_from: payload.from || payload.user,
};
if (payload.pass) entries.smtp_pass_enc = encrypt(payload.pass);
await repo.setMany(entries);
await logAudit({ userId: actorId, action: 'update', entityType: 'settings:smtp', entityId: null });
return getSmtpConfig();
}
+10
View File
@@ -18,3 +18,13 @@ export const patchUserActive = asyncHandler(async (req, res) => {
const user = await service.setUserActive(Number(req.params.id), !!req.body.isActive, req.currentUser.id);
ok(res, user);
});
export const postApproveUser = asyncHandler(async (req, res) => {
const user = await service.approveUser(Number(req.params.id), req.body, req.currentUser.id);
ok(res, user);
});
export const postRejectUser = asyncHandler(async (req, res) => {
await service.rejectUser(Number(req.params.id), req.currentUser.id);
ok(res, { rejected: true });
});
+15 -2
View File
@@ -3,7 +3,7 @@ import { pool } from '../../config/db.js';
export async function findAll() {
const [rows] = await pool.query(
`SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, r.name AS role_name,
u.department_id, d.name AS department_name, u.position, u.is_active, u.created_at
u.department_id, d.name AS department_name, u.position, u.is_active, u.status, u.created_at
FROM users u
JOIN roles r ON r.id = u.role_id
LEFT JOIN departments d ON d.id = u.department_id
@@ -14,7 +14,7 @@ export async function findAll() {
export async function findById(id) {
const [rows] = await pool.query(
`SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, u.department_id, u.position, u.is_active
`SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, u.department_id, u.position, u.is_active, u.status
FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`,
[id]
);
@@ -48,6 +48,19 @@ export async function setActive(id, isActive) {
return findById(id);
}
export async function approve(id, { roleId, departmentId }) {
await pool.query(
`UPDATE users SET status = 'active', is_active = 1, role_id = COALESCE(?, role_id), department_id = COALESCE(?, department_id)
WHERE id = ?`,
[roleId || null, departmentId || null, id]
);
return findById(id);
}
export async function remove(id) {
await pool.query('DELETE FROM users WHERE id = ?', [id]);
}
export async function listRoles() {
const [rows] = await pool.query('SELECT id, code, name FROM roles ORDER BY id');
return rows;
+3 -1
View File
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { requireAuth, requireRole, CAN_MANAGE_USERS } from '../../middleware/auth.js';
import { getUsers, getRoles, postUser, putUser, patchUserActive } from './controller.js';
import { getUsers, getRoles, postUser, putUser, patchUserActive, postApproveUser, postRejectUser } from './controller.js';
const router = Router();
@@ -10,5 +10,7 @@ router.get('/roles', getRoles);
router.post('/', requireRole(...CAN_MANAGE_USERS), postUser);
router.put('/:id', requireRole(...CAN_MANAGE_USERS), putUser);
router.patch('/:id/active', requireRole(...CAN_MANAGE_USERS), patchUserActive);
router.post('/:id/approve', requireRole(...CAN_MANAGE_USERS), postApproveUser);
router.post('/:id/reject', requireRole(...CAN_MANAGE_USERS), postRejectUser);
export default router;
+17
View File
@@ -39,3 +39,20 @@ export async function setUserActive(id, isActive, actorId) {
});
return user;
}
export async function approveUser(id, payload, actorId) {
const existing = await repo.findById(id);
if (!existing) throw new HttpError(404, 'Usuario no encontrado');
if (existing.status !== 'pending_approval') throw new HttpError(400, 'El usuario no esta pendiente de aprobacion');
const user = await repo.approve(id, payload);
await logAudit({ userId: actorId, action: 'approve', entityType: 'user', entityId: id });
return user;
}
export async function rejectUser(id, actorId) {
const existing = await repo.findById(id);
if (!existing) throw new HttpError(404, 'Usuario no encontrado');
if (existing.status !== 'pending_approval') throw new HttpError(400, 'El usuario no esta pendiente de aprobacion');
await logAudit({ userId: actorId, action: 'reject', entityType: 'user', entityId: id, detail: { email: existing.email } });
await repo.remove(id);
}
+1
View File
@@ -13,6 +13,7 @@ async function run() {
// Catalogos base — necesarios para que la app funcione en CUALQUIER entorno (dev y produccion).
const roles = [
['superadmin', 'Super Administrador'],
['admin', 'Administrador'],
['it_manager', 'Jefe de TI'],
['project_lead', 'Coordinador / Lider de proyecto'],
+31
View File
@@ -0,0 +1,31 @@
import crypto from 'node:crypto';
// Cifrado simetrico para secretos de terceros guardados en BD (ej. clave SMTP) — nunca en texto
// plano. AES-256-GCM con ENCRYPTION_KEY (32 bytes hex) del entorno.
const KEY = Buffer.from(process.env.ENCRYPTION_KEY || '', 'hex');
function requireKey() {
if (KEY.length !== 32) {
throw new Error('ENCRYPTION_KEY invalido o ausente (debe ser hex de 32 bytes / 64 caracteres)');
}
}
export function encrypt(text) {
requireKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return Buffer.concat([iv, authTag, encrypted]).toString('base64');
}
export function decrypt(payload) {
requireKey();
const raw = Buffer.from(payload, 'base64');
const iv = raw.subarray(0, 12);
const authTag = raw.subarray(12, 28);
const encrypted = raw.subarray(28);
const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
}
+31
View File
@@ -0,0 +1,31 @@
import nodemailer from 'nodemailer';
import { getSmtpConfigInternal } from '../modules/settings/service.js';
// Degradacion a modo dev cuando el SMTP no esta configurado (regla del orquestador): en vez de
// fallar, se loguea el contenido/enlace y el flujo que llamo a esto sigue funcionando.
export async function sendMail({ to, subject, html }) {
const config = await getSmtpConfigInternal();
if (!config) {
console.log(`[mail:dev] to=${to} subject="${subject}"\n${html}`);
return { sent: false, dev: true };
}
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: false,
requireTLS: true,
auth: { user: config.user, pass: config.pass },
});
// Nunca dejar que un fallo de envio (SMTP mal configurado, host caido) tumbe al que llama —
// critico para forgot-password, que siempre debe responder generico sin importar el resultado.
try {
await transporter.sendMail({ from: config.from, to, subject, html });
return { sent: true, dev: false };
} catch (err) {
console.error('[mail:error]', err.message);
return { sent: false, dev: false, error: err.message };
}
}