From 4056c70d9093c2ec72eac57ce024b158731daee2 Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Fri, 23 Oct 2026 14:14:01 -0500 Subject: [PATCH] feat: version 3.1.0 -- Acceso por modulo y por usuario Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 +- API.md | 4 ++ BASE_DE_DATOS.md | 1 + CHANGELOG.md | 12 ++++ ESTADO.md | 7 ++- SPEC.md | 3 +- VERSION | 2 +- .../database/migrations/030_user_modules.sql | 12 ++++ backend/package.json | 2 +- backend/src/middleware/auth.js | 2 + backend/src/middleware/modules.js | 27 +++++++++ backend/src/modules/activities/routes.js | 2 + backend/src/modules/auth/service.js | 5 +- backend/src/modules/departments/routes.js | 7 ++- backend/src/modules/devices/routes.js | 2 + backend/src/modules/notes/routes.js | 2 + backend/src/modules/projects/routes.js | 2 + backend/src/modules/recurring-tasks/routes.js | 2 + backend/src/modules/search/service.js | 4 +- backend/src/modules/tasks/routes.js | 2 + backend/src/modules/users/controller.js | 6 ++ backend/src/modules/users/repository.js | 10 ++++ backend/src/modules/users/routes.js | 18 +++--- backend/src/modules/users/service.js | 19 ++++++ backend/src/modules/vault/routes.js | 2 + frontend/package.json | 2 +- frontend/src/app/App.jsx | 16 ++--- frontend/src/app/ProtectedRoute.jsx | 8 +++ frontend/src/components/NavigationView.jsx | 15 ++--- frontend/src/i18n/messages.js | 6 ++ .../pages/Organizacion/UserModulesDialog.jsx | 59 +++++++++++++++++++ .../src/pages/Organizacion/UsersPanel.jsx | 8 ++- frontend/src/services/users.js | 2 + frontend/src/utils/modules.js | 13 ++++ 34 files changed, 253 insertions(+), 34 deletions(-) create mode 100644 backend/database/migrations/030_user_modules.sql create mode 100644 backend/src/middleware/modules.js create mode 100644 frontend/src/pages/Organizacion/UserModulesDialog.jsx create mode 100644 frontend/src/utils/modules.js diff --git a/AGENTS.md b/AGENTS.md index 29107f2..7a2cf3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,6 +171,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador): - Fase 2: riesgos/incidencias como módulos propios, solicitudes formales, recursos/proveedores, presupuesto y costos, registro de horas detallado, reportes exportables (PDF/Excel/CSV), - permisos configurables por módulo×acción, notificaciones por email. + permisos por acción dentro de cada módulo (el acceso por módulo y usuario ya existe, v3.1.0), + integración IA, notificaciones por email. - Limpiar `.md` del source en producción (`find ... -iname '*.md' -delete`) — pendiente, ver `ESTADO.md`. diff --git a/API.md b/API.md index e605a44..2105f2c 100644 --- a/API.md +++ b/API.md @@ -57,6 +57,10 @@ el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`. - `GET /api/users` · `POST /api/users` · `PUT /api/users/:id` · `PATCH /api/users/:id/active` - `POST /api/users/:id/approve` — `{ roleId?, departmentId?, editVersion }`, solo sobre usuarios `pending_approval`; también rechaza cambios concurrentes con `409`. - `POST /api/users/:id/reject` — borra al usuario `pending_approval` (se audita el email antes) +- `GET /api/users/:id/modules` · `PUT /api/users/:id/modules` `{ modules: [...] }` — solo Superadministrador. + Módulos: `projects`, `recurring_tasks`, `notes`, `vault`, `devices`, `organization`. No aplica a + un superadmin (`400`). `GET /api/auth/me` y el login devuelven `modules` (efectivos del usuario). + Las rutas de un módulo deshabilitado responden `403`; la búsqueda global devuelve vacío. ### Departamentos (`/api/departments`) - `GET /api/departments` (árbol) · `POST /api/departments` · `PUT /api/departments/:id` · `DELETE /api/departments/:id` diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index dda1276..9ce8f24 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -46,6 +46,7 @@ | `device_cpus` / `device_memories` / `device_disks` / `device_volumes` / `device_networks` / `device_monitors` / `device_software` | componentes de cada equipo, reemplazados completos en cada reporte | | `device_accounts` | cuentas que reporta el agente: sesión iniciada, usuarios locales y grupos locales (`kind`), reemplazadas en cada reporte (migración `027`) | | `device_changes` | diff entre reportes (RAM, discos, software instalado/desinstalado) | +| `user_modules` | acceso por módulo y usuario (`user_id`, `module`, `enabled`, `updated_by`); sin fila = habilitado (migración 030) | | `device_viewers` | usuarios con acceso de solo lectura compartido al menú Equipos (`user_id`, `granted_by`) | | `inventory_tokens` | tokens de ingesta del agente (solo hash sha256) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fd6fb2..52b9427 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog - project.linkedcorp.net +## [3.1.0] - 2026-10-23 +### Added +- Acceso por módulo y por usuario: el superadmin, desde Organización → Usuarios → menú de la + fila → "Módulos", habilita o quita Proyectos, Tareas recurrentes, Notas, Contraseñas, Equipos y + Organización. Panel lo ve todo usuario; Configuración sigue siendo solo de superadmin. +- El backend bloquea con 403 (`requireModule`) cada ruta del módulo deshabilitado; el menú lo + oculta y la ruta redirige al Panel. La búsqueda global devuelve vacío sin módulo Proyectos. + En Organización, sin el módulo no se puede gestionar (las lecturas de usuarios/departamentos + siguen abiertas porque alimentan selectores de otras pantallas). +- Migración 030 (`user_modules`): sin fila = habilitado, así que al actualizar nadie pierde acceso. + Cada cambio queda en auditoría (`update_modules`). + ## [3.0.1] - 2026-10-23 ### Fixed - El diálogo de actualización permanece abierto con la fase del proceso y solo recarga al confirmar diff --git a/ESTADO.md b/ESTADO.md index b185dc9..a920856 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -172,7 +172,12 @@ y recargar. Pendiente verificar el flujo completo en un despliegue autorizado; n - Recursos (servidores/licencias/software), Proveedores, Presupuesto y costos. - Registro de horas trabajadas detallado (bitacora diaria). - Reportes exportables (PDF/Excel/CSV) y vista ejecutiva separada. -- Permisos configurables por modulo × accion desde la UI (hoy: roles fijos codificados). +- Permisos por accion dentro de cada modulo (hoy: acceso por modulo y usuario desde v3.1.0 + + roles fijos codificados). +- Integracion IA (API Claude) — ideas: plan de proyecto generado desde una descripcion, resumen + semanal del Panel. Clave cifrada en `system_settings` como el SMTP; la IA solo sugiere. +- Panel: con el modulo Proyectos deshabilitado, los KPIs del Panel siguen mostrando datos de + proyectos del usuario (revisar si se deben ocultar). - Notificaciones por email/WhatsApp (hoy: solo in-app). - Job programado para detectar actividades vencidas/por vencer (hoy: solo se notifica al asignar). - DatePicker: sigue usando `` nativo (simplificacion deliberada del MVP). diff --git a/SPEC.md b/SPEC.md index 37b3433..2aa1c12 100644 --- a/SPEC.md +++ b/SPEC.md @@ -125,7 +125,8 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa - Recursos (servidores/licencias/software), Proveedores, Presupuesto y costos. - Registro de horas trabajadas detallado (bitácora diaria). - Reportes exportables (PDF/Excel/CSV) y vista ejecutiva separada. -- Permisos configurables por módulo × acción desde la UI. +- Permisos por acción dentro de cada módulo (ver/crear/editar/borrar). El acceso por módulo y + usuario ya existe (v3.1.0). - Notificaciones por email/WhatsApp. - Equipos de trabajo (personas) como entidad propia multi-proyecto (hoy: miembros por proyecto). diff --git a/VERSION b/VERSION index cb2b00e..fd2a018 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.1 +3.1.0 diff --git a/backend/database/migrations/030_user_modules.sql b/backend/database/migrations/030_user_modules.sql new file mode 100644 index 0000000..c1f71bd --- /dev/null +++ b/backend/database/migrations/030_user_modules.sql @@ -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; diff --git a/backend/package.json b/backend/package.json index 4a53c38..4bf63da 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "3.0.1", + "version": "3.1.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index f9bc6c8..f6a6b42 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -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(); } diff --git a/backend/src/middleware/modules.js b/backend/src/middleware/modules.js new file mode 100644 index 0000000..bca3b19 --- /dev/null +++ b/backend/src/middleware/modules.js @@ -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(); + }; +} diff --git a/backend/src/modules/activities/routes.js b/backend/src/modules/activities/routes.js index 232ab73..63b2dce 100644 --- a/backend/src/modules/activities/routes.js +++ b/backend/src/modules/activities/routes.js @@ -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); diff --git a/backend/src/modules/auth/service.js b/backend/src/modules/auth/service.js index accaf68..0b1f493 100644 --- a/backend/src/modules/auth/service.js +++ b/backend/src/modules/auth/service.js @@ -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), }, }; } diff --git a/backend/src/modules/departments/routes.js b/backend/src/modules/departments/routes.js index 7d864e6..9b7cbbb 100644 --- a/backend/src/modules/departments/routes.js +++ b/backend/src/modules/departments/routes.js @@ -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; diff --git a/backend/src/modules/devices/routes.js b/backend/src/modules/devices/routes.js index e1efaba..59ff33b 100644 --- a/backend/src/modules/devices/routes.js +++ b/backend/src/modules/devices/routes.js @@ -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); diff --git a/backend/src/modules/notes/routes.js b/backend/src/modules/notes/routes.js index c47d3b2..01d5403 100644 --- a/backend/src/modules/notes/routes.js +++ b/backend/src/modules/notes/routes.js @@ -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"). diff --git a/backend/src/modules/projects/routes.js b/backend/src/modules/projects/routes.js index 1b44b32..c9785d2 100644 --- a/backend/src/modules/projects/routes.js +++ b/backend/src/modules/projects/routes.js @@ -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); diff --git a/backend/src/modules/recurring-tasks/routes.js b/backend/src/modules/recurring-tasks/routes.js index 3a245db..a31c477 100644 --- a/backend/src/modules/recurring-tasks/routes.js +++ b/backend/src/modules/recurring-tasks/routes.js @@ -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); diff --git a/backend/src/modules/search/service.js b/backend/src/modules/search/service.js index 9dfbb10..f0d7d5d 100644 --- a/backend/src/modules/search/service.js +++ b/backend/src/modules/search/service.js @@ -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); diff --git a/backend/src/modules/tasks/routes.js b/backend/src/modules/tasks/routes.js index d5e206c..7e3637b 100644 --- a/backend/src/modules/tasks/routes.js +++ b/backend/src/modules/tasks/routes.js @@ -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); diff --git a/backend/src/modules/users/controller.js b/backend/src/modules/users/controller.js index 019c8f8..4bbd581 100644 --- a/backend/src/modules/users/controller.js +++ b/backend/src/modules/users/controller.js @@ -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)); +}); diff --git a/backend/src/modules/users/repository.js b/backend/src/modules/users/repository.js index 16d6385..ece3bc6 100644 --- a/backend/src/modules/users/repository.js +++ b/backend/src/modules/users/repository.js @@ -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] + ); +} diff --git a/backend/src/modules/users/routes.js b/backend/src/modules/users/routes.js index e14bc4f..00fd123 100644 --- a/backend/src/modules/users/routes.js +++ b/backend/src/modules/users/routes.js @@ -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; diff --git a/backend/src/modules/users/service.js b/backend/src/modules/users/service.js index cc62c0f..6e7edd5 100644 --- a/backend/src/modules/users/service.js +++ b/backend/src/modules/users/service.js @@ -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); +} diff --git a/backend/src/modules/vault/routes.js b/backend/src/modules/vault/routes.js index 6b7a809..6ae1a63 100644 --- a/backend/src/modules/vault/routes.js +++ b/backend/src/modules/vault/routes.js @@ -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); diff --git a/frontend/package.json b/frontend/package.json index 3c4331a..e6718dd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "3.0.1", + "version": "3.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index baa686f..bfb99ea 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -5,7 +5,7 @@ import { BrandingProvider } from '../context/branding-context.jsx'; import { ContentDialogHost } from '../components/ContentDialog.jsx'; import { ToastHost } from '../components/ToastHost.jsx'; import { ErrorBoundary } from '../components/ErrorBoundary.jsx'; -import { ProtectedRoute } from './ProtectedRoute.jsx'; +import { ProtectedRoute, ModuleRoute } from './ProtectedRoute.jsx'; import AppShell from './AppShell.jsx'; import LoginPage from '../pages/Login/LoginPage.jsx'; import RegisterPage from '../pages/Login/RegisterPage.jsx'; @@ -37,13 +37,13 @@ const router = createBrowserRouter(createRoutesFromElements( } /> }> } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/frontend/src/app/ProtectedRoute.jsx b/frontend/src/app/ProtectedRoute.jsx index 8451f00..dcb154e 100644 --- a/frontend/src/app/ProtectedRoute.jsx +++ b/frontend/src/app/ProtectedRoute.jsx @@ -1,5 +1,6 @@ import { Navigate, useLocation } from 'react-router-dom'; import { useAuth } from '../context/auth-context.jsx'; +import { hasModule } from '../utils/modules.js'; export function ProtectedRoute({ children }) { const { user, ready } = useAuth(); @@ -9,3 +10,10 @@ export function ProtectedRoute({ children }) { if (!user) return ; return children; } + +// Modulo deshabilitado para el usuario: al Panel (el backend igual responde 403). +export function ModuleRoute({ module, children }) { + const { user } = useAuth(); + if (!hasModule(user, module)) return ; + return children; +} diff --git a/frontend/src/components/NavigationView.jsx b/frontend/src/components/NavigationView.jsx index 9f0d089..bea4064 100644 --- a/frontend/src/components/NavigationView.jsx +++ b/frontend/src/components/NavigationView.jsx @@ -9,15 +9,16 @@ import { useBranding } from '../context/branding-context.jsx'; import logoIcon from '../assets/logoLinkedTrackIcon.png'; import { getDevicesAccess } from '../services/devices.js'; import pkg from '../../package.json'; +import { hasModule } from '../utils/modules.js'; const ITEMS = [ { to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true }, - { to: '/proyectos', icon: FolderKanban, labelKey: 'nav_projects' }, - { to: '/tareas-recurrentes', icon: Repeat, labelKey: 'nav_recurring_tasks' }, - { to: '/notas', icon: StickyNote, labelKey: 'nav_notes' }, - { to: '/contrasenas', icon: KeyRound, labelKey: 'nav_passwords' }, - { to: '/equipos', icon: Monitor, labelKey: 'nav_devices', needsDevicesAccess: true }, - { to: '/organizacion', icon: Building2, labelKey: 'nav_organization' }, + { to: '/proyectos', module: 'projects', icon: FolderKanban, labelKey: 'nav_projects' }, + { to: '/tareas-recurrentes', module: 'recurring_tasks', icon: Repeat, labelKey: 'nav_recurring_tasks' }, + { to: '/notas', module: 'notes', icon: StickyNote, labelKey: 'nav_notes' }, + { to: '/contrasenas', module: 'vault', icon: KeyRound, labelKey: 'nav_passwords' }, + { to: '/equipos', module: 'devices', icon: Monitor, labelKey: 'nav_devices', needsDevicesAccess: true }, + { to: '/organizacion', module: 'organization', icon: Building2, labelKey: 'nav_organization' }, { to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true }, ]; @@ -34,7 +35,7 @@ export function NavigationView({ mode, mobileOpen, onNavigate }) { if (!user) return; getDevicesAccess().then((a) => setCanSeeDevices(!!a?.canView)).catch(() => setCanSeeDevices(false)); }, [user?.id]); // eslint-disable-line react-hooks/exhaustive-deps - const items = ITEMS.filter((item) => (!item.superadminOnly || user?.roleCode === 'superadmin') && (!item.needsDevicesAccess || canSeeDevices)); + const items = ITEMS.filter((item) => hasModule(user, item.module) && (!item.superadminOnly || user?.roleCode === 'superadmin') && (!item.needsDevicesAccess || canSeeDevices)); const brandName = branding.name || t('appName'); // Menus con borradores pendientes (el borrador guarda la ruta donde se estaba editando). const drafts = useUserDrafts(); diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index e12512c..61a009a 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -2,6 +2,9 @@ export const messages = { 'es-EC': { appName: 'LinkedCorp Proyectos', nav_dashboard: 'Panel', + user_modules: 'Módulos', + user_modules_hint: 'Marca los módulos que este usuario puede usar. Lo que no esté marcado no aparece en su menú y el sistema le niega el acceso.', + user_modules_saved: 'Módulos actualizados. El usuario verá el cambio al recargar.', nav_projects: 'Proyectos', nav_organization: 'Organizacion', nav_config: 'Configuracion', @@ -521,6 +524,9 @@ export const messages = { 'en-US': { appName: 'LinkedCorp Projects', nav_dashboard: 'Dashboard', + user_modules: 'Modules', + user_modules_hint: 'Check the modules this user can use. Unchecked modules are hidden from their menu and access is denied.', + user_modules_saved: 'Modules updated. The user will see the change after reloading.', nav_projects: 'Projects', nav_organization: 'Organization', nav_config: 'Settings', diff --git a/frontend/src/pages/Organizacion/UserModulesDialog.jsx b/frontend/src/pages/Organizacion/UserModulesDialog.jsx new file mode 100644 index 0000000..b536e0c --- /dev/null +++ b/frontend/src/pages/Organizacion/UserModulesDialog.jsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react'; +import { Save } from 'lucide-react'; +import { DetailPopup } from '../../components/DetailPopup.jsx'; +import { Button } from '../../components/Button.jsx'; +import { useUi } from '../../context/ui-context.jsx'; +import { getUserModules, setUserModules } from '../../services/users.js'; +import { MODULE_LABEL_KEYS } from '../../utils/modules.js'; + +// Acceso por modulo de un usuario (solo superadmin). Sin marcar = el usuario no ve el modulo. +export function UserModulesDialog({ user, onClose }) { + const { t, pushToast } = useUi(); + const [all, setAll] = useState([]); + const [initial, setInitial] = useState([]); + const [selected, setSelected] = useState([]); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!user) return; + getUserModules(user.id).then((r) => { setAll(r.all); setInitial(r.modules); setSelected(r.modules); }).catch(() => {}); + }, [user?.id]); // eslint-disable-line react-hooks/exhaustive-deps + + const dirty = all.some((m) => initial.includes(m) !== selected.includes(m)); + + function toggle(module) { + setSelected((s) => (s.includes(module) ? s.filter((m) => m !== module) : [...s, module])); + } + + async function save() { + if (!dirty) return onClose(); + setSaving(true); + try { + await setUserModules(user.id, selected); + pushToast({ tone: 'success', message: t('user_modules_saved') }); + onClose(); + } catch (err) { + pushToast({ tone: 'error', message: err.message }); + } finally { + setSaving(false); + } + } + + return ( + +

{t('user_modules_hint')}

+
+ {all.map((m) => ( + + ))} +
+
+ + +
+
+ ); +} diff --git a/frontend/src/pages/Organizacion/UsersPanel.jsx b/frontend/src/pages/Organizacion/UsersPanel.jsx index 804aefc..210200c 100644 --- a/frontend/src/pages/Organizacion/UsersPanel.jsx +++ b/frontend/src/pages/Organizacion/UsersPanel.jsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Plus, Save, UserX, UserCheck, Check, X as XIcon, Mail } from 'lucide-react'; +import { Plus, Save, UserX, UserCheck, Check, X as XIcon, Mail, LayoutGrid } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; import { useAuth } from '../../context/auth-context.jsx'; import { useAsync, useAction } from '../../hooks/useAsync.js'; @@ -12,6 +12,7 @@ import { Field, FieldRow } from '../../components/Field.jsx'; import { ComboBox } from '../../components/ComboBox.jsx'; import { Badge } from '../../components/Badge.jsx'; import { DetailPopup } from '../../components/DetailPopup.jsx'; +import { UserModulesDialog } from './UserModulesDialog.jsx'; import { DraftNotice } from '../../components/DraftNotice.jsx'; import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx'; import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js'; @@ -34,6 +35,8 @@ export default function UsersPanel() { const { data: deptTree } = useAsync(listDepartments, []); const canManage = ['admin', 'superadmin'].includes(currentUser?.roleCode); + const isSuperadmin = currentUser?.roleCode === 'superadmin'; + const [modulesUser, setModulesUser] = useState(null); const [search, setSearch] = useState(''); const [editing, setEditing] = useState(false); @@ -179,6 +182,7 @@ export default function UsersPanel() { } return [ { label: t('common_edit'), onClick: () => startEdit(u) }, + ...(isSuperadmin && u.role_code !== 'superadmin' ? [{ label: t('user_modules'), icon: LayoutGrid, onClick: () => setModulesUser(u) }] : []), u.is_active ? { label: t('common_inactive'), icon: UserX, danger: true, onClick: () => toggleActive(u) } : { label: t('common_active'), icon: UserCheck, onClick: () => toggleActive(u) }, @@ -186,6 +190,8 @@ export default function UsersPanel() { } : undefined} /> + {modulesUser && setModulesUser(null)} />} + guardedClose(draft, () => setEditing(false))} width={560} height={500}> diff --git a/frontend/src/services/users.js b/frontend/src/services/users.js index eb378d2..d1c4674 100644 --- a/frontend/src/services/users.js +++ b/frontend/src/services/users.js @@ -8,3 +8,5 @@ export const setUserActive = (id, isActive) => api.patch(`/users/${id}/active`, export const approveUser = (id, data) => api.post(`/users/${id}/approve`, data); export const rejectUser = (id) => api.post(`/users/${id}/reject`); export const resendUserEmail = (id) => api.post(`/users/${id}/resend`); +export const getUserModules = (id) => api.get(`/users/${id}/modules`); +export const setUserModules = (id, modules) => api.put(`/users/${id}/modules`, { modules }); diff --git a/frontend/src/utils/modules.js b/frontend/src/utils/modules.js new file mode 100644 index 0000000..04daabb --- /dev/null +++ b/frontend/src/utils/modules.js @@ -0,0 +1,13 @@ +// Espejo de backend/src/middleware/modules.js: el backend es quien bloquea, aqui solo se oculta. +export const MODULE_LABEL_KEYS = { + projects: 'nav_projects', + recurring_tasks: 'nav_recurring_tasks', + notes: 'nav_notes', + vault: 'nav_passwords', + devices: 'nav_devices', + organization: 'nav_organization', +}; + +export function hasModule(user, module) { + return !module || !!user?.modules?.includes(module); +}