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
+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);
}