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:
@@ -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,
|
- Fase 2: riesgos/incidencias como módulos propios, solicitudes formales, recursos/proveedores,
|
||||||
presupuesto y costos, registro de horas detallado, reportes exportables (PDF/Excel/CSV),
|
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
|
- Limpiar `.md` del source en producción (`find ... -iname '*.md' -delete`) — pendiente, ver
|
||||||
`ESTADO.md`.
|
`ESTADO.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`
|
- `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/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)
|
- `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`)
|
### Departamentos (`/api/departments`)
|
||||||
- `GET /api/departments` (árbol) · `POST /api/departments` · `PUT /api/departments/:id` · `DELETE /api/departments/:id`
|
- `GET /api/departments` (árbol) · `POST /api/departments` · `PUT /api/departments/:id` · `DELETE /api/departments/:id`
|
||||||
|
|||||||
@@ -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_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_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) |
|
| `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`) |
|
| `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) |
|
| `inventory_tokens` | tokens de ingesta del agente (solo hash sha256) |
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [3.0.1] - 2026-10-23
|
||||||
### Fixed
|
### Fixed
|
||||||
- El diálogo de actualización permanece abierto con la fase del proceso y solo recarga al confirmar
|
- El diálogo de actualización permanece abierto con la fase del proceso y solo recarga al confirmar
|
||||||
|
|||||||
@@ -172,7 +172,12 @@ y recargar. Pendiente verificar el flujo completo en un despliegue autorizado; n
|
|||||||
- Recursos (servidores/licencias/software), Proveedores, Presupuesto y costos.
|
- Recursos (servidores/licencias/software), Proveedores, Presupuesto y costos.
|
||||||
- Registro de horas trabajadas detallado (bitacora diaria).
|
- Registro de horas trabajadas detallado (bitacora diaria).
|
||||||
- Reportes exportables (PDF/Excel/CSV) y vista ejecutiva separada.
|
- 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).
|
- Notificaciones por email/WhatsApp (hoy: solo in-app).
|
||||||
- Job programado para detectar actividades vencidas/por vencer (hoy: solo se notifica al asignar).
|
- Job programado para detectar actividades vencidas/por vencer (hoy: solo se notifica al asignar).
|
||||||
- DatePicker: sigue usando `<input type="date">` nativo (simplificacion deliberada del MVP).
|
- DatePicker: sigue usando `<input type="date">` nativo (simplificacion deliberada del MVP).
|
||||||
|
|||||||
@@ -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.
|
- Recursos (servidores/licencias/software), Proveedores, Presupuesto y costos.
|
||||||
- Registro de horas trabajadas detallado (bitácora diaria).
|
- Registro de horas trabajadas detallado (bitácora diaria).
|
||||||
- Reportes exportables (PDF/Excel/CSV) y vista ejecutiva separada.
|
- 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.
|
- Notificaciones por email/WhatsApp.
|
||||||
- Equipos de trabajo (personas) como entidad propia multi-proyecto (hoy: miembros por proyecto).
|
- Equipos de trabajo (personas) como entidad propia multi-proyecto (hoy: miembros por proyecto).
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "3.0.1",
|
"version": "3.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import jwt from 'jsonwebtoken';
|
|||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
import { pool } from '../config/db.js';
|
import { pool } from '../config/db.js';
|
||||||
import { fail } from '../utils/http.js';
|
import { fail } from '../utils/http.js';
|
||||||
|
import { loadModules } from './modules.js';
|
||||||
|
|
||||||
export async function requireAuth(req, res, next) {
|
export async function requireAuth(req, res, next) {
|
||||||
const header = req.headers.authorization || '';
|
const header = req.headers.authorization || '';
|
||||||
@@ -31,6 +32,7 @@ export async function requireAuth(req, res, next) {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
roleCode: user.role_code,
|
roleCode: user.role_code,
|
||||||
departmentId: user.department_id,
|
departmentId: user.department_id,
|
||||||
|
modules: await loadModules(user.id, user.role_code),
|
||||||
};
|
};
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth } from '../../middleware/auth.js';
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
|
import { requireModule } from '../../middleware/modules.js';
|
||||||
import { getActivity, putActivity, deleteActivity, postActivityRestore, putMoveActivity, postConvertToProject, postDependency, deleteDependency } from './controller.js';
|
import { getActivity, putActivity, deleteActivity, postActivityRestore, putMoveActivity, postConvertToProject, postDependency, deleteDependency } from './controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
router.use(requireModule('projects'));
|
||||||
router.get('/:id', getActivity);
|
router.get('/:id', getActivity);
|
||||||
router.put('/:id', putActivity);
|
router.put('/:id', putActivity);
|
||||||
router.delete('/:id', deleteActivity);
|
router.delete('/:id', deleteActivity);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { pool } from '../../config/db.js';
|
|||||||
import { env } from '../../config/env.js';
|
import { env } from '../../config/env.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
|
import { loadModules } from '../../middleware/modules.js';
|
||||||
import { sendMail } from '../../utils/mailer.js';
|
import { sendMail } from '../../utils/mailer.js';
|
||||||
import { createNotification } from '../notifications/service.js';
|
import { createNotification } from '../notifications/service.js';
|
||||||
import { verificationEmail, recoveryEmail } from '../../utils/emailHtml.js';
|
import { verificationEmail, recoveryEmail } from '../../utils/emailHtml.js';
|
||||||
@@ -49,6 +50,7 @@ export async function login(email, password) {
|
|||||||
roleCode: user.role_code,
|
roleCode: user.role_code,
|
||||||
roleName: user.role_name,
|
roleName: user.role_name,
|
||||||
departmentId: user.department_id,
|
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) {
|
if (bootstrap) {
|
||||||
await logAudit({ userId: result.insertId, action: 'register_bootstrap_superadmin', entityType: 'user', entityId: result.insertId });
|
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 });
|
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 });
|
await logAudit({ userId: result.insertId, action: 'register', entityType: 'user', entityId: result.insertId });
|
||||||
|
|
||||||
@@ -182,6 +184,7 @@ export async function acceptInvite(token, name, password) {
|
|||||||
user: {
|
user: {
|
||||||
id: full[0].id, name: full[0].name, email: full[0].email,
|
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,
|
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),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth, requireRole, CAN_MANAGE_USERS } from '../../middleware/auth.js';
|
import { requireAuth, requireRole, CAN_MANAGE_USERS } from '../../middleware/auth.js';
|
||||||
|
import { requireModule } from '../../middleware/modules.js';
|
||||||
import { getDepartments, postDepartment, putDepartment, deleteDepartment } from './controller.js';
|
import { getDepartments, postDepartment, putDepartment, deleteDepartment } from './controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
router.get('/', getDepartments);
|
router.get('/', getDepartments);
|
||||||
router.post('/', requireRole(...CAN_MANAGE_USERS), postDepartment);
|
router.post('/', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), postDepartment);
|
||||||
router.put('/:id', requireRole(...CAN_MANAGE_USERS), putDepartment);
|
router.put('/:id', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), putDepartment);
|
||||||
router.delete('/:id', requireRole(...CAN_MANAGE_USERS), deleteDepartment);
|
router.delete('/:id', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), deleteDepartment);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth, requireRole, CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
|
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 { fail } from '../../utils/http.js';
|
||||||
import { getAccess as getAccessFor } from './service.js';
|
import { getAccess as getAccessFor } from './service.js';
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,7 @@ async function canView(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
router.use(requireModule('devices'));
|
||||||
router.get('/access', getAccess); // cualquier autenticado: el menu lo usa para decidir si se muestra
|
router.get('/access', getAccess); // cualquier autenticado: el menu lo usa para decidir si se muestra
|
||||||
router.get('/dashboard', canView, getDashboard);
|
router.get('/dashboard', canView, getDashboard);
|
||||||
router.get('/software', canView, getSoftware);
|
router.get('/software', canView, getSoftware);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth } from '../../middleware/auth.js';
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
|
import { requireModule } from '../../middleware/modules.js';
|
||||||
import {
|
import {
|
||||||
getNotes, postNote, putNote, deleteNote, putReorderNotes, getNoteShares, postNoteShare, deleteNoteShare,
|
getNotes, postNote, putNote, deleteNote, putReorderNotes, getNoteShares, postNoteShare, deleteNoteShare,
|
||||||
} from './controller.js';
|
} 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
|
// 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).
|
// otros compartieron con el). Los permisos de compartir se resuelven en el service (dueño-only).
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
router.use(requireModule('notes'));
|
||||||
router.get('/', getNotes);
|
router.get('/', getNotes);
|
||||||
router.post('/', postNote);
|
router.post('/', postNote);
|
||||||
// /reorder ANTES de /:id -- si no, Express lo confunde con un id (":id" matcheria "reorder").
|
// /reorder ANTES de /:id -- si no, Express lo confunde con un id (":id" matcheria "reorder").
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth } from '../../middleware/auth.js';
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
|
import { requireModule } from '../../middleware/modules.js';
|
||||||
import {
|
import {
|
||||||
getProjects, getProjectOptions, getProject, postProject, putProject, deleteProject, postProjectRestore,
|
getProjects, getProjectOptions, getProject, postProject, putProject, deleteProject, postProjectRestore,
|
||||||
deleteProjectPermanent, postMember, putMemberRole, deleteMember,
|
deleteProjectPermanent, postMember, putMemberRole, deleteMember,
|
||||||
@@ -10,6 +11,7 @@ import { getActivitiesByProject, getActivitiesTrash, postActivity, putReorderAct
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
router.use(requireModule('projects'));
|
||||||
router.get('/', getProjects);
|
router.get('/', getProjects);
|
||||||
router.get('/options', getProjectOptions);
|
router.get('/options', getProjectOptions);
|
||||||
router.post('/', postProject);
|
router.post('/', postProject);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth } from '../../middleware/auth.js';
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
|
import { requireModule } from '../../middleware/modules.js';
|
||||||
import {
|
import {
|
||||||
getRecurringTasks, postRecurringTask, putRecurringTask, deleteRecurringTask,
|
getRecurringTasks, postRecurringTask, putRecurringTask, deleteRecurringTask,
|
||||||
postCompleteCycle, postPromoteFromTask,
|
postCompleteCycle, postPromoteFromTask,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
router.use(requireModule('recurring_tasks'));
|
||||||
router.get('/', getRecurringTasks);
|
router.get('/', getRecurringTasks);
|
||||||
router.post('/', postRecurringTask);
|
router.post('/', postRecurringTask);
|
||||||
router.post('/from-task/:taskId', postPromoteFromTask);
|
router.post('/from-task/:taskId', postPromoteFromTask);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
import { getProject } from '../projects/service.js';
|
import { getProject } from '../projects/service.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
import { hasModule } from '../../middleware/modules.js';
|
||||||
|
|
||||||
const MIN_LENGTH = 2;
|
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');
|
if (!Number.isSafeInteger(Number(projectId)) || Number(projectId) < 1) throw new HttpError(400, 'projectId invalido');
|
||||||
await getProject(Number(projectId), currentUser);
|
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 ? { activities: [], tasks: [] } : { projects: [], activities: [], tasks: [] };
|
||||||
}
|
}
|
||||||
return projectId ? repo.searchInProject(Number(projectId), q) : repo.searchGlobal(q, currentUser);
|
return projectId ? repo.searchInProject(Number(projectId), q) : repo.searchGlobal(q, currentUser);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth } from '../../middleware/auth.js';
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
|
import { requireModule } from '../../middleware/modules.js';
|
||||||
import { getTasks, getTasksTrash, postTask, putTask, putReorderTasks, patchTaskDone, deleteTask, postTaskRestore } from './controller.js';
|
import { getTasks, getTasksTrash, postTask, putTask, putReorderTasks, patchTaskDone, deleteTask, postTaskRestore } from './controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
router.use(['/tasks', '/activities'], requireModule('projects'));
|
||||||
router.get('/activities/:activityId/tasks', getTasks);
|
router.get('/activities/:activityId/tasks', getTasks);
|
||||||
router.get('/activities/:activityId/tasks/trash', getTasksTrash);
|
router.get('/activities/:activityId/tasks/trash', getTasksTrash);
|
||||||
router.post('/activities/:activityId/tasks', postTask);
|
router.post('/activities/:activityId/tasks', postTask);
|
||||||
|
|||||||
@@ -32,3 +32,9 @@ export const postRejectUser = asyncHandler(async (req, res) => {
|
|||||||
export const postResendUserEmail = asyncHandler(async (req, res) => {
|
export const postResendUserEmail = asyncHandler(async (req, res) => {
|
||||||
ok(res, await service.resendUserEmail(Number(req.params.id), req.currentUser.id));
|
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));
|
||||||
|
});
|
||||||
|
|||||||
@@ -90,3 +90,13 @@ export async function listRoles() {
|
|||||||
const [rows] = await pool.query('SELECT id, code, name FROM roles ORDER BY id');
|
const [rows] = await pool.query('SELECT id, code, name FROM roles ORDER BY id');
|
||||||
return rows;
|
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]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Router } from 'express';
|
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 {
|
import {
|
||||||
getUsers, getRoles, postUser, putUser, patchUserActive, postApproveUser, postRejectUser, postResendUserEmail,
|
getUsers, getRoles, postUser, putUser, patchUserActive, postApproveUser, postRejectUser, postResendUserEmail,
|
||||||
|
getUserModules, putUserModules,
|
||||||
} from './controller.js';
|
} from './controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -9,11 +11,13 @@ const router = Router();
|
|||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
router.get('/', getUsers);
|
router.get('/', getUsers);
|
||||||
router.get('/roles', getRoles);
|
router.get('/roles', getRoles);
|
||||||
router.post('/', requireRole(...CAN_MANAGE_USERS), postUser);
|
router.post('/', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), postUser);
|
||||||
router.put('/:id', requireRole(...CAN_MANAGE_USERS), putUser);
|
router.put('/:id', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), putUser);
|
||||||
router.patch('/:id/active', requireRole(...CAN_MANAGE_USERS), patchUserActive);
|
router.patch('/:id/active', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), patchUserActive);
|
||||||
router.post('/:id/approve', requireRole(...CAN_MANAGE_USERS), postApproveUser);
|
router.post('/:id/approve', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), postApproveUser);
|
||||||
router.post('/:id/reject', requireRole(...CAN_MANAGE_USERS), postRejectUser);
|
router.post('/:id/reject', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), postRejectUser);
|
||||||
router.post('/:id/resend', requireRole(...CAN_MANAGE_USERS), postResendUserEmail);
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as repo from './repository.js';
|
|||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
|
import { MODULES, loadModules } from '../../middleware/modules.js';
|
||||||
import { sendMail } from '../../utils/mailer.js';
|
import { sendMail } from '../../utils/mailer.js';
|
||||||
import { env } from '../../config/env.js';
|
import { env } from '../../config/env.js';
|
||||||
import { invitationEmail, verificationEmail } from '../../utils/emailHtml.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 logAudit({ userId: actorId, action: 'reject', entityType: 'user', entityId: id, detail: { email: existing.email, status: existing.status } });
|
||||||
await repo.remove(id);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import { requireAuth } from '../../middleware/auth.js';
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
|
import { requireModule } from '../../middleware/modules.js';
|
||||||
import { requireVaultUnlock } from './middleware.js';
|
import { requireVaultUnlock } from './middleware.js';
|
||||||
import {
|
import {
|
||||||
getStatus, postPin, postUnlock, postChangePin, postForgotPin, postResetPin,
|
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.post('/reset-pin', unlockLimiter, postResetPin);
|
||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
router.use(requireModule('vault'));
|
||||||
router.get('/status', getStatus);
|
router.get('/status', getStatus);
|
||||||
router.post('/pin', postPin);
|
router.post('/pin', postPin);
|
||||||
router.post('/unlock', unlockLimiter, postUnlock);
|
router.post('/unlock', unlockLimiter, postUnlock);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.0.1",
|
"version": "3.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { BrandingProvider } from '../context/branding-context.jsx';
|
|||||||
import { ContentDialogHost } from '../components/ContentDialog.jsx';
|
import { ContentDialogHost } from '../components/ContentDialog.jsx';
|
||||||
import { ToastHost } from '../components/ToastHost.jsx';
|
import { ToastHost } from '../components/ToastHost.jsx';
|
||||||
import { ErrorBoundary } from '../components/ErrorBoundary.jsx';
|
import { ErrorBoundary } from '../components/ErrorBoundary.jsx';
|
||||||
import { ProtectedRoute } from './ProtectedRoute.jsx';
|
import { ProtectedRoute, ModuleRoute } from './ProtectedRoute.jsx';
|
||||||
import AppShell from './AppShell.jsx';
|
import AppShell from './AppShell.jsx';
|
||||||
import LoginPage from '../pages/Login/LoginPage.jsx';
|
import LoginPage from '../pages/Login/LoginPage.jsx';
|
||||||
import RegisterPage from '../pages/Login/RegisterPage.jsx';
|
import RegisterPage from '../pages/Login/RegisterPage.jsx';
|
||||||
@@ -37,13 +37,13 @@ const router = createBrowserRouter(createRoutesFromElements(
|
|||||||
<Route path="/aceptar-invitacion" element={<AcceptInvitePage />} />
|
<Route path="/aceptar-invitacion" element={<AcceptInvitePage />} />
|
||||||
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
|
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="proyectos" element={<ProyectosPage />} />
|
<Route path="proyectos" element={<ModuleRoute module="projects"><ProyectosPage /></ModuleRoute>} />
|
||||||
<Route path="proyectos/:id" element={<ProyectoDetallePage />} />
|
<Route path="proyectos/:id" element={<ModuleRoute module="projects"><ProyectoDetallePage /></ModuleRoute>} />
|
||||||
<Route path="tareas-recurrentes" element={<RecurringTasksPage />} />
|
<Route path="tareas-recurrentes" element={<ModuleRoute module="recurring_tasks"><RecurringTasksPage /></ModuleRoute>} />
|
||||||
<Route path="notas" element={<NotasPage />} />
|
<Route path="notas" element={<ModuleRoute module="notes"><NotasPage /></ModuleRoute>} />
|
||||||
<Route path="contrasenas" element={<ContrasenasPage />} />
|
<Route path="contrasenas" element={<ModuleRoute module="vault"><ContrasenasPage /></ModuleRoute>} />
|
||||||
<Route path="equipos" element={<EquiposPage />} />
|
<Route path="equipos" element={<ModuleRoute module="devices"><EquiposPage /></ModuleRoute>} />
|
||||||
<Route path="organizacion" element={<OrganizacionPage />} />
|
<Route path="organizacion" element={<ModuleRoute module="organization"><OrganizacionPage /></ModuleRoute>} />
|
||||||
<Route path="configuracion" element={<ConfiguracionPage />} />
|
<Route path="configuracion" element={<ConfiguracionPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/auth-context.jsx';
|
import { useAuth } from '../context/auth-context.jsx';
|
||||||
|
import { hasModule } from '../utils/modules.js';
|
||||||
|
|
||||||
export function ProtectedRoute({ children }) {
|
export function ProtectedRoute({ children }) {
|
||||||
const { user, ready } = useAuth();
|
const { user, ready } = useAuth();
|
||||||
@@ -9,3 +10,10 @@ export function ProtectedRoute({ children }) {
|
|||||||
if (!user) return <Navigate to="/login" replace state={{ from: `${location.pathname}${location.search}` }} />;
|
if (!user) return <Navigate to="/login" replace state={{ from: `${location.pathname}${location.search}` }} />;
|
||||||
return children;
|
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 <Navigate to="/" replace />;
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,15 +9,16 @@ import { useBranding } from '../context/branding-context.jsx';
|
|||||||
import logoIcon from '../assets/logoLinkedTrackIcon.png';
|
import logoIcon from '../assets/logoLinkedTrackIcon.png';
|
||||||
import { getDevicesAccess } from '../services/devices.js';
|
import { getDevicesAccess } from '../services/devices.js';
|
||||||
import pkg from '../../package.json';
|
import pkg from '../../package.json';
|
||||||
|
import { hasModule } from '../utils/modules.js';
|
||||||
|
|
||||||
const ITEMS = [
|
const ITEMS = [
|
||||||
{ to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true },
|
{ to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true },
|
||||||
{ to: '/proyectos', icon: FolderKanban, labelKey: 'nav_projects' },
|
{ to: '/proyectos', module: 'projects', icon: FolderKanban, labelKey: 'nav_projects' },
|
||||||
{ to: '/tareas-recurrentes', icon: Repeat, labelKey: 'nav_recurring_tasks' },
|
{ to: '/tareas-recurrentes', module: 'recurring_tasks', icon: Repeat, labelKey: 'nav_recurring_tasks' },
|
||||||
{ to: '/notas', icon: StickyNote, labelKey: 'nav_notes' },
|
{ to: '/notas', module: 'notes', icon: StickyNote, labelKey: 'nav_notes' },
|
||||||
{ to: '/contrasenas', icon: KeyRound, labelKey: 'nav_passwords' },
|
{ to: '/contrasenas', module: 'vault', icon: KeyRound, labelKey: 'nav_passwords' },
|
||||||
{ to: '/equipos', icon: Monitor, labelKey: 'nav_devices', needsDevicesAccess: true },
|
{ to: '/equipos', module: 'devices', icon: Monitor, labelKey: 'nav_devices', needsDevicesAccess: true },
|
||||||
{ to: '/organizacion', icon: Building2, labelKey: 'nav_organization' },
|
{ to: '/organizacion', module: 'organization', icon: Building2, labelKey: 'nav_organization' },
|
||||||
{ to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true },
|
{ to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ export function NavigationView({ mode, mobileOpen, onNavigate }) {
|
|||||||
if (!user) return;
|
if (!user) return;
|
||||||
getDevicesAccess().then((a) => setCanSeeDevices(!!a?.canView)).catch(() => setCanSeeDevices(false));
|
getDevicesAccess().then((a) => setCanSeeDevices(!!a?.canView)).catch(() => setCanSeeDevices(false));
|
||||||
}, [user?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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');
|
const brandName = branding.name || t('appName');
|
||||||
// Menus con borradores pendientes (el borrador guarda la ruta donde se estaba editando).
|
// Menus con borradores pendientes (el borrador guarda la ruta donde se estaba editando).
|
||||||
const drafts = useUserDrafts();
|
const drafts = useUserDrafts();
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ export const messages = {
|
|||||||
'es-EC': {
|
'es-EC': {
|
||||||
appName: 'LinkedCorp Proyectos',
|
appName: 'LinkedCorp Proyectos',
|
||||||
nav_dashboard: 'Panel',
|
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_projects: 'Proyectos',
|
||||||
nav_organization: 'Organizacion',
|
nav_organization: 'Organizacion',
|
||||||
nav_config: 'Configuracion',
|
nav_config: 'Configuracion',
|
||||||
@@ -521,6 +524,9 @@ export const messages = {
|
|||||||
'en-US': {
|
'en-US': {
|
||||||
appName: 'LinkedCorp Projects',
|
appName: 'LinkedCorp Projects',
|
||||||
nav_dashboard: 'Dashboard',
|
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_projects: 'Projects',
|
||||||
nav_organization: 'Organization',
|
nav_organization: 'Organization',
|
||||||
nav_config: 'Settings',
|
nav_config: 'Settings',
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<DetailPopup open={!!user} title={`${t('user_modules')} — ${user?.name || ''}`} onClose={onClose} width={420} height={420}>
|
||||||
|
<p className="text-muted" style={{ marginTop: 0 }}>{t('user_modules_hint')}</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 16 }}>
|
||||||
|
{all.map((m) => (
|
||||||
|
<label key={m} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input type="checkbox" checked={selected.includes(m)} onChange={() => toggle(m)} />
|
||||||
|
{t(MODULE_LABEL_KEYS[m])}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button variant="accent" icon={Save} onClick={save} disabled={saving || !dirty}>{t('common_save')}</Button>
|
||||||
|
<Button variant="standard" onClick={onClose}>{t('common_cancel')}</Button>
|
||||||
|
</div>
|
||||||
|
</DetailPopup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
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 { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAuth } from '../../context/auth-context.jsx';
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
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 { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
import { Badge } from '../../components/Badge.jsx';
|
import { Badge } from '../../components/Badge.jsx';
|
||||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { UserModulesDialog } from './UserModulesDialog.jsx';
|
||||||
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||||
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
|
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||||
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
|
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
|
||||||
@@ -34,6 +35,8 @@ export default function UsersPanel() {
|
|||||||
const { data: deptTree } = useAsync(listDepartments, []);
|
const { data: deptTree } = useAsync(listDepartments, []);
|
||||||
|
|
||||||
const canManage = ['admin', 'superadmin'].includes(currentUser?.roleCode);
|
const canManage = ['admin', 'superadmin'].includes(currentUser?.roleCode);
|
||||||
|
const isSuperadmin = currentUser?.roleCode === 'superadmin';
|
||||||
|
const [modulesUser, setModulesUser] = useState(null);
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
@@ -179,6 +182,7 @@ export default function UsersPanel() {
|
|||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ label: t('common_edit'), onClick: () => startEdit(u) },
|
{ label: t('common_edit'), onClick: () => startEdit(u) },
|
||||||
|
...(isSuperadmin && u.role_code !== 'superadmin' ? [{ label: t('user_modules'), icon: LayoutGrid, onClick: () => setModulesUser(u) }] : []),
|
||||||
u.is_active
|
u.is_active
|
||||||
? { label: t('common_inactive'), icon: UserX, danger: true, onClick: () => toggleActive(u) }
|
? { label: t('common_inactive'), icon: UserX, danger: true, onClick: () => toggleActive(u) }
|
||||||
: { label: t('common_active'), icon: UserCheck, onClick: () => toggleActive(u) },
|
: { label: t('common_active'), icon: UserCheck, onClick: () => toggleActive(u) },
|
||||||
@@ -186,6 +190,8 @@ export default function UsersPanel() {
|
|||||||
} : undefined}
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{modulesUser && <UserModulesDialog user={modulesUser} onClose={() => setModulesUser(null)} />}
|
||||||
|
|
||||||
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={560} height={500}>
|
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={560} height={500}>
|
||||||
<DraftNotice draft={draft} />
|
<DraftNotice draft={draft} />
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
|
|||||||
@@ -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 approveUser = (id, data) => api.post(`/users/${id}/approve`, data);
|
||||||
export const rejectUser = (id) => api.post(`/users/${id}/reject`);
|
export const rejectUser = (id) => api.post(`/users/${id}/reject`);
|
||||||
export const resendUserEmail = (id) => api.post(`/users/${id}/resend`);
|
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 });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user