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
+2 -1
View File
@@ -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`.
+4
View File
@@ -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`
+1
View File
@@ -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) |
+12
View File
@@ -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
+6 -1
View File
@@ -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 `<input type="date">` nativo (simplificacion deliberada del MVP).
+2 -1
View File
@@ -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).
+1 -1
View File
@@ -1 +1 @@
3.0.1
3.1.0
@@ -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);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "3.0.1",
"version": "3.1.0",
"type": "module",
"scripts": {
"dev": "vite",
+8 -8
View File
@@ -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(
<Route path="/aceptar-invitacion" element={<AcceptInvitePage />} />
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
<Route index element={<DashboardPage />} />
<Route path="proyectos" element={<ProyectosPage />} />
<Route path="proyectos/:id" element={<ProyectoDetallePage />} />
<Route path="tareas-recurrentes" element={<RecurringTasksPage />} />
<Route path="notas" element={<NotasPage />} />
<Route path="contrasenas" element={<ContrasenasPage />} />
<Route path="equipos" element={<EquiposPage />} />
<Route path="organizacion" element={<OrganizacionPage />} />
<Route path="proyectos" element={<ModuleRoute module="projects"><ProyectosPage /></ModuleRoute>} />
<Route path="proyectos/:id" element={<ModuleRoute module="projects"><ProyectoDetallePage /></ModuleRoute>} />
<Route path="tareas-recurrentes" element={<ModuleRoute module="recurring_tasks"><RecurringTasksPage /></ModuleRoute>} />
<Route path="notas" element={<ModuleRoute module="notes"><NotasPage /></ModuleRoute>} />
<Route path="contrasenas" element={<ModuleRoute module="vault"><ContrasenasPage /></ModuleRoute>} />
<Route path="equipos" element={<ModuleRoute module="devices"><EquiposPage /></ModuleRoute>} />
<Route path="organizacion" element={<ModuleRoute module="organization"><OrganizacionPage /></ModuleRoute>} />
<Route path="configuracion" element={<ConfiguracionPage />} />
</Route>
</>
+8
View File
@@ -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 <Navigate to="/login" replace state={{ from: `${location.pathname}${location.search}` }} />;
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;
}
+8 -7
View File
@@ -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();
+6
View File
@@ -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',
@@ -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 { 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 && <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}>
<DraftNotice draft={draft} />
<FieldRow>
+2
View File
@@ -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 });
+13
View File
@@ -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);
}