feat: version 3.1.0 -- Acceso por modulo y por usuario

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-23 14:14:01 -05:00
parent 959c365d5c
commit 4056c70d90
34 changed files with 253 additions and 34 deletions
@@ -0,0 +1,12 @@
-- Acceso por modulo y por usuario (fase 2). Sin fila = modulo habilitado (comportamiento previo);
-- el superadmin guarda aqui las excepciones. Lo gestiona solo superadmin. Idempotente.
CREATE TABLE IF NOT EXISTS user_modules (
user_id INT NOT NULL,
module VARCHAR(40) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
updated_by INT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, module),
CONSTRAINT fk_user_modules_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_modules_updater FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "3.0.1",
"version": "3.1.0",
"private": true,
"type": "module",
"main": "src/server.js",
+2
View File
@@ -2,6 +2,7 @@ import jwt from 'jsonwebtoken';
import { env } from '../config/env.js';
import { pool } from '../config/db.js';
import { fail } from '../utils/http.js';
import { loadModules } from './modules.js';
export async function requireAuth(req, res, next) {
const header = req.headers.authorization || '';
@@ -31,6 +32,7 @@ export async function requireAuth(req, res, next) {
email: user.email,
roleCode: user.role_code,
departmentId: user.department_id,
modules: await loadModules(user.id, user.role_code),
};
next();
}
+27
View File
@@ -0,0 +1,27 @@
import { pool } from '../config/db.js';
import { fail } from '../utils/http.js';
// Modulos que el superadmin puede habilitar/deshabilitar por usuario. Panel y Configuracion no
// estan aqui: el Panel lo ve todo usuario y Configuracion sigue siendo solo de superadmin.
export const MODULES = ['projects', 'recurring_tasks', 'notes', 'vault', 'devices', 'organization'];
// Sin fila en user_modules = habilitado. El superadmin siempre tiene todo.
export async function loadModules(userId, roleCode) {
if (roleCode === 'superadmin') return [...MODULES];
const [rows] = await pool.query('SELECT module FROM user_modules WHERE user_id = ? AND enabled = 0', [userId]);
const disabled = new Set(rows.map((r) => r.module));
return MODULES.filter((m) => !disabled.has(m));
}
export function hasModule(user, module) {
return !!user?.modules?.includes(module);
}
// Va despues de requireAuth (que carga currentUser.modules).
export function requireModule(module) {
return (req, res, next) => {
if (!req.currentUser) return fail(res, 401, 'No autenticado');
if (!hasModule(req.currentUser, module)) return fail(res, 403, 'No tienes acceso a este modulo');
next();
};
}
+2
View File
@@ -1,10 +1,12 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js';
import { getActivity, putActivity, deleteActivity, postActivityRestore, putMoveActivity, postConvertToProject, postDependency, deleteDependency } from './controller.js';
const router = Router();
router.use(requireAuth);
router.use(requireModule('projects'));
router.get('/:id', getActivity);
router.put('/:id', putActivity);
router.delete('/:id', deleteActivity);
+4 -1
View File
@@ -5,6 +5,7 @@ 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';
@@ -49,6 +50,7 @@ export async function login(email, password) {
roleCode: user.role_code,
roleName: user.role_name,
departmentId: user.department_id,
modules: await loadModules(user.id, user.role_code),
},
};
}
@@ -88,7 +90,7 @@ export async function register({ name, email, password }) {
if (bootstrap) {
await logAudit({ userId: result.insertId, action: 'register_bootstrap_superadmin', entityType: 'user', entityId: result.insertId });
const token = jwt.sign({ sub: result.insertId, sv: 1 }, env.jwtSecret, { expiresIn: env.jwtExpires });
return { bootstrap: true, token, user: { id: result.insertId, name, email, roleCode: 'superadmin', roleName: 'Super Administrador', departmentId: null } };
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 });
@@ -182,6 +184,7 @@ export async function acceptInvite(token, name, password) {
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,
modules: await loadModules(full[0].id, full[0].role_code),
},
};
}
+4 -3
View File
@@ -1,13 +1,14 @@
import { Router } from 'express';
import { requireAuth, requireRole, CAN_MANAGE_USERS } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js';
import { getDepartments, postDepartment, putDepartment, deleteDepartment } from './controller.js';
const router = Router();
router.use(requireAuth);
router.get('/', getDepartments);
router.post('/', requireRole(...CAN_MANAGE_USERS), postDepartment);
router.put('/:id', requireRole(...CAN_MANAGE_USERS), putDepartment);
router.delete('/:id', requireRole(...CAN_MANAGE_USERS), deleteDepartment);
router.post('/', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), postDepartment);
router.put('/:id', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), putDepartment);
router.delete('/:id', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), deleteDepartment);
export default router;
+2
View File
@@ -1,5 +1,6 @@
import { Router } from 'express';
import { requireAuth, requireRole, CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js';
import { fail } from '../../utils/http.js';
import { getAccess as getAccessFor } from './service.js';
import {
@@ -21,6 +22,7 @@ async function canView(req, res, next) {
}
router.use(requireAuth);
router.use(requireModule('devices'));
router.get('/access', getAccess); // cualquier autenticado: el menu lo usa para decidir si se muestra
router.get('/dashboard', canView, getDashboard);
router.get('/software', canView, getSoftware);
+2
View File
@@ -1,5 +1,6 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js';
import {
getNotes, postNote, putNote, deleteNote, putReorderNotes, getNoteShares, postNoteShare, deleteNoteShare,
} from './controller.js';
@@ -9,6 +10,7 @@ const router = Router();
// Sin restriccion de rol: cualquier usuario autenticado tiene sus propias notas (y ve las que
// otros compartieron con el). Los permisos de compartir se resuelven en el service (dueño-only).
router.use(requireAuth);
router.use(requireModule('notes'));
router.get('/', getNotes);
router.post('/', postNote);
// /reorder ANTES de /:id -- si no, Express lo confunde con un id (":id" matcheria "reorder").
+2
View File
@@ -1,5 +1,6 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js';
import {
getProjects, getProjectOptions, getProject, postProject, putProject, deleteProject, postProjectRestore,
deleteProjectPermanent, postMember, putMemberRole, deleteMember,
@@ -10,6 +11,7 @@ import { getActivitiesByProject, getActivitiesTrash, postActivity, putReorderAct
const router = Router();
router.use(requireAuth);
router.use(requireModule('projects'));
router.get('/', getProjects);
router.get('/options', getProjectOptions);
router.post('/', postProject);
@@ -1,5 +1,6 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js';
import {
getRecurringTasks, postRecurringTask, putRecurringTask, deleteRecurringTask,
postCompleteCycle, postPromoteFromTask,
@@ -8,6 +9,7 @@ import {
const router = Router();
router.use(requireAuth);
router.use(requireModule('recurring_tasks'));
router.get('/', getRecurringTasks);
router.post('/', postRecurringTask);
router.post('/from-task/:taskId', postPromoteFromTask);
+3 -1
View File
@@ -1,6 +1,7 @@
import * as repo from './repository.js';
import { getProject } from '../projects/service.js';
import { HttpError } from '../../utils/http.js';
import { hasModule } from '../../middleware/modules.js';
const MIN_LENGTH = 2;
@@ -10,7 +11,8 @@ export async function search(term, projectId, currentUser) {
if (!Number.isSafeInteger(Number(projectId)) || Number(projectId) < 1) throw new HttpError(400, 'projectId invalido');
await getProject(Number(projectId), currentUser);
}
if (q.length < MIN_LENGTH) {
// Todo lo que busca es del modulo Proyectos: sin acceso, resultado vacio (no 403 en la barra global).
if (q.length < MIN_LENGTH || !hasModule(currentUser, 'projects')) {
return projectId ? { activities: [], tasks: [] } : { projects: [], activities: [], tasks: [] };
}
return projectId ? repo.searchInProject(Number(projectId), q) : repo.searchGlobal(q, currentUser);
+2
View File
@@ -1,10 +1,12 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js';
import { getTasks, getTasksTrash, postTask, putTask, putReorderTasks, patchTaskDone, deleteTask, postTaskRestore } from './controller.js';
const router = Router();
router.use(requireAuth);
router.use(['/tasks', '/activities'], requireModule('projects'));
router.get('/activities/:activityId/tasks', getTasks);
router.get('/activities/:activityId/tasks/trash', getTasksTrash);
router.post('/activities/:activityId/tasks', postTask);
+6
View File
@@ -32,3 +32,9 @@ export const postRejectUser = asyncHandler(async (req, res) => {
export const postResendUserEmail = asyncHandler(async (req, res) => {
ok(res, await service.resendUserEmail(Number(req.params.id), req.currentUser.id));
});
export const getUserModules = asyncHandler(async (req, res) => ok(res, await service.getUserModules(Number(req.params.id))));
export const putUserModules = asyncHandler(async (req, res) => {
ok(res, await service.setUserModules(Number(req.params.id), req.body?.modules, req.currentUser));
});
+10
View File
@@ -90,3 +90,13 @@ export async function listRoles() {
const [rows] = await pool.query('SELECT id, code, name FROM roles ORDER BY id');
return rows;
}
// pairs: [[module, enabled], ...]. Se guardan todas las filas para que quede explicito.
export async function saveModules(userId, pairs, actorId) {
const values = pairs.map(([module, enabled]) => [userId, module, enabled ? 1 : 0, actorId]);
await pool.query(
`INSERT INTO user_modules (user_id, module, enabled, updated_by) VALUES ?
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_by = VALUES(updated_by)`,
[values]
);
}
+11 -7
View File
@@ -1,7 +1,9 @@
import { Router } from 'express';
import { requireAuth, requireRole, CAN_MANAGE_USERS } from '../../middleware/auth.js';
import { requireAuth, requireRole, CAN_MANAGE_USERS, ROLES } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js';
import {
getUsers, getRoles, postUser, putUser, patchUserActive, postApproveUser, postRejectUser, postResendUserEmail,
getUserModules, putUserModules,
} from './controller.js';
const router = Router();
@@ -9,11 +11,13 @@ const router = Router();
router.use(requireAuth);
router.get('/', getUsers);
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);
router.post('/:id/resend', requireRole(...CAN_MANAGE_USERS), postResendUserEmail);
router.post('/', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), postUser);
router.put('/:id', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), putUser);
router.patch('/:id/active', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), patchUserActive);
router.post('/:id/approve', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), postApproveUser);
router.post('/:id/reject', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), postRejectUser);
router.get('/:id/modules', requireRole(ROLES.SUPERADMIN), getUserModules);
router.put('/:id/modules', requireRole(ROLES.SUPERADMIN), putUserModules);
router.post('/:id/resend', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), postResendUserEmail);
export default router;
+19
View File
@@ -4,6 +4,7 @@ import * as repo from './repository.js';
import { HttpError } from '../../utils/http.js';
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
import { logAudit } from '../../utils/audit.js';
import { MODULES, loadModules } from '../../middleware/modules.js';
import { sendMail } from '../../utils/mailer.js';
import { env } from '../../config/env.js';
import { invitationEmail, verificationEmail } from '../../utils/emailHtml.js';
@@ -148,3 +149,21 @@ export async function rejectUser(id, actorId) {
await logAudit({ userId: actorId, action: 'reject', entityType: 'user', entityId: id, detail: { email: existing.email, status: existing.status } });
await repo.remove(id);
}
// Acceso por modulo (solo superadmin). El superadmin siempre tiene todo y no se edita.
export async function getUserModules(id) {
const user = await repo.findById(id);
if (!user) throw new HttpError(404, 'Usuario no encontrado');
return { modules: await loadModules(id, user.role_code), all: MODULES, locked: user.role_code === 'superadmin' };
}
export async function setUserModules(id, modules, actor) {
const user = await repo.findById(id);
if (!user) throw new HttpError(404, 'Usuario no encontrado');
if (user.role_code === 'superadmin') throw new HttpError(400, 'El superadministrador siempre tiene todos los modulos');
if (!Array.isArray(modules) || modules.some((m) => !MODULES.includes(m))) throw new HttpError(400, 'Modulos invalidos');
const enabled = new Set(modules);
await repo.saveModules(id, MODULES.map((m) => [m, enabled.has(m)]), actor.id);
await logAudit({ userId: actor.id, action: 'update_modules', entityType: 'user', entityId: id, detail: { modules } });
return getUserModules(id);
}
+2
View File
@@ -1,6 +1,7 @@
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { requireAuth } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js';
import { requireVaultUnlock } from './middleware.js';
import {
getStatus, postPin, postUnlock, postChangePin, postForgotPin, postResetPin,
@@ -18,6 +19,7 @@ const unlockLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 8, standardHe
router.post('/reset-pin', unlockLimiter, postResetPin);
router.use(requireAuth);
router.use(requireModule('vault'));
router.get('/status', getStatus);
router.post('/pin', postPin);
router.post('/unlock', unlockLimiter, postUnlock);