diff --git a/AGENTS.md b/AGENTS.md index aab4058..29107f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,7 +113,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador): (se borra). Solo el usuario más antiguo del sistema es `superadmin` automático (bootstrap, sin correo ni aprobación — no hay quién lo apruebe). El admin sigue pudiendo crear usuarios directamente (ya activos, sin pasar por este flujo) desde Organización → Usuarios. -- `401` → el frontend limpia sesión, redirige a login y muestra aviso (ContentDialog). +- `401` o JWT vencido (detectado también sin peticiones y al volver a la pestaña) → el frontend corta la sesión, va a login con aviso y, tras entrar el mismo usuario, vuelve a la ruta/pestaña/edición donde estaba (ver `docs/SESION-Y-BORRADORES.md`). - Cierre de sesión por expiración de JWT. - Auditoría en BD (`audit_log`): usuario, acción, entidad, fecha/hora, detalle. - Credenciales/secretos solo en `.env` (nunca en código); `.env.example` sí se versiona. Excepción: @@ -164,6 +164,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador): - `README.md`, `SPEC.md`, `BASE_DE_DATOS.md`, `CONFIGURACION.md`, `API.md`, `VERSIONADO.md` - `PRODUCCION.md`, `DEPLOY_WEB.md` (topología y flujo de deploy — arquetipo B, en producción) - `docs/UI-UX-WINDOWS.md`, `docs/SEGURIDAD-Y-CALIDAD.md`, `docs/ARQUITECTURA-INVENTARIO.md` +- `docs/SESION-Y-BORRADORES.md` (vencimiento de sesión, retorno al contexto, borradores y avisos al salir; usar `useDraftForm` en todo formulario nuevo) - `ESTADO.md` (handoff — leer siempre al retomar el proyecto) ## Pendientes para quedar listo diff --git a/API.md b/API.md index 9f3f6de..e605a44 100644 --- a/API.md +++ b/API.md @@ -9,6 +9,8 @@ - Tipo: Bearer JWT (`Authorization: Bearer `), emitido por `/api/auth/login`. - El backend revalida `is_active` del usuario en cada request (no solo la firma del JWT). +- El JWT incluye `sv` (versión de sesión). Cambiar o restablecer la clave revoca los JWT + anteriores; se responde `401` y el usuario vuelve a iniciar sesión. ## Formato de respuesta @@ -20,6 +22,14 @@ } ``` +## Edición concurrente + +Los `PUT` de proyectos, comentarios, actividades, tareas, departamentos, usuarios, los cuatro +catálogos, notas, tareas recurrentes, equipos y SMTP exigen `editVersion` (entero positivo) +obtenido de la lectura correspondiente como `edit_version`. El `PUT /api/company` exige el mismo +campo en multipart. La API responde `428` si falta o es inválido y `409` si otro guardado cambió +el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`. + ## Endpoints principales ### Auth @@ -45,16 +55,19 @@ ### Usuarios (`/api/users`) — lectura para cualquier autenticado; escritura solo Administrador/Superadministrador - `GET /api/users` · `POST /api/users` · `PUT /api/users/:id` · `PATCH /api/users/:id/active` -- `POST /api/users/:id/approve` — `{ roleId?, departmentId? }`, solo sobre usuarios `pending_approval` +- `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) ### Departamentos (`/api/departments`) - `GET /api/departments` (árbol) · `POST /api/departments` · `PUT /api/departments/:id` · `DELETE /api/departments/:id` ### Proyectos (`/api/projects`) -- `GET /api/projects` (filtros: estado, responsable, prioridad; `trash=true` = papelera, solo - superadmin) · `POST /api/projects` -- `GET /api/projects/:id` · `PUT /api/projects/:id` +- `GET /api/projects` (filtros: `statusId`, `priorityId`, `ownerUserId`, `dueSoon=true`, + `progress=hide|only`, `q`, `trash=true` solo superadmin; `page` desde 1 y `pageSize` 1–100, + predeterminado 20) → `{ items, total, page, pageSize }` · `POST /api/projects`. +- `GET /api/projects/options` → `{ id, code, name }[]` accesibles al usuario para selectores. +- `GET /api/projects/:id` · `PUT /api/projects/:id` — para editar, enviar `editVersion` obtenido + de `GET`; `428` sin versión, `409` si otra edición la cambió. - `DELETE /api/projects/:id` — eliminación lógica (`deleted_at`/`deleted_by`), mismo permiso que editar el proyecto. `POST /api/projects/:id/restore` y `DELETE /api/projects/:id/permanent` (borrado físico, irreversible, arrastra actividades/comentarios/adjuntos) — ambos solo @@ -107,21 +120,24 @@ (`last_reminder_sent_at`, se limpia al completar el ciclo). ### Comentarios (`/api/comments`) + +- `PUT /api/comments/:id` — `{ message, editVersion }`; `428` sin versión y `409` si cambió. - `GET /api/comments?entityType=project&entityId=1` · `POST /api/comments` ### Adjuntos (`/api/attachments`) -- `GET /api/attachments?entityType=activity&entityId=1` · `POST /api/attachments` (multipart) +- `GET /api/attachments?entityType=activity&entityId=1` · `POST /api/attachments?entityType=activity&entityId=1` + (multipart; entidad y permiso se validan antes de recibir el archivo) - `GET /api/attachments/:id/download` · `DELETE /api/attachments/:id` ### Notas (`/api/notes`) - `GET /api/notes` — solo las del usuario autenticado, ordenadas por `updated_at` descendente. -- `POST /api/notes` — `{ content }`. `PUT /api/notes/:id` — `{ content }`. `DELETE /api/notes/:id`. +- `POST /api/notes` — `{ content }`. `PUT /api/notes/:id` — `{ content, editVersion }`. `DELETE /api/notes/:id`. - Siempre privadas: una nota de otro usuario responde `404` (no `403`, para no revelar que existe). Sin restricción de rol — cualquier autenticado tiene las suyas. ### Equipos (`/api/devices`) — superadmin / admin / it_manager (tokens: solo superadmin / admin) - `GET /api/devices?q=&kind=&status=&stale=1&diskFull=1` · `GET /api/devices/:id` (con hardware, - red, software e historial) · `PUT /api/devices/:id` — `{ status, assignedUserId, departmentId, notes }`. + red, software e historial) · `PUT /api/devices/:id` — `{ status, assignedUserId, departmentId, notes, editVersion }`. - `GET /api/devices/dashboard` — KPIs y top (SO, tipo, software). `GET /api/devices/software` · `GET /api/devices/software/devices?name=` — software transversal. - `GET /api/devices/access` — `{canView, canEdit, canManage}` del usuario (lo usa el menú). Lectura (`GET` de @@ -184,8 +200,8 @@ - `GET /api/audit?entityType=project&entityId=1` ### Configuración SMTP (`/api/settings`) — solo Superadministrador -- `GET /api/settings/smtp` — `{ host, port, user, from, configured }` (nunca la clave) -- `PUT /api/settings/smtp` — `{ host, port, user, from, pass? }` (`pass` vacío = no cambiarla) +- `GET /api/settings/smtp` — `{ host, port, user, from, configured, edit_version }` (nunca la clave) +- `PUT /api/settings/smtp` — `{ host, port, user, from, pass?, editVersion }` (`pass` vacío = no cambiarla) - `POST /api/settings/smtp/test` — `{ host, port, user, from, pass?, to }` (`pass` vacío = usa la guardada). Envía un correo real con los valores del formulario (aunque no se hayan guardado) y responde `{ sent: true }` o `400` con el error real del servidor SMTP. Rate-limited (10/15min). @@ -195,8 +211,8 @@ logoVersion }`; `name` por defecto "LinkedCorp" si nunca se configuró. - `GET /api/company/logo` — público. Devuelve el archivo de imagen del logo, o `404` si no hay ninguno configurado (el cliente cae al logo estático embebido). -- `PUT /api/company` — solo Superadministrador, multipart: `name?` (texto) y `logo?` (archivo, - PNG/JPG/WEBP/SVG, máx 3MB). Al subir un logo nuevo se borra el archivo anterior (nunca quedan +- `PUT /api/company` — solo Superadministrador, multipart: `name?` (texto), `editVersion` y `logo?` (archivo, + PNG/JPG/WEBP, máx 3MB; se comprueba la firma real del archivo). Al subir un logo nuevo se borra el archivo anterior (nunca quedan huérfanos). Guardado en `system_settings` (mismo patrón que SMTP), sin tabla nueva. ## Errores esperados diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 28951c3..dda1276 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -70,6 +70,13 @@ editables directo por el cliente: actividad con tareas = % de tareas hechas; actividad sin tareas = manual (0-100); proyecto = promedio del avance de sus actividades. - `users.password_hash` con bcrypt, nunca texto plano ni reversible. +- `users.session_version` (migración `028`) comienza en 1 y aumenta al cambiar/restablecer clave; + cada JWT de sesión debe tener la misma versión. Aplicar la migración antes de iniciar el backend nuevo. +- Migración `029`: `edit_version` comienza en 1 en proyectos, comentarios, actividades, tareas, + departamentos, usuarios, catálogos, notas, tareas recurrentes y equipos. El `UPDATE` de edición + verifica la versión y la incrementa atómicamente; sin coincidencia responde 409. En + `system_settings`, las claves `company_edit_version` y `smtp_edit_version` serializan los + guardados de cada panel dentro de una transacción. Aplicar 029 antes de iniciar el backend nuevo. - `users.status` (`pending_verification`/`pending_approval`/`active`/`invited`) es el ciclo de vida de la cuenta, independiente de `is_active` (que sigue siendo el interruptor manual del admin para desactivar a alguien ya activo). Un usuario `pending_approval` rechazado se **borra** diff --git a/CHANGELOG.md b/CHANGELOG.md index 4717bbf..45e4f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog - project.linkedcorp.net +## [3.0.0] - 2026-10-23 +### Security +- Se restringe la asignación de superadmin, la escritura de consultores y la búsqueda/tareas + recurrentes por alcance. El primer registro es atómico. Cambio/restablecimiento de clave + revoca JWT previos (migración 028). +- Logo público solo PNG/JPG/WEBP con firma comprobada y CSP; se censura `?token=` en logs de la + aplicación; los adjuntos comprueban permisos antes de recibir archivos y limpian fallos. +- La ingesta valida token antes del cuerpo, limita frecuencia/concurrencia y parsea en worker. + El vínculo token-equipo queda pendiente para no romper agentes con token compartido. +### Changed +- `GET /api/projects` devuelve páginas; frontend y selectores adaptados. La edición de proyectos + y comentarios exige versión optimista (migración 029), con rechazo 409 al competir. +- El control optimista se extiende a actividades, tareas, departamentos, usuarios, catálogos, + notas, tareas recurrentes, equipos, empresa y SMTP. Los formularios conservan la versión + del borrador y muestran el aviso de conflicto al recibir 409. +- Correos HTML escapados y recordatorios reintentables tras fallo SMTP; borradores limitados a + campos permitidos por formulario y limpiados al cerrar sesión voluntariamente. +- Diálogos, controles de lista/combo/expansor, pestañas e i18n mejorados para teclado y lectores + de pantalla. +### Deployment +- Aplicar migraciones 028 y 029 antes del backend de esta versión. Los JWT existentes caducarán; + clientes externos de proyectos/adjuntos deben adoptar los contratos descritos en `API.md`. + ## [2.19.0] - 2026-10-21 ### Added - Panel: selector **Tarjetas (n/7)** junto al título, con una casilla por tarjeta para diff --git a/ESTADO.md b/ESTADO.md index 359584c..301e948 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -1,9 +1,28 @@ # ESTADO — project.linkedcorp.net -Ultima actualizacion: 2026-10-19 (v2.15.0 en desarrollo, sin desplegar) +Ultima actualizacion: 2026-10-23 (v3.0.0 en desarrollo, sin desplegar) Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo + autorregistro/aprobacion/SMTP. +## Auditoría de seguridad y calidad (v3.0.0, sin desplegar) + +- Se corrigieron controles de roles, alcance de búsqueda y tareas recurrentes, bootstrap + concurrente, subida de logo/adjuntos, censura de tokens en logs de la app, revocación JWT, + límites de ingesta, correo, paginación y accesibilidad base. +- Migraciones nuevas `028_session_version.sql` y `029_optimistic_versions.sql`: **no aplicadas**. + El backend nuevo requiere ambas antes de arrancar con una base existente. Tras `028`, los JWT + anteriores dejan de funcionar y se debe iniciar sesión otra vez. +- `GET /api/projects` ahora devuelve `{items,total,page,pageSize}`. El frontend de esta versión + se adaptó; otros clientes deben hacerlo. `POST /api/attachments` requiere entidad en query. +- Pendiente: vinculación token de inventario a equipo (instaladores actuales comparten token), + medición SQL `EXPLAIN` con datos reales. Hallazgo 7: falta verificar los logs del proxy NPM + para confirmar la censura de tokens; pendiente operativo, sin tocar NPM. Hallazgo 11: + vincular token de inventario a equipo requiere migrar las instalaciones que comparten token; + pendiente operativo. No se consultó la BD ni producción durante esta tarea. +- Hallazgo 16 completo en código: edición con versión optimista para proyectos, comentarios, + actividades, tareas, departamentos, usuarios, los cuatro catálogos, notas, tareas recurrentes, + equipos, empresa y SMTP. La migración 029 se amplió; sigue **sin aplicar**. + ## Como retomar 1. Leer primero el orquestador: `/home/mgonzalez/desarrollo/project-md-templates/leeme_primero.txt`. diff --git a/VERSION b/VERSION index ef0f38a..4a36342 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.19.0 +3.0.0 diff --git a/backend/database/migrations/028_session_version.sql b/backend/database/migrations/028_session_version.sql new file mode 100644 index 0000000..e8b0c01 --- /dev/null +++ b/backend/database/migrations/028_session_version.sql @@ -0,0 +1,2 @@ +-- Una version por cuenta invalida todos los JWT anteriores al cambiar la clave. +ALTER TABLE users ADD COLUMN IF NOT EXISTS session_version INT UNSIGNED NOT NULL DEFAULT 1; diff --git a/backend/database/migrations/029_optimistic_versions.sql b/backend/database/migrations/029_optimistic_versions.sql new file mode 100644 index 0000000..d29d062 --- /dev/null +++ b/backend/database/migrations/029_optimistic_versions.sql @@ -0,0 +1,14 @@ +ALTER TABLE projects ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1; +ALTER TABLE comments ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1; +ALTER TABLE activities ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1; +ALTER TABLE departments ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1; +ALTER TABLE project_types ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1; +ALTER TABLE priorities ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1; +ALTER TABLE project_statuses ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1; +ALTER TABLE activity_statuses ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1; +ALTER TABLE notes ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1; +ALTER TABLE recurring_tasks ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1; +ALTER TABLE devices ADD COLUMN IF NOT EXISTS edit_version INT UNSIGNED NOT NULL DEFAULT 1; +INSERT INTO system_settings (`key`, `value`) VALUES ('company_edit_version', '1'), ('smtp_edit_version', '1') ON DUPLICATE KEY UPDATE `value` = `value`; diff --git a/backend/package-lock.json b/backend/package-lock.json index 831cd3a..06aa3e0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "project-linkedcorp-backend", - "version": "2.14.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "project-linkedcorp-backend", - "version": "2.14.0", + "version": "3.0.0", "dependencies": { "bcryptjs": "^2.4.3", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 15ffb30..ae02a25 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "2.19.0", + "version": "3.0.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/app.js b/backend/src/app.js index 2f9819f..347998e 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -34,7 +34,11 @@ export const app = express(); // de uno por usuario) y ademas tira un ValidationError que ensucia los logs en cada request. app.set('trust proxy', 1); -app.use(helmet({ contentSecurityPolicy: false })); +app.use(helmet({ contentSecurityPolicy: { directives: { + defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], + fontSrc: ["'self'", 'https://fonts.gstatic.com'], imgSrc: ["'self'", 'data:', 'blob:'], + connectSrc: ["'self'"], objectSrc: ["'none'"], baseUri: ["'self'"], frameAncestors: ["'none'"], +} } })); app.use(cors({ origin: env.corsOrigin, credentials: true, exposedHeaders: ['X-Vault-Token'] })); // Ingesta del agente GLPI: ANTES del parser JSON (usa cuerpo crudo, sin CORS: la llama el agente). app.use(inventoryIngestRoutes); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index fc820ce..f9bc6c8 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -17,13 +17,13 @@ export async function requireAuth(req, res, next) { // Revalidar en cada request que la cuenta siga activa (un JWT firmado no basta). const [rows] = await pool.query( - `SELECT u.id, u.name, u.email, u.is_active, u.department_id, r.code AS role_code + `SELECT u.id, u.name, u.email, u.is_active, u.session_version, u.department_id, r.code AS role_code FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ? LIMIT 1`, [payload.sub] ); const user = rows[0]; - if (!user || !user.is_active) return fail(res, 401, 'Cuenta inactiva o inexistente'); + if (!user || !user.is_active || payload.sv !== user.session_version) return fail(res, 401, 'Sesion invalida o revocada'); req.currentUser = { id: user.id, @@ -59,3 +59,5 @@ export const CAN_MANAGE_USERS = [ROLES.SUPERADMIN, ROLES.ADMIN]; export const CAN_MANAGE_SMTP = [ROLES.SUPERADMIN]; export const CAN_MANAGE_SETTINGS = [ROLES.SUPERADMIN]; export const CAN_WRITE = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD, ROLES.COLLABORATOR]; +export const CAN_EDIT_DEVICES = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER]; +export const CAN_MANAGE_DEVICES = [ROLES.SUPERADMIN, ROLES.ADMIN]; diff --git a/backend/src/middleware/uploadLogo.js b/backend/src/middleware/uploadLogo.js index 3258490..cbaa365 100644 --- a/backend/src/middleware/uploadLogo.js +++ b/backend/src/middleware/uploadLogo.js @@ -4,7 +4,7 @@ import crypto from 'node:crypto'; import multer from 'multer'; import { env } from '../config/env.js'; -const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']); +const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp']); export const logoDir = path.join(env.storageRoot, 'branding'); fs.mkdirSync(logoDir, { recursive: true }); @@ -24,7 +24,7 @@ export const uploadLogo = multer({ limits: { fileSize: 3 * 1024 * 1024 }, fileFilter: (req, file, cb) => { if (!ALLOWED_MIME.has(file.mimetype)) { - return cb(new Error('Tipo de archivo no permitido (usa PNG, JPG, WEBP o SVG)')); + return cb(new Error('Tipo de archivo no permitido (usa PNG, JPG o WEBP)')); } cb(null, true); }, diff --git a/backend/src/modules/activities/repository.js b/backend/src/modules/activities/repository.js index 899af0b..c39a6b6 100644 --- a/backend/src/modules/activities/repository.js +++ b/backend/src/modules/activities/repository.js @@ -5,7 +5,7 @@ const LIST_SELECT = ` a.priority_id, pr.name AS priority_name, pr.color AS priority_color, a.status_id, st.name AS status_name, st.color AS status_color, st.is_closed AS status_is_closed, a.start_date, a.due_date, a.actual_end_date, a.progress_pct, a.is_milestone, - a.estimated_hours, a.sort_order, a.created_by, a.created_at, a.updated_at, + a.estimated_hours, a.edit_version, a.sort_order, a.created_by, a.created_at, a.updated_at, a.deleted_at, a.deleted_by, (SELECT COUNT(*) FROM activity_dependencies d WHERE d.activity_id = a.id) AS dependencies_count, (SELECT COUNT(*) FROM comments c WHERE c.entity_type = 'activity' AND c.entity_id = a.id) AS comments_count, @@ -62,18 +62,18 @@ export async function create(projectId, data) { } export async function update(id, data) { - await pool.query( + const [result] = await pool.query( `UPDATE activities SET name = ?, description = ?, responsible_user_id = ?, priority_id = ?, status_id = ?, - start_date = ?, due_date = ?, actual_end_date = ?, is_milestone = ?, estimated_hours = ? - WHERE id = ?`, + start_date = ?, due_date = ?, actual_end_date = ?, is_milestone = ?, estimated_hours = ?, edit_version = edit_version + 1 + WHERE id = ? AND edit_version = ? AND deleted_at IS NULL`, [ data.name, data.description || null, data.responsibleUserId || null, data.priorityId, data.statusId, data.startDate || null, data.dueDate || null, data.actualEndDate || null, - data.isMilestone ? 1 : 0, data.estimatedHours || null, id, + data.isMilestone ? 1 : 0, data.estimatedHours || null, id, data.editVersion, ] ); - return findById(id); + return result.affectedRows ? findById(id) : null; } export async function updateProgress(id, progressPct) { @@ -81,11 +81,11 @@ export async function updateProgress(id, progressPct) { } export async function softDelete(id, userId) { - await pool.query('UPDATE activities SET deleted_at = NOW(), deleted_by = ? WHERE id = ?', [userId, id]); + await pool.query('UPDATE activities SET deleted_at = NOW(), deleted_by = ?, edit_version = edit_version + 1 WHERE id = ?', [userId, id]); } export async function restore(id) { - await pool.query('UPDATE activities SET deleted_at = NULL, deleted_by = NULL WHERE id = ?', [id]); + await pool.query('UPDATE activities SET deleted_at = NULL, deleted_by = NULL, edit_version = edit_version + 1 WHERE id = ?', [id]); } // Las tareas (tasks.activity_id) y los comentarios/adjuntos (entity_id de la actividad) se @@ -94,7 +94,7 @@ export async function restore(id) { // Las dependencias solo tienen sentido dentro del mismo proyecto (addDependency ya lo exige), asi // que las existentes (en cualquiera de los dos sentidos) quedan invalidas al cambiar de proyecto. export async function moveToProject(id, projectId) { - await pool.query('UPDATE activities SET project_id = ?, sort_order = 0 WHERE id = ?', [projectId, id]); + await pool.query('UPDATE activities SET project_id = ?, sort_order = 0, edit_version = edit_version + 1 WHERE id = ?', [projectId, id]); await pool.query('DELETE FROM activity_dependencies WHERE activity_id = ? OR depends_on_activity_id = ?', [id, id]); return findById(id); } diff --git a/backend/src/modules/activities/service.js b/backend/src/modules/activities/service.js index 3407e78..3048c4a 100644 --- a/backend/src/modules/activities/service.js +++ b/backend/src/modules/activities/service.js @@ -5,6 +5,7 @@ import * as attachmentsRepo from '../attachments/repository.js'; import * as tasksRepo from '../tasks/repository.js'; import { recalcProjectProgress, canReadProject, canWriteProject } from '../projects/service.js'; import { HttpError } from '../../utils/http.js'; +import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js'; import { logAudit } from '../../utils/audit.js'; import { ROLES } from '../../middleware/auth.js'; import { createNotification } from '../notifications/service.js'; @@ -84,8 +85,9 @@ export async function updateActivity(id, payload, currentUser) { ? activity.responsible_user_id : (payload.responsibleUserId ?? activity.responsible_user_id); + requireEditVersion(payload.editVersion); const taskCount = await repo.countTasks(id); - const updated = await repo.update(id, { ...payload, responsibleUserId: nextResponsible }); + const updated = requireUpdated(await repo.update(id, { ...payload, responsibleUserId: nextResponsible })); if (taskCount === 0 && payload.progressPct !== undefined) { const pct = Math.max(0, Math.min(100, Number(payload.progressPct))); diff --git a/backend/src/modules/attachments/controller.js b/backend/src/modules/attachments/controller.js index 1ce5ea3..bd9d165 100644 --- a/backend/src/modules/attachments/controller.js +++ b/backend/src/modules/attachments/controller.js @@ -24,21 +24,36 @@ export const getAttachments = asyncHandler(async (req, res) => { ok(res, await repo.findByEntity(entityType, Number(entityId))); }); +export const preflightAttachment = asyncHandler(async (req, res, next) => { + const { entityType, entityId } = req.query; + if (!VALID_ENTITY_TYPES.has(entityType) || !Number.isSafeInteger(Number(entityId)) || Number(entityId) < 1) throw new HttpError(400, 'entityType y entityId son obligatorios'); + if (req.currentUser.roleCode === ROLES.VIEWER) throw new HttpError(403, 'Tu rol es de solo lectura'); + const project = await getProjectOr404(entityType, Number(entityId)); + if (!(await canWriteProject(req.currentUser, project))) throw new HttpError(404, 'No encontrado'); + next(); +}); + export const postAttachment = asyncHandler(async (req, res) => { - const { entityType, entityId } = req.body || {}; + const { entityType, entityId } = req.query; + let attachment; + try { if (!VALID_ENTITY_TYPES.has(entityType) || !entityId) throw new HttpError(400, 'entityType y entityId son obligatorios'); if (req.currentUser.roleCode === ROLES.VIEWER) throw new HttpError(403, 'Tu rol es de solo lectura'); const project = await getProjectOr404(entityType, Number(entityId)); if (!(await canWriteProject(req.currentUser, project))) throw new HttpError(403, 'No tienes permisos de escritura en este proyecto'); if (!req.file) throw new HttpError(400, 'Archivo obligatorio'); - const attachment = await repo.create({ + attachment = await repo.create({ entityType, entityId: Number(entityId), filename: req.file.filename, originalName: req.file.originalname, mimeType: req.file.mimetype, size: req.file.size, uploadedBy: req.currentUser.id, }); await logAudit({ userId: req.currentUser.id, action: 'upload', entityType, entityId: Number(entityId), detail: { attachmentId: attachment.id } }); ok(res, attachment, 201); + } catch (err) { + if (req.file && !attachment) await fs.promises.unlink(req.file.path).catch(() => {}); + throw err; + } }); export const downloadAttachment = asyncHandler(async (req, res) => { diff --git a/backend/src/modules/attachments/routes.js b/backend/src/modules/attachments/routes.js index 2b103db..cc8a756 100644 --- a/backend/src/modules/attachments/routes.js +++ b/backend/src/modules/attachments/routes.js @@ -1,13 +1,13 @@ import { Router } from 'express'; import { requireAuth } from '../../middleware/auth.js'; import { upload } from '../../middleware/upload.js'; -import { getAttachments, postAttachment, downloadAttachment, deleteAttachment } from './controller.js'; +import { getAttachments, postAttachment, preflightAttachment, downloadAttachment, deleteAttachment } from './controller.js'; const router = Router(); router.use(requireAuth); router.get('/', getAttachments); -router.post('/', upload.single('file'), postAttachment); +router.post('/', preflightAttachment, upload.single('file'), postAttachment); router.get('/:id/download', downloadAttachment); router.delete('/:id', deleteAttachment); diff --git a/backend/src/modules/auth/service.js b/backend/src/modules/auth/service.js index 3dd2b26..accaf68 100644 --- a/backend/src/modules/auth/service.js +++ b/backend/src/modules/auth/service.js @@ -7,6 +7,7 @@ import { HttpError } from '../../utils/http.js'; import { logAudit } from '../../utils/audit.js'; import { sendMail } from '../../utils/mailer.js'; import { createNotification } from '../notifications/service.js'; +import { verificationEmail, recoveryEmail } from '../../utils/emailHtml.js'; const STATUS_MESSAGES = { pending_verification: 'Debes confirmar tu correo antes de iniciar sesion. Revisa tu bandeja de entrada.', @@ -20,7 +21,7 @@ function newToken() { export async function login(email, password) { const [rows] = await pool.query( - `SELECT u.id, u.name, u.email, u.password_hash, u.is_active, u.status, u.department_id, + `SELECT u.id, u.name, u.email, u.password_hash, u.is_active, u.status, u.department_id, u.session_version, r.code AS role_code, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.email = ? LIMIT 1`, @@ -35,7 +36,7 @@ export async function login(email, password) { if (STATUS_MESSAGES[user.status]) throw new HttpError(403, STATUS_MESSAGES[user.status]); if (!user.is_active) throw new HttpError(403, 'Cuenta inactiva. Contacta a un administrador.'); - const token = jwt.sign({ sub: user.id }, env.jwtSecret, { expiresIn: env.jwtExpires }); + const token = jwt.sign({ sub: user.id, sv: user.session_version }, env.jwtSecret, { expiresIn: env.jwtExpires }); await logAudit({ userId: user.id, action: 'login', entityType: 'auth', entityId: user.id }); @@ -59,40 +60,43 @@ export async function register({ name, email, password }) { const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]); if (existing.length) throw new HttpError(400, 'Ya existe una cuenta con ese correo'); - const [[{ total }]] = await pool.query('SELECT COUNT(*) AS total FROM users'); const passwordHash = await bcrypt.hash(password, 12); - - if (total === 0) { - // Primer usuario del sistema: superadmin, sin correo ni aprobacion (no hay quien lo apruebe). - const [superadminRole] = await pool.query("SELECT id FROM roles WHERE code = 'superadmin'"); - const [result] = await pool.query( - `INSERT INTO users (name, email, password_hash, role_id, is_active, status, email_verified_at) - VALUES (?, ?, ?, ?, 1, 'active', NOW())`, - [name, email, passwordHash, superadminRole[0].id] + const connection = await pool.getConnection(); + let bootstrap; + let result; + let verificationToken; + try { + await connection.beginTransaction(); + // El bloqueo de la fila de roles serializa incluso los registros con correos distintos. + const [roles] = await connection.query("SELECT id, code FROM roles WHERE code IN ('superadmin', 'collaborator') FOR UPDATE"); + const [[{ total }]] = await connection.query('SELECT COUNT(*) AS total FROM users'); + bootstrap = total === 0; + const roleId = roles.find((role) => role.code === (bootstrap ? 'superadmin' : 'collaborator'))?.id; + if (!roleId) throw new Error('Rol de registro no configurado'); + verificationToken = bootstrap ? null : newToken(); + [result] = await connection.query( + `INSERT INTO users (name, email, password_hash, role_id, is_active, status, email_verified_at, verification_token, verification_token_expires) + VALUES (?, ?, ?, ?, ?, ?, ${bootstrap ? 'NOW()' : 'NULL'}, ?, ${bootstrap ? 'NULL' : 'DATE_ADD(NOW(), INTERVAL 24 HOUR)'})`, + [name, email, passwordHash, roleId, bootstrap ? 1 : 0, bootstrap ? 'active' : 'pending_verification', verificationToken] ); + await connection.commit(); + } catch (err) { + await connection.rollback(); + if (err.code === 'ER_DUP_ENTRY') throw new HttpError(400, 'Ya existe una cuenta con ese correo'); + throw err; + } finally { connection.release(); } + if (bootstrap) { await logAudit({ userId: result.insertId, action: 'register_bootstrap_superadmin', entityType: 'user', entityId: result.insertId }); - const token = jwt.sign({ sub: result.insertId }, env.jwtSecret, { expiresIn: env.jwtExpires }); - return { - bootstrap: true, - token, - user: { id: result.insertId, name, email, roleCode: 'superadmin', roleName: 'Super Administrador', departmentId: null }, - }; + 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 } }; } - - const [collaboratorRole] = await pool.query("SELECT id FROM roles WHERE code = 'collaborator'"); - const verificationToken = newToken(); - const [result] = await pool.query( - `INSERT INTO users (name, email, password_hash, role_id, is_active, status, verification_token, verification_token_expires) - VALUES (?, ?, ?, ?, 0, 'pending_verification', ?, DATE_ADD(NOW(), INTERVAL 24 HOUR))`, - [name, email, passwordHash, collaboratorRole[0].id, verificationToken] - ); await logAudit({ userId: result.insertId, action: 'register', entityType: 'user', entityId: result.insertId }); const verifyUrl = `${env.corsOrigin}/verificar-correo?token=${verificationToken}`; await sendMail({ to: email, subject: 'Confirma tu correo — project.linkedcorp.net', - html: `

Hola ${name},

Confirma tu correo para continuar tu registro:

${verifyUrl}

Este enlace vence en 24 horas.

`, + html: verificationEmail(name, verifyUrl), }); return { bootstrap: false }; @@ -160,7 +164,7 @@ export async function acceptInvite(token, name, password) { const passwordHash = await bcrypt.hash(password, 12); await pool.query( - `UPDATE users SET name = ?, password_hash = ?, is_active = 1, status = 'active', email_verified_at = NOW(), + `UPDATE users SET name = ?, password_hash = ?, is_active = 1, status = 'active', edit_version = edit_version + 1, email_verified_at = NOW(), verification_token = NULL, verification_token_expires = NULL WHERE id = ?`, [name, passwordHash, user.id] @@ -168,11 +172,11 @@ export async function acceptInvite(token, name, password) { await logAudit({ userId: user.id, action: 'accept_invite', entityType: 'user', entityId: user.id }); const [full] = await pool.query( - `SELECT u.id, u.name, u.email, u.department_id, r.code AS role_code, r.name AS role_name + `SELECT u.id, u.name, u.email, u.department_id, u.session_version, r.code AS role_code, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`, [user.id] ); - const jwtToken = jwt.sign({ sub: user.id }, env.jwtSecret, { expiresIn: env.jwtExpires }); + const jwtToken = jwt.sign({ sub: user.id, sv: full[0].session_version }, env.jwtSecret, { expiresIn: env.jwtExpires }); return { token: jwtToken, user: { @@ -200,7 +204,7 @@ export async function forgotPassword(email) { await sendMail({ to: email, subject: 'Recuperar contraseña — project.linkedcorp.net', - html: `

Hola ${user.name},

Para elegir una nueva clave entra a este enlace (vence en 1 hora):

${resetUrl}

Si no pediste esto, ignora el correo.

`, + html: recoveryEmail(user.name, resetUrl), }); } @@ -217,7 +221,7 @@ export async function resetPassword(token, newPassword) { const passwordHash = await bcrypt.hash(newPassword, 12); await pool.query( - `UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL WHERE id = ?`, + `UPDATE users SET password_hash = ?, session_version = session_version + 1, reset_token = NULL, reset_token_expires = NULL WHERE id = ?`, [passwordHash, user.id] ); await logAudit({ userId: user.id, action: 'reset_password', entityType: 'user', entityId: user.id }); @@ -236,6 +240,6 @@ export async function changePassword(userId, currentPassword, newPassword) { if (!validPassword) throw new HttpError(400, 'La clave actual no es correcta'); const passwordHash = await bcrypt.hash(newPassword, 12); - await pool.query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, userId]); + await pool.query('UPDATE users SET password_hash = ?, session_version = session_version + 1 WHERE id = ?', [passwordHash, userId]); await logAudit({ userId, action: 'change_password', entityType: 'user', entityId: userId }); } diff --git a/backend/src/modules/catalogs/controller.js b/backend/src/modules/catalogs/controller.js index b4d2cbf..5b50923 100644 --- a/backend/src/modules/catalogs/controller.js +++ b/backend/src/modules/catalogs/controller.js @@ -1,6 +1,7 @@ import { ok, fail, asyncHandler } from '../../utils/http.js'; import { logAudit } from '../../utils/audit.js'; import { CATALOGS } from './repository.js'; +import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js'; import * as repo from './repository.js'; function validKey(req, res) { @@ -29,7 +30,8 @@ export const postCatalog = asyncHandler(async (req, res) => { export const putCatalog = asyncHandler(async (req, res) => { const key = validKey(req, res); if (!key) return; - const item = await repo.update(key, Number(req.params.id), req.body); + requireEditVersion(req.body.editVersion); + const item = requireUpdated(await repo.update(key, Number(req.params.id), req.body)); await logAudit({ userId: req.currentUser.id, action: 'update', entityType: `catalog:${key}`, entityId: item.id }); ok(res, item); }); diff --git a/backend/src/modules/catalogs/repository.js b/backend/src/modules/catalogs/repository.js index c355e0f..18a73b1 100644 --- a/backend/src/modules/catalogs/repository.js +++ b/backend/src/modules/catalogs/repository.js @@ -37,8 +37,8 @@ export async function update(catalogKey, id, payload) { const cfg = CATALOGS[catalogKey]; const cols = cfg.fields.filter((f) => payload[f] !== undefined); const setClause = cols.map((c) => `${c} = ?`).join(', '); - await pool.query(`UPDATE ${cfg.table} SET ${setClause} WHERE id = ?`, [...cols.map((c) => payload[c]), id]); - return { id, ...payload }; + const [result] = await pool.query(`UPDATE ${cfg.table} SET ${setClause}, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?`, [...cols.map((c) => payload[c]), id, payload.editVersion]); + return result.affectedRows ? { id, ...payload, edit_version: payload.editVersion + 1 } : null; } export async function remove(catalogKey, id) { diff --git a/backend/src/modules/comments/controller.js b/backend/src/modules/comments/controller.js index 9451f2a..dd2301e 100644 --- a/backend/src/modules/comments/controller.js +++ b/backend/src/modules/comments/controller.js @@ -37,7 +37,7 @@ export const postComment = asyncHandler(async (req, res) => { // Solo el autor, o superadmin/admin, puede editar/borrar -- nunca otro colaborador aunque tenga // permiso de escritura en el proyecto (un comentario es responsabilidad de quien lo escribio). function canManageComment(currentUser, comment) { - return comment.user_id === currentUser.id || [ROLES.SUPERADMIN, ROLES.ADMIN].includes(currentUser.roleCode); + return currentUser.roleCode !== ROLES.VIEWER && (comment.user_id === currentUser.id || [ROLES.SUPERADMIN, ROLES.ADMIN].includes(currentUser.roleCode)); } export const putComment = asyncHandler(async (req, res) => { @@ -46,7 +46,10 @@ export const putComment = asyncHandler(async (req, res) => { if (!canManageComment(req.currentUser, comment)) throw new HttpError(403, 'Solo quien escribio el comentario puede editarlo'); const message = (req.body?.message || '').trim(); if (!message) throw new HttpError(400, 'El mensaje es obligatorio'); - const updated = await repo.update(comment.id, message); + const editVersion = req.body?.editVersion; + if (!Number.isSafeInteger(editVersion) || editVersion < 1) throw new HttpError(428, 'Falta la version del comentario'); + const updated = await repo.update(comment.id, message, editVersion); + if (!updated) throw new HttpError(409, 'El comentario cambio en el servidor. Recarga antes de guardar'); await logAudit({ userId: req.currentUser.id, action: 'comment_edit', entityType: comment.entity_type, entityId: comment.entity_id, detail: { commentId: comment.id } }); ok(res, updated); }); diff --git a/backend/src/modules/comments/repository.js b/backend/src/modules/comments/repository.js index b69f529..efbda83 100644 --- a/backend/src/modules/comments/repository.js +++ b/backend/src/modules/comments/repository.js @@ -2,7 +2,7 @@ import { pool } from '../../config/db.js'; export async function findByEntity(entityType, entityId) { const [rows] = await pool.query( - `SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.created_at, c.updated_at + `SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.edit_version, c.created_at, c.updated_at FROM comments c JOIN users u ON u.id = c.user_id WHERE c.entity_type = ? AND c.entity_id = ? ORDER BY c.created_at`, [entityType, entityId] @@ -12,16 +12,16 @@ export async function findByEntity(entityType, entityId) { export async function findById(id) { const [rows] = await pool.query( - `SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.created_at, c.updated_at + `SELECT c.id, c.entity_type, c.entity_id, c.user_id, u.name AS user_name, c.message, c.edit_version, c.created_at, c.updated_at FROM comments c JOIN users u ON u.id = c.user_id WHERE c.id = ?`, [id] ); return rows[0] || null; } -export async function update(id, message) { - await pool.query('UPDATE comments SET message = ? WHERE id = ?', [message, id]); - return findById(id); +export async function update(id, message, editVersion) { + const [result] = await pool.query('UPDATE comments SET message = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?', [message, id, editVersion]); + return result.affectedRows ? findById(id) : null; } export async function remove(id) { diff --git a/backend/src/modules/company/controller.js b/backend/src/modules/company/controller.js index 460965f..9cfa3eb 100644 --- a/backend/src/modules/company/controller.js +++ b/backend/src/modules/company/controller.js @@ -8,10 +8,12 @@ export const getCompany = asyncHandler(async (req, res) => ok(res, await service export const getCompanyLogo = asyncHandler(async (req, res) => { const file = await service.getCompanyLogoFile(); if (!file) throw new HttpError(404, 'No hay logo configurado'); + res.set('X-Content-Type-Options', 'nosniff'); + res.set('Content-Security-Policy', "default-src 'none'; sandbox"); // sendFile exige ruta absoluta (a diferencia de res.download, que la resuelve sola). res.type(file.mime).sendFile(path.resolve(file.filePath)); }); export const putCompany = asyncHandler(async (req, res) => { - ok(res, await service.updateCompanyInfo({ name: req.body.name, file: req.file }, req.currentUser.id)); + ok(res, await service.updateCompanyInfo({ name: req.body.name, file: req.file, editVersion: Number(req.body.editVersion) }, req.currentUser.id)); }); diff --git a/backend/src/modules/company/service.js b/backend/src/modules/company/service.js index c795f76..a44b4e8 100644 --- a/backend/src/modules/company/service.js +++ b/backend/src/modules/company/service.js @@ -3,16 +3,18 @@ import path from 'node:path'; import * as settingsRepo from '../settings/repository.js'; import { logoDir } from '../../middleware/uploadLogo.js'; import { logAudit } from '../../utils/audit.js'; +import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js'; import { HttpError } from '../../utils/http.js'; -const COMPANY_KEYS = ['company_name', 'company_logo_filename', 'company_logo_mime']; +const COMPANY_KEYS = ['company_name', 'company_logo_filename', 'company_logo_mime', 'company_edit_version']; const DEFAULT_NAME = 'LinkedCorp'; export async function getCompanyInfo() { const values = await settingsRepo.getAll(COMPANY_KEYS); return { name: values.company_name || DEFAULT_NAME, - hasLogo: !!values.company_logo_filename, + edit_version: Number(values.company_edit_version), + hasLogo: !!values.company_logo_filename && ['image/png', 'image/jpeg', 'image/webp'].includes(values.company_logo_mime), // El propio nombre de archivo (unico por subida) sirve de version para cache-busting del logo. logoVersion: values.company_logo_filename || null, }; @@ -21,28 +23,45 @@ export async function getCompanyInfo() { export async function getCompanyLogoFile() { const values = await settingsRepo.getAll(['company_logo_filename', 'company_logo_mime']); if (!values.company_logo_filename) return null; + if (!['image/png', 'image/jpeg', 'image/webp'].includes(values.company_logo_mime)) return null; const filePath = path.join(logoDir, values.company_logo_filename); if (!fs.existsSync(filePath)) return null; return { filePath, mime: values.company_logo_mime || 'application/octet-stream' }; } -export async function updateCompanyInfo({ name, file }, actorId) { +export async function updateCompanyInfo({ name, file, editVersion }, actorId) { + requireEditVersion(editVersion); const entries = {}; + let oldLogoFilename; if (name !== undefined) { const trimmed = (name || '').trim(); if (!trimmed) throw new HttpError(400, 'El nombre de la empresa es obligatorio'); entries.company_name = trimmed; } if (file) { - const values = await settingsRepo.getAll(['company_logo_filename']); - if (values.company_logo_filename) { - const oldPath = path.join(logoDir, values.company_logo_filename); - if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath); + const bytes = await fs.promises.readFile(file.path); + const png = bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + const jpeg = bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; + const webp = bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP'; + const actualMime = png ? 'image/png' : jpeg ? 'image/jpeg' : webp ? 'image/webp' : null; + if (!actualMime || file.mimetype !== actualMime) { + await fs.promises.unlink(file.path).catch(() => {}); + throw new HttpError(400, 'El contenido del logo no coincide con PNG, JPG o WEBP'); } + const values = await settingsRepo.getAll(['company_logo_filename']); + oldLogoFilename = values.company_logo_filename; entries.company_logo_filename = file.filename; - entries.company_logo_mime = file.mimetype; + entries.company_logo_mime = actualMime; + } + try { + requireUpdated(await settingsRepo.setManyIfVersion(entries, 'company_edit_version', editVersion)); + } catch (err) { + if (file) await fs.promises.unlink(file.path).catch(() => {}); + throw err; + } + if (file && oldLogoFilename && oldLogoFilename !== file.filename) { + await fs.promises.unlink(path.join(logoDir, oldLogoFilename)).catch(() => {}); } - if (Object.keys(entries).length) await settingsRepo.setMany(entries); await logAudit({ userId: actorId, action: 'update', entityType: 'settings:company', entityId: null }); return getCompanyInfo(); } diff --git a/backend/src/modules/departments/repository.js b/backend/src/modules/departments/repository.js index 00d940b..c5180d5 100644 --- a/backend/src/modules/departments/repository.js +++ b/backend/src/modules/departments/repository.js @@ -1,7 +1,7 @@ import { pool } from '../../config/db.js'; export async function findAllFlat() { - const [rows] = await pool.query('SELECT id, name, parent_id FROM departments ORDER BY name'); + const [rows] = await pool.query('SELECT id, name, parent_id, edit_version FROM departments ORDER BY name'); return rows; } @@ -13,9 +13,9 @@ export async function create({ name, parentId }) { return { id: result.insertId, name, parent_id: parentId || null }; } -export async function update(id, { name, parentId }) { - await pool.query('UPDATE departments SET name = ?, parent_id = ? WHERE id = ?', [name, parentId || null, id]); - return { id, name, parent_id: parentId || null }; +export async function update(id, { name, parentId, editVersion }) { + const [result] = await pool.query('UPDATE departments SET name = ?, parent_id = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?', [name, parentId || null, id, editVersion]); + return result.affectedRows ? { id, name, parent_id: parentId || null, edit_version: editVersion + 1 } : null; } export async function remove(id) { diff --git a/backend/src/modules/departments/service.js b/backend/src/modules/departments/service.js index d3bf17e..8d3aacd 100644 --- a/backend/src/modules/departments/service.js +++ b/backend/src/modules/departments/service.js @@ -1,5 +1,6 @@ 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'; function toTree(flat) { @@ -30,7 +31,8 @@ export async function createDepartment(payload, actorId) { export async function updateDepartment(id, payload, actorId) { if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio'); if (Number(payload.parentId) === Number(id)) throw new HttpError(400, 'Un departamento no puede ser su propio padre'); - const dept = await repo.update(id, payload); + requireEditVersion(payload.editVersion); + const dept = requireUpdated(await repo.update(id, payload)); await logAudit({ userId: actorId, action: 'update', entityType: 'department', entityId: id }); return dept; } diff --git a/backend/src/modules/devices/ingest.js b/backend/src/modules/devices/ingest.js index 1ea694d..913f217 100644 --- a/backend/src/modules/devices/ingest.js +++ b/backend/src/modules/devices/ingest.js @@ -3,10 +3,13 @@ import zlib from 'node:zlib'; import fs from 'node:fs'; import path from 'node:path'; import express from 'express'; +import rateLimit from 'express-rate-limit'; +import { promisify } from 'node:util'; +import { Worker } from 'node:worker_threads'; import { env } from '../../config/env.js'; import { HttpError, asyncHandler } from '../../utils/http.js'; import * as repo from './repository.js'; -import { parseInventoryText, normalizeInventory } from './normalizer.js'; +import { redactUrl } from '../../utils/redactUrl.js'; // Ingesta del agente GLPI. NO usa JWT de usuario: cada instalacion lleva un token (guardado como // hash en inventory_tokens) que el agente manda como password Basic, Bearer, X-Ingest-Token o @@ -17,6 +20,8 @@ const MAX_INFLATED = MAX_BYTES * 4; // tope de inflado (anti zip-bomb) const REPORT_EVERY_HOURS = 24; const sha256 = (v) => crypto.createHash('sha256').update(v).digest('hex'); +const gunzip = promisify(zlib.gunzip); +const uncompress = promisify(zlib.inflate); function extractToken(req) { if (req.headers['x-ingest-token']) return String(req.headers['x-ingest-token']).trim(); @@ -32,10 +37,10 @@ function extractToken(req) { return null; } -function inflate(buf) { +async function inflate(buf) { const opts = { maxOutputLength: MAX_INFLATED }; - if (buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b) return zlib.gunzipSync(buf, opts); - if (buf.length > 2 && buf[0] === 0x78) return zlib.inflateSync(buf, opts); + if (buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b) return gunzip(buf, opts); + if (buf.length > 2 && buf[0] === 0x78) return uncompress(buf, opts); return buf; } @@ -46,24 +51,43 @@ function detectFormat(text) { return null; } -function saveRaw(deviceid, text, format) { +async function saveRaw(deviceid, text, format) { const dir = path.join(env.storageRoot, 'inventories'); - fs.mkdirSync(dir, { recursive: true }); + await fs.promises.mkdir(dir, { recursive: true }); // Un archivo por equipo (se sobrescribe): solo interesa el ultimo reporte crudo para depurar. const name = `${sha256(deviceid).slice(0, 24)}.${format}`; - fs.writeFileSync(path.join(dir, name), text); + await fs.promises.writeFile(path.join(dir, name), text); return name; } +function parseInWorker(text, format) { + return new Promise((resolve, reject) => { + const worker = new Worker(new URL('./parseWorker.js', import.meta.url), { + workerData: { text, format }, resourceLimits: { maxOldGenerationSizeMb: 256 }, + }); + let settled = false; + const timeout = setTimeout(() => worker.terminate(), 15_000); + function finish(error, value) { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (error) reject(error); else resolve(value); + } + worker.once('message', (value) => value.error ? finish(new Error(value.error)) : finish(null, value)); + worker.once('error', (error) => finish(error)); + worker.once('exit', (code) => { if (!settled) finish(new Error(`Parser interrumpido (${code})`)); }); + }); +} + const ok = (res, format) => (format === 'xml' ? res.type('application/xml').send('') : res.json({ status: 'ok' })); const receive = asyncHandler(async (req, res) => { - const token = extractToken(req); - const tokenRow = token ? await repo.findActiveTokenByHash(sha256(token)) : null; + const tokenRow = req.inventoryToken; + const token = req.inventoryTokenPresent; // Log de diagnostico de TODA peticion del agente (sin imprimir el token): util para depurar la conexion. - console.log(`[inventario] ${req.method} ${req.originalUrl} desde ${req.ip} type=${req.headers['content-type'] || '-'} enc=${req.headers['content-encoding'] || '-'} bytes=${req.headers['content-length'] || '?'} token=${token ? (tokenRow ? 'valido' : 'INVALIDO') : 'ausente'}`); + console.log(`[inventario] ${req.method} ${redactUrl(req.originalUrl)} desde ${req.ip} type=${req.headers['content-type'] || '-'} enc=${req.headers['content-encoding'] || '-'} bytes=${req.headers['content-length'] || '?'} token=${token ? (tokenRow ? 'valido' : 'INVALIDO') : 'ausente'}`); if (!tokenRow) { // El agente (LWP) manda las credenciales solo despues de recibir este desafio Basic. res.set('WWW-Authenticate', 'Basic realm="Project inventory"'); @@ -76,7 +100,7 @@ const receive = asyncHandler(async (req, res) => { let text; try { - text = inflate(body).toString('utf8'); + text = (await inflate(body)).toString('utf8'); } catch { throw new HttpError(400, 'Cuerpo comprimido invalido o demasiado grande'); } @@ -85,7 +109,7 @@ const receive = asyncHandler(async (req, res) => { let parsed; try { - parsed = parseInventoryText(text, format); + parsed = await parseInWorker(text, format); } catch { throw new HttpError(400, 'No se pudo interpretar el inventario'); } @@ -102,8 +126,8 @@ const receive = asyncHandler(async (req, res) => { if (parsed.action && parsed.action !== 'inventory') return ok(res, format); if (!parsed.deviceid) throw new HttpError(400, 'Inventario sin deviceid'); - const normalized = normalizeInventory(parsed); - const rawFile = saveRaw(parsed.deviceid, text, format); + const normalized = parsed.normalized; + const rawFile = await saveRaw(parsed.deviceid, text, format); const result = await repo.upsertInventory(parsed.deviceid, normalized, rawFile); console.log(`[inventario] ${result.created ? 'nuevo' : 'actualizado'} #${result.id} ${normalized.device.hostname || parsed.deviceid} (${format}, ${result.changeCount} cambios)`); return ok(res, format); @@ -111,13 +135,32 @@ const receive = asyncHandler(async (req, res) => { const router = express.Router(); const rawBody = express.raw({ type: () => true, limit: MAX_BYTES }); +const limiter = rateLimit({ windowMs: 60_000, limit: 120, standardHeaders: 'draft-7', legacyHeaders: false }); +let activeRequests = 0; +const preflight = asyncHandler(async (req, res, next) => { + const token = extractToken(req); + const tokenRow = token ? await repo.findActiveTokenByHash(sha256(token)) : null; + req.inventoryTokenPresent = !!token; + req.inventoryToken = tokenRow; + if (!tokenRow) { + res.set('WWW-Authenticate', 'Basic realm="Project inventory"'); + throw new HttpError(401, 'No autorizado'); + } + if (activeRequests >= 2) throw new HttpError(503, 'Ingesta ocupada; reintenta'); + activeRequests++; + let released = false; + function release() { if (!released) { released = true; activeRequests--; } } + res.once('finish', release); + res.once('close', release); + next(); +}); // Ruta propia + alias de la ruta nativa de GLPI (para no parchear el agente). -router.post('/api/inventory', rawBody, receive); -router.post('/front/inventory.php', rawBody, receive); +router.post('/api/inventory', limiter, preflight, rawBody, receive); +router.post('/front/inventory.php', limiter, preflight, rawBody, receive); // Cualquier otro metodo/ruta que el agente pruebe bajo estas rutas queda registrado. router.all(['/api/inventory', '/front/inventory.php'], (req, res) => { - console.log(`[inventario] ${req.method} ${req.originalUrl} desde ${req.ip} (metodo no soportado)`); + console.log(`[inventario] ${req.method} ${redactUrl(req.originalUrl)} desde ${req.ip} (metodo no soportado)`); res.status(405).json({ success: false, data: null, error: 'Usa POST' }); }); diff --git a/backend/src/modules/devices/parseWorker.js b/backend/src/modules/devices/parseWorker.js new file mode 100644 index 0000000..bba58fb --- /dev/null +++ b/backend/src/modules/devices/parseWorker.js @@ -0,0 +1,10 @@ +import { parentPort, workerData } from 'node:worker_threads'; +import { parseInventoryText, normalizeInventory } from './normalizer.js'; + +try { + const parsed = parseInventoryText(workerData.text, workerData.format); + parentPort.postMessage({ action: parsed.action, deviceid: parsed.deviceid, + normalized: parsed.action === 'inventory' || !parsed.action ? normalizeInventory(parsed) : null }); +} catch { + parentPort.postMessage({ error: 'No se pudo interpretar el inventario' }); +} diff --git a/backend/src/modules/devices/repository.js b/backend/src/modules/devices/repository.js index 2bb0377..c5140d5 100644 --- a/backend/src/modules/devices/repository.js +++ b/backend/src/modules/devices/repository.js @@ -217,11 +217,12 @@ export async function findById(id) { return detail; } -export async function updateManual(id, { status, assignedUserId, departmentId, notes }) { - await pool.query( - 'UPDATE devices SET status = ?, assigned_user_id = ?, department_id = ?, notes = ? WHERE id = ?', - [status, assignedUserId, departmentId, notes, id] +export async function updateManual(id, { status, assignedUserId, departmentId, notes, editVersion }) { + const [result] = await pool.query( + 'UPDATE devices SET status = ?, assigned_user_id = ?, department_id = ?, notes = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?', + [status, assignedUserId, departmentId, notes, id, editVersion] ); + return result.affectedRows > 0; } export async function findRawFile(id) { diff --git a/backend/src/modules/devices/routes.js b/backend/src/modules/devices/routes.js index 6d01437..e1efaba 100644 --- a/backend/src/modules/devices/routes.js +++ b/backend/src/modules/devices/routes.js @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { requireAuth, requireRole, ROLES } from '../../middleware/auth.js'; +import { requireAuth, requireRole, CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js'; import { fail } from '../../utils/http.js'; import { getAccess as getAccessFor } from './service.js'; import { @@ -11,8 +11,6 @@ const router = Router(); // Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente y el compartir, solo // admin/superadmin. Otros usuarios pueden tener acceso de SOLO LECTURA compartido (device_viewers). -const CAN_EDIT = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER]; -const CAN_MANAGE = [ROLES.SUPERADMIN, ROLES.ADMIN]; async function canView(req, res, next) { try { @@ -27,17 +25,17 @@ router.get('/access', getAccess); // cualquier autenticado: el menu lo usa para router.get('/dashboard', canView, getDashboard); router.get('/software', canView, getSoftware); router.get('/software/devices', canView, getSoftwareDevices); -router.get('/viewers', requireRole(...CAN_MANAGE), getViewers); -router.post('/viewers', requireRole(...CAN_MANAGE), postViewer); -router.delete('/viewers/:userId', requireRole(...CAN_MANAGE), deleteViewer); -router.get('/tokens', requireRole(...CAN_MANAGE), getTokens); -router.post('/tokens', requireRole(...CAN_MANAGE), postToken); -router.get('/tokens/:id/secret', requireRole(...CAN_MANAGE), getTokenSecret); -router.patch('/tokens/:id', requireRole(...CAN_MANAGE), patchToken); -router.delete('/tokens/:id', requireRole(...CAN_MANAGE), deleteToken); +router.get('/viewers', requireRole(...CAN_MANAGE_DEVICES), getViewers); +router.post('/viewers', requireRole(...CAN_MANAGE_DEVICES), postViewer); +router.delete('/viewers/:userId', requireRole(...CAN_MANAGE_DEVICES), deleteViewer); +router.get('/tokens', requireRole(...CAN_MANAGE_DEVICES), getTokens); +router.post('/tokens', requireRole(...CAN_MANAGE_DEVICES), postToken); +router.get('/tokens/:id/secret', requireRole(...CAN_MANAGE_DEVICES), getTokenSecret); +router.patch('/tokens/:id', requireRole(...CAN_MANAGE_DEVICES), patchToken); +router.delete('/tokens/:id', requireRole(...CAN_MANAGE_DEVICES), deleteToken); router.get('/', canView, getDevices); router.get('/:id', canView, getDevice); -router.get('/:id/raw', requireRole(...CAN_EDIT), getRaw); -router.put('/:id', requireRole(...CAN_EDIT), putDevice); +router.get('/:id/raw', requireRole(...CAN_EDIT_DEVICES), getRaw); +router.put('/:id', requireRole(...CAN_EDIT_DEVICES), putDevice); export default router; diff --git a/backend/src/modules/devices/service.js b/backend/src/modules/devices/service.js index e85212a..595d15f 100644 --- a/backend/src/modules/devices/service.js +++ b/backend/src/modules/devices/service.js @@ -3,9 +3,11 @@ import fs from 'node:fs'; import path from 'node:path'; import { env } from '../../config/env.js'; import { HttpError } from '../../utils/http.js'; +import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js'; import { logAudit } from '../../utils/audit.js'; import { encrypt, decrypt } from '../../utils/crypto.js'; import * as repo from './repository.js'; +import { CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js'; const STATUSES = ['active', 'retired']; const STALE_DAYS = 7; @@ -34,7 +36,8 @@ export async function updateDevice(id, data, actor) { departmentId: data.departmentId === undefined ? current.department_id : data.departmentId || null, notes: data.notes === undefined ? current.notes : (String(data.notes).trim() || null), }; - await repo.updateManual(id, next); + requireEditVersion(data.editVersion); + requireUpdated(await repo.updateManual(id, { ...next, editVersion: data.editVersion })); await logAudit({ userId: actor.id, action: 'update', entityType: 'device', entityId: id, detail: { hostname: current.hostname, ...next } }); return getDevice(id); } @@ -51,14 +54,12 @@ export const listSoftware = (q, all) => repo.listSoftware({ search: q?.trim() || export const devicesWithSoftware = (name) => repo.listDevicesWithSoftware(name); export const getDashboard = () => repo.dashboard(STALE_DAYS); -const FULL_ACCESS_ROLES = ['superadmin', 'admin', 'it_manager']; -const MANAGE_ROLES = ['superadmin', 'admin']; // Que puede hacer este usuario en Equipos: ver (rol de TI o compartido), editar, gestionar (agentes/compartir). export async function getAccess(user) { - const full = FULL_ACCESS_ROLES.includes(user.roleCode); + const full = CAN_EDIT_DEVICES.includes(user.roleCode); const canView = full || (await repo.isViewer(user.id)); - return { canView, canEdit: full, canManage: MANAGE_ROLES.includes(user.roleCode) }; + return { canView, canEdit: full, canManage: CAN_MANAGE_DEVICES.includes(user.roleCode) }; } export const listViewers = () => repo.listViewers(); @@ -66,7 +67,7 @@ export const listViewers = () => repo.listViewers(); export async function addViewer(userId, actor) { const target = await repo.findUserBrief(userId); if (!target || !target.is_active) throw new HttpError(404, 'Usuario no encontrado'); - if (FULL_ACCESS_ROLES.includes(target.role_code)) throw new HttpError(400, 'Ese usuario ya tiene acceso completo por su rol'); + if (CAN_EDIT_DEVICES.includes(target.role_code)) throw new HttpError(400, 'Ese usuario ya tiene acceso completo por su rol'); await repo.addViewer(userId, actor.id); await logAudit({ userId: actor.id, action: 'share', entityType: 'devices', entityId: userId, detail: { name: target.name } }); return repo.listViewers(); diff --git a/backend/src/modules/errorLogs/service.js b/backend/src/modules/errorLogs/service.js index 4c4772c..2d23cb4 100644 --- a/backend/src/modules/errorLogs/service.js +++ b/backend/src/modules/errorLogs/service.js @@ -2,6 +2,7 @@ import jwt from 'jsonwebtoken'; import * as repo from './repository.js'; import { env } from '../../config/env.js'; import { HttpError } from '../../utils/http.js'; +import { redactUrl } from '../../utils/redactUrl.js'; // Severidad SIEMPRE automatica, nunca etiquetada a mano (mismo criterio que la criticidad del // panel de Actualizaciones): backend y errores de render de React "alta" (rompen toda la @@ -22,7 +23,7 @@ export async function logBackendError(err, req) { severity: 'alta', message: (err?.message || String(err)).slice(0, 2000), stack: (err?.stack || '').slice(0, 5000) || null, - url: req?.originalUrl?.slice(0, 500) || null, + url: redactUrl(req?.originalUrl).slice(0, 500) || null, method: req?.method || null, userId: req?.currentUser?.id || null, userAgent: (req?.headers?.['user-agent'] || '').slice(0, 255) || null, @@ -53,7 +54,7 @@ export async function logFrontendError(payload, req) { severity: normalizeSeverity(payload.severity), message, stack: (payload.stack || '').slice(0, 5000) || null, - url: (payload.url || '').slice(0, 500) || null, + url: redactUrl(payload.url || '').slice(0, 500) || null, method: null, userId: decodeOptionalUserId(req), userAgent: (req.headers['user-agent'] || '').slice(0, 255) || null, diff --git a/backend/src/modules/notes/controller.js b/backend/src/modules/notes/controller.js index b85791d..66fa29e 100644 --- a/backend/src/modules/notes/controller.js +++ b/backend/src/modules/notes/controller.js @@ -10,7 +10,7 @@ export const postNote = asyncHandler(async (req, res) => { }); export const putNote = asyncHandler(async (req, res) => { - ok(res, await service.updateNote(Number(req.params.id), req.body.content, req.currentUser)); + ok(res, await service.updateNote(Number(req.params.id), req.body.content, req.body.editVersion, req.currentUser)); }); export const deleteNote = asyncHandler(async (req, res) => { diff --git a/backend/src/modules/notes/repository.js b/backend/src/modules/notes/repository.js index aa40f89..5461433 100644 --- a/backend/src/modules/notes/repository.js +++ b/backend/src/modules/notes/repository.js @@ -6,7 +6,7 @@ import { pool } from '../../config/db.js'; // su orden manual), compartidas despues (por fecha -- nunca arrastrables). export async function findVisibleToUser(userId) { const [rows] = await pool.query( - `SELECT n.id, n.user_id, n.content, n.created_at, n.updated_at, + `SELECT n.id, n.user_id, n.content, n.edit_version, n.created_at, n.updated_at, (n.user_id = ?) AS is_owner, u.name AS owner_name, (SELECT COUNT(*) FROM note_shares ns WHERE ns.note_id = n.id) AS share_count FROM notes n @@ -29,7 +29,7 @@ export async function reorder(userId, ids) { } export async function findById(id) { - const [rows] = await pool.query('SELECT id, user_id, content, created_at, updated_at FROM notes WHERE id = ?', [id]); + const [rows] = await pool.query('SELECT id, user_id, content, edit_version, created_at, updated_at FROM notes WHERE id = ?', [id]); return rows[0] || null; } @@ -38,9 +38,9 @@ export async function create(userId, content) { return findById(result.insertId); } -export async function update(id, content) { - await pool.query('UPDATE notes SET content = ? WHERE id = ?', [content, id]); - return findById(id); +export async function update(id, content, editVersion) { + const [result] = await pool.query('UPDATE notes SET content = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?', [content, id, editVersion]); + return result.affectedRows ? findById(id) : null; } export async function remove(id) { diff --git a/backend/src/modules/notes/service.js b/backend/src/modules/notes/service.js index 343f3a8..1b55b80 100644 --- a/backend/src/modules/notes/service.js +++ b/backend/src/modules/notes/service.js @@ -1,6 +1,7 @@ import * as repo from './repository.js'; import * as usersRepo from '../users/repository.js'; import { HttpError } from '../../utils/http.js'; +import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js'; import { logAudit } from '../../utils/audit.js'; // Siempre privadas para editar/borrar/compartir: cada quien solo toca sus propias notas, sin @@ -19,11 +20,12 @@ export async function createNote(content, currentUser) { return repo.create(currentUser.id, trimmed); } -export async function updateNote(id, content, currentUser) { +export async function updateNote(id, content, editVersion, currentUser) { await getOwnNoteOr404(id, currentUser); const trimmed = (content || '').trim(); if (!trimmed) throw new HttpError(400, 'El contenido es obligatorio'); - return repo.update(id, trimmed); + requireEditVersion(editVersion); + return requireUpdated(await repo.update(id, trimmed, editVersion)); } export async function removeNote(id, currentUser) { diff --git a/backend/src/modules/projects/controller.js b/backend/src/modules/projects/controller.js index 410a2e5..0898c9a 100644 --- a/backend/src/modules/projects/controller.js +++ b/backend/src/modules/projects/controller.js @@ -2,10 +2,12 @@ import { ok, asyncHandler } from '../../utils/http.js'; import * as service from './service.js'; export const getProjects = asyncHandler(async (req, res) => { - const { statusId, priorityId, ownerUserId, trash } = req.query; - ok(res, await service.listProjects({ statusId, priorityId, ownerUserId, trash: trash === 'true' }, req.currentUser)); + const { statusId, priorityId, ownerUserId, trash, page, pageSize, dueSoon, progress, q } = req.query; + ok(res, await service.listProjects({ statusId, priorityId, ownerUserId, trash: trash === 'true', page, pageSize, dueSoon: dueSoon === 'true', progress, q }, req.currentUser)); }); +export const getProjectOptions = asyncHandler(async (req, res) => ok(res, await service.listProjectOptions(req.currentUser))); + export const getProject = asyncHandler(async (req, res) => { ok(res, await service.getProject(Number(req.params.id), req.currentUser)); }); diff --git a/backend/src/modules/projects/repository.js b/backend/src/modules/projects/repository.js index 2d2083c..a8a4fe4 100644 --- a/backend/src/modules/projects/repository.js +++ b/backend/src/modules/projects/repository.js @@ -8,7 +8,7 @@ const LIST_SELECT = ` p.requesting_department_id, rd.name AS requesting_department_name, p.responsible_department_id, sd.name AS responsible_department_name, p.start_date, p.estimated_end_date, p.actual_end_date, p.progress_pct, - p.risk_level, p.notes, p.created_by, p.created_at, p.updated_at, p.deleted_at, p.deleted_by, + p.risk_level, p.notes, p.edit_version, p.created_by, p.created_at, p.updated_at, p.deleted_at, p.deleted_by, (SELECT COUNT(*) FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id <> p.owner_user_id) AS shared_count, (SELECT COUNT(*) FROM project_members pm2 WHERE pm2.project_id = p.id) AS members_count, (SELECT COUNT(*) FROM activities act WHERE act.project_id = p.id AND act.deleted_at IS NULL) AS activities_total, @@ -38,12 +38,16 @@ function castCounts(row) { }; } -export async function findAll({ statusId, priorityId, ownerUserId, memberUserId, trash } = {}) { +export async function findAll({ statusId, priorityId, ownerUserId, memberUserId, trash, page = 1, pageSize = 20, dueSoon, progress, q } = {}) { const clauses = [trash ? 'p.deleted_at IS NOT NULL' : 'p.deleted_at IS NULL']; const params = []; if (statusId) { clauses.push('p.status_id = ?'); params.push(statusId); } if (priorityId) { clauses.push('p.priority_id = ?'); params.push(priorityId); } if (ownerUserId) { clauses.push('p.owner_user_id = ?'); params.push(ownerUserId); } + if (dueSoon) clauses.push('ps.is_closed = 0 AND p.estimated_end_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 7 DAY)'); + if (progress === 'hide') clauses.push('p.progress_pct < 100'); + if (progress === 'only') clauses.push('p.progress_pct >= 100'); + if (q) { clauses.push('(p.code LIKE ? OR p.name LIKE ? OR u.name LIKE ?)'); params.push(...Array(3).fill(`%${q}%`)); } // Restringe a proyectos donde el usuario es miembro (compartidos con el, o el propio dueño ya // que quien crea un proyecto queda como miembro 'owner' automaticamente -- ver create()). if (memberUserId) { @@ -51,8 +55,18 @@ export async function findAll({ statusId, priorityId, ownerUserId, memberUserId, params.push(memberUserId); } const where = `WHERE ${clauses.join(' AND ')}`; - const [rows] = await pool.query(`${LIST_SELECT} ${where} ORDER BY p.created_at DESC`, params); - return rows.map(castCounts); + const [[{ total }]] = await pool.query(`SELECT COUNT(*) AS total FROM projects p JOIN project_statuses ps ON ps.id = p.status_id JOIN users u ON u.id = p.owner_user_id ${where}`, params); + const [rows] = await pool.query(`${LIST_SELECT} ${where} ORDER BY p.updated_at DESC, p.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, (page - 1) * pageSize]); + return { items: rows.map(castCounts), total: Number(total), page, pageSize }; +} + +export async function findOptions(memberUserId) { + const [rows] = await pool.query( + `SELECT p.id, p.code, p.name FROM projects p WHERE p.deleted_at IS NULL + ${memberUserId ? 'AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)' : ''} + ORDER BY p.name`, memberUserId ? [memberUserId] : [] + ); + return rows; } export async function findById(id, { includeDeleted = false } = {}) { @@ -62,11 +76,11 @@ export async function findById(id, { includeDeleted = false } = {}) { } export async function softDelete(id, userId) { - await pool.query('UPDATE projects SET deleted_at = NOW(), deleted_by = ? WHERE id = ? AND deleted_at IS NULL', [userId, id]); + await pool.query('UPDATE projects SET deleted_at = NOW(), deleted_by = ?, edit_version = edit_version + 1 WHERE id = ? AND deleted_at IS NULL', [userId, id]); } export async function restore(id) { - await pool.query('UPDATE projects SET deleted_at = NULL, deleted_by = NULL WHERE id = ?', [id]); + await pool.query('UPDATE projects SET deleted_at = NULL, deleted_by = NULL, edit_version = edit_version + 1 WHERE id = ?', [id]); } export async function hardDelete(id) { @@ -106,21 +120,21 @@ export async function getDefaultStatusId() { } export async function update(id, data) { - await pool.query( + const [result] = await pool.query( `UPDATE projects SET name = ?, description = ?, objective = ?, project_type_id = ?, priority_id = ?, status_id = ?, owner_user_id = ?, requesting_department_id = ?, responsible_department_id = ?, - start_date = ?, estimated_end_date = ?, actual_end_date = ?, risk_level = ?, notes = ? - WHERE id = ?`, + start_date = ?, estimated_end_date = ?, actual_end_date = ?, risk_level = ?, notes = ?, edit_version = edit_version + 1 + WHERE id = ? AND edit_version = ? AND deleted_at IS NULL`, [ data.name, data.description || null, data.objective || null, data.projectTypeId || null, data.priorityId, data.statusId, data.ownerUserId, data.requestingDepartmentId || null, data.responsibleDepartmentId || null, data.startDate || null, data.estimatedEndDate || null, data.actualEndDate || null, - data.riskLevel || 'low', data.notes || null, id, + data.riskLevel || 'low', data.notes || null, id, data.editVersion, ] ); - return findById(id); + return result.affectedRows ? findById(id) : null; } export async function updateProgress(id, progressPct) { diff --git a/backend/src/modules/projects/routes.js b/backend/src/modules/projects/routes.js index da68209..1b44b32 100644 --- a/backend/src/modules/projects/routes.js +++ b/backend/src/modules/projects/routes.js @@ -1,7 +1,7 @@ import { Router } from 'express'; import { requireAuth } from '../../middleware/auth.js'; import { - getProjects, getProject, postProject, putProject, deleteProject, postProjectRestore, + getProjects, getProjectOptions, getProject, postProject, putProject, deleteProject, postProjectRestore, deleteProjectPermanent, postMember, putMemberRole, deleteMember, } from './controller.js'; import { getGantt } from '../dashboard/controller.js'; @@ -11,6 +11,7 @@ const router = Router(); router.use(requireAuth); router.get('/', getProjects); +router.get('/options', getProjectOptions); router.post('/', postProject); router.get('/:id', getProject); router.put('/:id', putProject); diff --git a/backend/src/modules/projects/service.js b/backend/src/modules/projects/service.js index 72eeb53..867653a 100644 --- a/backend/src/modules/projects/service.js +++ b/backend/src/modules/projects/service.js @@ -22,6 +22,7 @@ export async function canReadProject(user, project) { // tercer nivel "administrador de proyecto") que se sigue respetando como escritura en filas // viejas, pero ya no se asigna desde la UI. export async function canWriteProject(user, project) { + if (user.roleCode === ROLES.VIEWER) return false; if (FULL_ACCESS_ROLES.includes(user.roleCode)) return true; const membership = await repo.getMembership(project.id, user.id); if (['owner', 'admin', 'write'].includes(membership)) return true; @@ -37,18 +38,22 @@ export function isProjectOwner(user, project) { // un admin del proyecto ni un rol global de administracion pueden compartir el proyecto de otra // persona sin que el dueño lo autorice (acordado con el usuario). export function canManageMembers(user, project) { - return isProjectOwner(user, project); + return user.roleCode !== ROLES.VIEWER && isProjectOwner(user, project); } // Borrar el PROYECTO (no su contenido) es exclusivo del dueño o de un rol global de // administracion -- un colaborador con permiso de Escritura puede borrar actividades/tareas // dentro del proyecto, pero nunca el proyecto en si (acordado con el usuario). export function canDeleteProject(user, project) { - return FULL_ACCESS_ROLES.includes(user.roleCode) || isProjectOwner(user, project); + return user.roleCode !== ROLES.VIEWER && (FULL_ACCESS_ROLES.includes(user.roleCode) || isProjectOwner(user, project)); } // Solo el superadmin puede ver la papelera (proyectos eliminados logicamente). export async function listProjects(filters, currentUser) { + const page = Number(filters.page || 1); + const pageSize = Number(filters.pageSize || 20); + if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100 || !Number.isSafeInteger((page - 1) * pageSize)) throw new HttpError(400, 'Paginacion invalida'); + filters = { ...filters, page, pageSize, q: String(filters.q || '').trim().slice(0, 100) }; if (filters.trash && currentUser?.roleCode !== ROLES.SUPERADMIN) { throw new HttpError(403, 'Solo el superadministrador puede ver la papelera'); } @@ -59,6 +64,10 @@ export async function listProjects(filters, currentUser) { return repo.findAll({ ...filters, memberUserId: currentUser.id }); } +export function listProjectOptions(currentUser) { + return repo.findOptions(FULL_ACCESS_ROLES.includes(currentUser.roleCode) ? null : currentUser.id); +} + // Usado por comments/attachments, que cuelgan de 'project' o 'activity' (entity_type/entity_id // generico, ver BASE_DE_DATOS.md) -- resuelve a que proyecto pertenece la entidad para poder // aplicar el mismo chequeo de lectura/escritura por membresia en los dos modulos. @@ -106,7 +115,9 @@ export async function updateProject(id, payload, currentUser) { if (!payload.name || !payload.priorityId || !payload.statusId) { throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios'); } + if (!Number.isSafeInteger(payload.editVersion) || payload.editVersion < 1) throw new HttpError(428, 'Falta la version del proyecto'); const updated = await repo.update(id, { ...payload, ownerUserId: payload.ownerUserId || project.owner_user_id }); + if (!updated) throw new HttpError(409, 'El proyecto cambio en el servidor. Recarga antes de guardar'); await logAudit({ userId: currentUser.id, action: 'update', entityType: 'project', entityId: id }); return updated; } diff --git a/backend/src/modules/recurring-tasks/controller.js b/backend/src/modules/recurring-tasks/controller.js index 215baea..92a172e 100644 --- a/backend/src/modules/recurring-tasks/controller.js +++ b/backend/src/modules/recurring-tasks/controller.js @@ -2,7 +2,7 @@ import { ok, asyncHandler } from '../../utils/http.js'; import * as service from './service.js'; export const getRecurringTasks = asyncHandler(async (req, res) => { - ok(res, await service.listRecurringTasks()); + ok(res, await service.listRecurringTasks(req.currentUser)); }); export const postRecurringTask = asyncHandler(async (req, res) => { diff --git a/backend/src/modules/recurring-tasks/repository.js b/backend/src/modules/recurring-tasks/repository.js index 56401ff..cf7638c 100644 --- a/backend/src/modules/recurring-tasks/repository.js +++ b/backend/src/modules/recurring-tasks/repository.js @@ -5,7 +5,7 @@ const LIST_SELECT = ` r.responsible_user_id, u.name AS responsible_name, r.interval_unit, r.interval_value, r.next_due_date, r.end_date, r.reminder_days_before, r.reminder_hour, r.is_active, r.last_reminder_sent_at, - r.created_by, r.created_at, r.updated_at + r.created_by, r.edit_version, r.created_at, r.updated_at FROM recurring_tasks r LEFT JOIN projects p ON p.id = r.project_id LEFT JOIN users u ON u.id = r.responsible_user_id @@ -38,19 +38,19 @@ export async function create(data) { } export async function update(id, data) { - await pool.query( + const [result] = await pool.query( `UPDATE recurring_tasks SET name = ?, description = ?, project_id = ?, responsible_user_id = ?, interval_unit = ?, - interval_value = ?, next_due_date = ?, end_date = ?, reminder_days_before = ?, reminder_hour = ?, is_active = ? - WHERE id = ?`, + interval_value = ?, next_due_date = ?, end_date = ?, reminder_days_before = ?, reminder_hour = ?, is_active = ?, edit_version = edit_version + 1 + WHERE id = ? AND edit_version = ?`, [ data.name, data.description || null, data.projectId || null, data.responsibleUserId || null, data.intervalUnit, data.intervalValue, data.nextDueDate, data.endDate || null, data.reminderDaysBefore ?? 7, data.reminderHour ?? 9, - data.isActive === false ? 0 : 1, id, + data.isActive === false ? 0 : 1, id, data.editVersion, ] ); - return findById(id); + return result.affectedRows ? findById(id) : null; } export async function remove(id) { @@ -69,12 +69,12 @@ export async function advanceCycle(id) { const unit = SQL_UNIT[row.interval_unit] || 'MONTH'; await pool.query( `UPDATE recurring_tasks - SET next_due_date = DATE_ADD(next_due_date, INTERVAL ? ${unit}), last_reminder_sent_at = NULL + SET next_due_date = DATE_ADD(next_due_date, INTERVAL ? ${unit}), last_reminder_sent_at = NULL, edit_version = edit_version + 1 WHERE id = ?`, [row.interval_value, id] ); await pool.query( - `UPDATE recurring_tasks SET is_active = 0 + `UPDATE recurring_tasks SET is_active = 0, edit_version = edit_version + 1 WHERE id = ? AND end_date IS NOT NULL AND next_due_date > end_date`, [id] ); diff --git a/backend/src/modules/recurring-tasks/service.js b/backend/src/modules/recurring-tasks/service.js index fb9fc08..4a7d634 100644 --- a/backend/src/modules/recurring-tasks/service.js +++ b/backend/src/modules/recurring-tasks/service.js @@ -2,8 +2,12 @@ import * as repo from './repository.js'; import * as tasksRepo from '../tasks/repository.js'; import * as activitiesRepo from '../activities/repository.js'; import { HttpError } from '../../utils/http.js'; +import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js'; import { logAudit } from '../../utils/audit.js'; import { sendMail } from '../../utils/mailer.js'; +import { canReadProject, canWriteProject } from '../projects/service.js'; +import * as projectsRepo from '../projects/repository.js'; +import { escapeHtml } from '../../utils/emailHtml.js'; const INTERVAL_UNITS = ['days', 'months', 'years']; @@ -20,9 +24,33 @@ function validate(payload) { } } -export const listRecurringTasks = () => repo.findAll(); +const FULL = ['superadmin', 'admin', 'it_manager']; +function canOwn(user, task) { return FULL.includes(user.roleCode) || task.created_by === user.id; } +async function checkProject(projectId, user, write) { + if (!projectId) return; + const project = await projectsRepo.findById(projectId); + if (!project || !(await (write ? canWriteProject : canReadProject)(user, project))) throw new HttpError(404, 'Proyecto no encontrado'); +} +function checkWritable(user, task) { + if (user.roleCode === 'viewer' || (task && !canOwn(user, task))) throw new HttpError(404, 'Tarea recurrente no encontrada'); +} +export async function listRecurringTasks(user) { + const tasks = await repo.findAll(); + const result = []; + for (const task of tasks) { + if (!canOwn(user, task)) continue; + if (task.project_id) { + const project = await projectsRepo.findById(task.project_id); + if (!project || !(await canReadProject(user, project))) continue; + } + result.push(task); + } + return result; +} export async function createRecurringTask(payload, currentUser) { + checkWritable(currentUser); + await checkProject(payload.projectId, currentUser, true); validate(payload); const task = await repo.create({ ...payload, createdBy: currentUser.id }); await logAudit({ userId: currentUser.id, action: 'create', entityType: 'recurring_task', entityId: task.id }); @@ -32,8 +60,12 @@ export async function createRecurringTask(payload, currentUser) { export async function updateRecurringTask(id, payload, currentUser) { const existing = await repo.findById(id); if (!existing) throw new HttpError(404, 'Tarea recurrente no encontrada'); + checkWritable(currentUser, existing); + await checkProject(existing.project_id, currentUser, true); + await checkProject(payload.projectId, currentUser, true); validate(payload); - const task = await repo.update(id, payload); + requireEditVersion(payload.editVersion); + const task = requireUpdated(await repo.update(id, payload)); await logAudit({ userId: currentUser.id, action: 'update', entityType: 'recurring_task', entityId: id }); return task; } @@ -41,6 +73,8 @@ export async function updateRecurringTask(id, payload, currentUser) { export async function removeRecurringTask(id, currentUser) { const existing = await repo.findById(id); if (!existing) throw new HttpError(404, 'Tarea recurrente no encontrada'); + checkWritable(currentUser, existing); + await checkProject(existing.project_id, currentUser, true); await repo.remove(id); await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'recurring_task', entityId: id }); } @@ -50,6 +84,8 @@ export async function removeRecurringTask(id, currentUser) { export async function completeCycle(id, currentUser) { const existing = await repo.findById(id); if (!existing) throw new HttpError(404, 'Tarea recurrente no encontrada'); + checkWritable(currentUser, existing); + await checkProject(existing.project_id, currentUser, true); const task = await repo.advanceCycle(id); await logAudit({ userId: currentUser.id, action: 'complete_cycle', entityType: 'recurring_task', entityId: id }); return task; @@ -59,10 +95,12 @@ export async function completeCycle(id, currentUser) { // actividad NO se borra (queda como referencia de que ya se hizo esta vez), simplemente se crea // la tarea recurrente enlazada al proyecto de esa actividad para tener contexto. export async function promoteFromTask(taskId, payload, currentUser) { + checkWritable(currentUser); const sourceTask = await tasksRepo.findById(taskId); if (!sourceTask) throw new HttpError(404, 'Tarea no encontrada'); const activity = await activitiesRepo.findById(sourceTask.activity_id); if (!activity) throw new HttpError(404, 'Actividad no encontrada'); + await checkProject(activity.project_id, currentUser, true); const merged = { name: payload.name || sourceTask.name, @@ -94,18 +132,18 @@ export async function sendDueReminders() { const to = item.responsible_email || item.creator_email; const recipientName = item.responsible_name || item.creator_name; try { - await sendMail({ + const result = await sendMail({ to, subject: `Recordatorio: ${item.name} vence el ${item.next_due_date}`, - html: `

Hola ${recipientName},

-

La tarea recurrente ${item.name}${item.project_name ? ` (proyecto: ${item.project_name})` : ''} - vence el ${item.next_due_date}.

+ html: `

Hola ${escapeHtml(recipientName)},

+

La tarea recurrente ${escapeHtml(item.name)}${item.project_name ? ` (proyecto: ${escapeHtml(item.project_name)})` : ''} + vence el ${escapeHtml(item.next_due_date)}.

Entra a project.linkedcorp.net → Tareas recurrentes para revisarla.

`, }); + if (result.sent) await repo.markReminderSent(item.id); } catch (err) { console.error('[recurring-tasks] error enviando recordatorio', item.id, err.message); } - await repo.markReminderSent(item.id); } return due.length; } diff --git a/backend/src/modules/search/controller.js b/backend/src/modules/search/controller.js index 6705cc0..9191f8d 100644 --- a/backend/src/modules/search/controller.js +++ b/backend/src/modules/search/controller.js @@ -2,5 +2,5 @@ import { ok, asyncHandler } from '../../utils/http.js'; import * as service from './service.js'; export const getSearch = asyncHandler(async (req, res) => { - ok(res, await service.search(req.query.q, req.query.projectId)); + ok(res, await service.search(req.query.q, req.query.projectId, req.currentUser)); }); diff --git a/backend/src/modules/search/repository.js b/backend/src/modules/search/repository.js index db96a96..fdde0c7 100644 --- a/backend/src/modules/search/repository.js +++ b/backend/src/modules/search/repository.js @@ -4,14 +4,17 @@ const RESULT_LIMIT = 20; // Busqueda global (menu Proyectos): proyectos, actividades (con su proyecto) y tareas (con su // actividad y proyecto) — para armar la migaja de pan "Proyecto - Actividad - Tarea" en el cliente. -export async function searchGlobal(term) { +export async function searchGlobal(term, user) { const like = `%${term}%`; + const fullAccess = ['superadmin', 'admin', 'it_manager'].includes(user.roleCode); + const scope = fullAccess ? '' : ' AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)'; + const scopeParams = fullAccess ? [] : [user.id]; const [projects] = await pool.query( - `SELECT id, code, name FROM projects - WHERE deleted_at IS NULL AND (code LIKE ? OR name LIKE ?) + `SELECT p.id, p.code, p.name FROM projects p + WHERE p.deleted_at IS NULL AND (p.code LIKE ? OR p.name LIKE ?)${scope} ORDER BY name LIMIT ?`, - [like, like, RESULT_LIMIT] + [like, like, ...scopeParams, RESULT_LIMIT] ); const [activities] = await pool.query( @@ -19,9 +22,9 @@ export async function searchGlobal(term) { p.id AS project_id, p.code AS project_code, p.name AS project_name FROM activities a JOIN projects p ON p.id = a.project_id - WHERE p.deleted_at IS NULL AND (a.code LIKE ? OR a.name LIKE ?) + WHERE p.deleted_at IS NULL AND a.deleted_at IS NULL AND (a.code LIKE ? OR a.name LIKE ?)${scope} ORDER BY a.name LIMIT ?`, - [like, like, RESULT_LIMIT] + [like, like, ...scopeParams, RESULT_LIMIT] ); const [tasks] = await pool.query( @@ -31,9 +34,9 @@ export async function searchGlobal(term) { FROM tasks t JOIN activities a ON a.id = t.activity_id JOIN projects p ON p.id = a.project_id - WHERE p.deleted_at IS NULL AND t.name LIKE ? + WHERE p.deleted_at IS NULL AND a.deleted_at IS NULL AND t.deleted_at IS NULL AND t.name LIKE ?${scope} ORDER BY t.name LIMIT ?`, - [like, RESULT_LIMIT] + [like, ...scopeParams, RESULT_LIMIT] ); return { projects, activities, tasks }; @@ -46,7 +49,7 @@ export async function searchInProject(projectId, term) { const [activities] = await pool.query( `SELECT id, code, name FROM activities - WHERE project_id = ? AND (code LIKE ? OR name LIKE ?) + WHERE project_id = ? AND deleted_at IS NULL AND (code LIKE ? OR name LIKE ?) ORDER BY name LIMIT ?`, [projectId, like, like, RESULT_LIMIT] ); @@ -55,7 +58,7 @@ export async function searchInProject(projectId, term) { `SELECT t.id, t.name, a.id AS activity_id, a.code AS activity_code, a.name AS activity_name FROM tasks t JOIN activities a ON a.id = t.activity_id - WHERE a.project_id = ? AND t.name LIKE ? + WHERE a.project_id = ? AND a.deleted_at IS NULL AND t.deleted_at IS NULL AND t.name LIKE ? ORDER BY t.name LIMIT ?`, [projectId, like, RESULT_LIMIT] ); diff --git a/backend/src/modules/search/service.js b/backend/src/modules/search/service.js index 6a6bf48..9dfbb10 100644 --- a/backend/src/modules/search/service.js +++ b/backend/src/modules/search/service.js @@ -1,11 +1,17 @@ import * as repo from './repository.js'; +import { getProject } from '../projects/service.js'; +import { HttpError } from '../../utils/http.js'; const MIN_LENGTH = 2; -export async function search(term, projectId) { +export async function search(term, projectId, currentUser) { const q = (term || '').trim(); + if (projectId) { + if (!Number.isSafeInteger(Number(projectId)) || Number(projectId) < 1) throw new HttpError(400, 'projectId invalido'); + await getProject(Number(projectId), currentUser); + } if (q.length < MIN_LENGTH) { return projectId ? { activities: [], tasks: [] } : { projects: [], activities: [], tasks: [] }; } - return projectId ? repo.searchInProject(Number(projectId), q) : repo.searchGlobal(q); + return projectId ? repo.searchInProject(Number(projectId), q) : repo.searchGlobal(q, currentUser); } diff --git a/backend/src/modules/settings/repository.js b/backend/src/modules/settings/repository.js index be9b983..424733b 100644 --- a/backend/src/modules/settings/repository.js +++ b/backend/src/modules/settings/repository.js @@ -16,3 +16,27 @@ export async function setMany(entries) { ); } } + +// La fila de version serializa las escrituras de cada panel y los valores se cambian +// en la misma transaccion. Una version antigua no modifica ninguna clave. +export async function setManyIfVersion(entries, versionKey, editVersion) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [result] = await conn.query( + 'UPDATE system_settings SET `value` = CAST(CAST(`value` AS UNSIGNED) + 1 AS CHAR) WHERE `key` = ? AND CAST(`value` AS UNSIGNED) = ?', + [versionKey, editVersion] + ); + if (!result.affectedRows) { await conn.rollback(); return false; } + for (const [key, value] of Object.entries(entries)) { + await conn.query('INSERT INTO system_settings (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)', [key, value]); + } + await conn.commit(); + return true; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } +} diff --git a/backend/src/modules/settings/service.js b/backend/src/modules/settings/service.js index 610697f..9718c46 100644 --- a/backend/src/modules/settings/service.js +++ b/backend/src/modules/settings/service.js @@ -2,6 +2,7 @@ import nodemailer from 'nodemailer'; import * as repo from './repository.js'; import { encrypt, decrypt } from '../../utils/crypto.js'; import { logAudit } from '../../utils/audit.js'; +import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js'; import { HttpError } from '../../utils/http.js'; const SMTP_KEYS = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_from', 'smtp_pass_enc']; @@ -12,8 +13,9 @@ const SMTP_KEYS = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_from', 'smtp_pas const stripSpaces = (s) => (s || '').replace(/\s+/g, ''); export async function getSmtpConfig() { - const values = await repo.getAll(SMTP_KEYS); + const values = await repo.getAll([...SMTP_KEYS, 'smtp_edit_version']); return { + edit_version: Number(values.smtp_edit_version), host: values.smtp_host || '', port: values.smtp_port ? Number(values.smtp_port) : 587, user: values.smtp_user || '', @@ -46,6 +48,7 @@ export async function getSmtpConfigInternal() { } export async function updateSmtpConfig(payload, actorId) { + requireEditVersion(payload.editVersion); const host = stripSpaces(payload.host); const user = stripSpaces(payload.user); const from = stripSpaces(payload.from); @@ -57,7 +60,7 @@ export async function updateSmtpConfig(payload, actorId) { smtp_from: from || user, }; if (payload.pass) entries.smtp_pass_enc = encrypt(payload.pass); - await repo.setMany(entries); + requireUpdated(await repo.setManyIfVersion(entries, 'smtp_edit_version', payload.editVersion)); await logAudit({ userId: actorId, action: 'update', entityType: 'settings:smtp', entityId: null }); return getSmtpConfig(); } diff --git a/backend/src/modules/tasks/repository.js b/backend/src/modules/tasks/repository.js index 953fd29..5cf7ce0 100644 --- a/backend/src/modules/tasks/repository.js +++ b/backend/src/modules/tasks/repository.js @@ -2,7 +2,7 @@ import { pool } from '../../config/db.js'; const LIST_SELECT = ` SELECT t.id, t.activity_id, t.name, t.responsible_user_id, u.name AS responsible_name, - t.is_done, t.due_date, t.sort_order, t.created_at + t.is_done, t.due_date, t.edit_version, t.sort_order, t.created_at FROM tasks t LEFT JOIN users u ON u.id = t.responsible_user_id `; @@ -44,30 +44,30 @@ export async function create(activityId, { name, responsibleUserId, dueDate }) { return findById(result.insertId); } -export async function update(id, { name, responsibleUserId, dueDate }) { - await pool.query('UPDATE tasks SET name = ?, responsible_user_id = ?, due_date = ? WHERE id = ?', [ - name, responsibleUserId || null, dueDate || null, id, +export async function update(id, { name, responsibleUserId, dueDate, editVersion }) { + const [result] = await pool.query('UPDATE tasks SET name = ?, responsible_user_id = ?, due_date = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ? AND deleted_at IS NULL', [ + name, responsibleUserId || null, dueDate || null, id, editVersion, ]); - return findById(id); + return result.affectedRows ? findById(id) : null; } export async function setDone(id, isDone) { - await pool.query('UPDATE tasks SET is_done = ? WHERE id = ?', [isDone ? 1 : 0, id]); + await pool.query('UPDATE tasks SET is_done = ?, edit_version = edit_version + 1 WHERE id = ?', [isDone ? 1 : 0, id]); return findById(id); } export async function softDelete(id, userId) { - await pool.query('UPDATE tasks SET deleted_at = NOW(), deleted_by = ? WHERE id = ?', [userId, id]); + await pool.query('UPDATE tasks SET deleted_at = NOW(), deleted_by = ?, edit_version = edit_version + 1 WHERE id = ?', [userId, id]); } export async function restore(id) { - await pool.query('UPDATE tasks SET deleted_at = NULL, deleted_by = NULL WHERE id = ?', [id]); + await pool.query('UPDATE tasks SET deleted_at = NULL, deleted_by = NULL, edit_version = edit_version + 1 WHERE id = ?', [id]); } // Usado al convertir una actividad en proyecto: sus tareas pasan a la actividad nueva que se crea // para contenerlas. sort_order vuelve a 0 ("nunca reordenado a mano") en el destino. export async function moveAll(oldActivityId, newActivityId) { - await pool.query('UPDATE tasks SET activity_id = ?, sort_order = 0 WHERE activity_id = ?', [newActivityId, oldActivityId]); + await pool.query('UPDATE tasks SET activity_id = ?, sort_order = 0, edit_version = edit_version + 1 WHERE activity_id = ?', [newActivityId, oldActivityId]); } export async function countsByActivity(activityId) { diff --git a/backend/src/modules/tasks/service.js b/backend/src/modules/tasks/service.js index 3f62e43..08c8521 100644 --- a/backend/src/modules/tasks/service.js +++ b/backend/src/modules/tasks/service.js @@ -4,6 +4,7 @@ import * as projectsRepo from '../projects/repository.js'; import { canWriteActivity, recalcFromTasks } from '../activities/service.js'; import { canReadProject } from '../projects/service.js'; import { HttpError } from '../../utils/http.js'; +import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js'; import { logAudit } from '../../utils/audit.js'; async function requireActivityAccess(activityId, currentUser) { @@ -44,7 +45,8 @@ export async function updateTask(id, payload, currentUser) { if (!task) throw new HttpError(404, 'Tarea no encontrada'); await requireActivityAccess(task.activity_id, currentUser); if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio'); - const updated = await repo.update(id, payload); + requireEditVersion(payload.editVersion); + const updated = requireUpdated(await repo.update(id, payload)); await logAudit({ userId: currentUser.id, action: 'update', entityType: 'task', entityId: id }); return updated; } diff --git a/backend/src/modules/users/controller.js b/backend/src/modules/users/controller.js index cd7290e..019c8f8 100644 --- a/backend/src/modules/users/controller.js +++ b/backend/src/modules/users/controller.js @@ -5,22 +5,22 @@ export const getUsers = asyncHandler(async (req, res) => ok(res, await service.l export const getRoles = asyncHandler(async (req, res) => ok(res, await service.listRoles())); export const postUser = asyncHandler(async (req, res) => { - const user = await service.createUser(req.body, req.currentUser.id); + const user = await service.createUser(req.body, req.currentUser); ok(res, user, 201); }); export const putUser = asyncHandler(async (req, res) => { - const user = await service.updateUser(Number(req.params.id), req.body, req.currentUser.id); + const user = await service.updateUser(Number(req.params.id), req.body, req.currentUser); ok(res, user); }); export const patchUserActive = asyncHandler(async (req, res) => { - const user = await service.setUserActive(Number(req.params.id), !!req.body.isActive, req.currentUser.id); + const user = await service.setUserActive(Number(req.params.id), !!req.body.isActive, req.currentUser); ok(res, user); }); export const postApproveUser = asyncHandler(async (req, res) => { - const user = await service.approveUser(Number(req.params.id), req.body, req.currentUser.id); + const user = await service.approveUser(Number(req.params.id), req.body, req.currentUser); ok(res, user); }); diff --git a/backend/src/modules/users/repository.js b/backend/src/modules/users/repository.js index ae4141b..16d6385 100644 --- a/backend/src/modules/users/repository.js +++ b/backend/src/modules/users/repository.js @@ -3,7 +3,7 @@ import { pool } from '../../config/db.js'; export async function findAll() { const [rows] = await pool.query( `SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, r.name AS role_name, - u.department_id, d.name AS department_name, u.position, u.is_active, u.status, u.created_at + u.department_id, d.name AS department_name, u.position, u.is_active, u.status, u.edit_version, u.created_at FROM users u JOIN roles r ON r.id = u.role_id LEFT JOIN departments d ON d.id = u.department_id @@ -14,7 +14,7 @@ export async function findAll() { export async function findById(id) { const [rows] = await pool.query( - `SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, u.department_id, u.position, u.is_active, u.status + `SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, u.department_id, u.position, u.is_active, u.status, u.edit_version FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`, [id] ); @@ -51,26 +51,26 @@ export async function create({ name, email, passwordHash, roleId, departmentId, return findById(result.insertId); } -export async function update(id, { name, roleId, departmentId, position }) { - await pool.query( - `UPDATE users SET name = ?, role_id = ?, department_id = ?, position = ? WHERE id = ?`, - [name, roleId, departmentId || null, position || null, id] +export async function update(id, { name, roleId, departmentId, position, editVersion }) { + const [result] = await pool.query( + `UPDATE users SET name = ?, role_id = ?, department_id = ?, position = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?`, + [name, roleId, departmentId || null, position || null, id, editVersion] ); - return findById(id); + return result.affectedRows ? findById(id) : null; } export async function setActive(id, isActive) { - await pool.query('UPDATE users SET is_active = ? WHERE id = ?', [isActive ? 1 : 0, id]); + await pool.query('UPDATE users SET is_active = ?, edit_version = edit_version + 1 WHERE id = ?', [isActive ? 1 : 0, id]); return findById(id); } -export async function approve(id, { roleId, departmentId }) { - await pool.query( - `UPDATE users SET status = 'active', is_active = 1, role_id = COALESCE(?, role_id), department_id = COALESCE(?, department_id) - WHERE id = ?`, - [roleId || null, departmentId || null, id] +export async function approve(id, { roleId, departmentId, editVersion }) { + const [result] = await pool.query( + `UPDATE users SET status = 'active', is_active = 1, edit_version = edit_version + 1, role_id = COALESCE(?, role_id), department_id = COALESCE(?, department_id) + WHERE id = ? AND edit_version = ? AND status = 'pending_approval'`, + [roleId || null, departmentId || null, id, editVersion] ); - return findById(id); + return result.affectedRows ? findById(id) : null; } export async function remove(id) { diff --git a/backend/src/modules/users/service.js b/backend/src/modules/users/service.js index ee3b462..cc62c0f 100644 --- a/backend/src/modules/users/service.js +++ b/backend/src/modules/users/service.js @@ -2,9 +2,11 @@ import crypto from 'node:crypto'; import bcrypt from 'bcryptjs'; 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 { sendMail } from '../../utils/mailer.js'; import { env } from '../../config/env.js'; +import { invitationEmail, verificationEmail } from '../../utils/emailHtml.js'; export const listUsers = () => repo.findAll(); export const listRoles = () => repo.listRoles(); @@ -28,41 +30,56 @@ export async function inviteUser(email, actorId) { await sendMail({ to: email, subject: 'Te invitaron a project.linkedcorp.net', - html: `

Te agregaron como miembro de un proyecto en project.linkedcorp.net.

-

Completa tu registro para acceder (elige tu nombre y una clave):

-

${inviteUrl}

Este enlace vence en 7 dias.

`, + html: invitationEmail(inviteUrl), }); return { userId, invited: true }; } -export async function createUser(payload, actorId) { +async function checkRole(roleId, actor) { + const role = (await repo.listRoles()).find((item) => item.id === Number(roleId)); + if (!role) throw new HttpError(400, 'Rol invalido'); + if (role.code === 'superadmin' && actor.roleCode !== 'superadmin') throw new HttpError(403, 'No puedes asignar ese rol'); +} + +function checkTarget(target, actor) { + if (target.role_code === 'superadmin' && actor.roleCode !== 'superadmin') { + throw new HttpError(403, 'No puedes modificar un superadministrador'); + } +} + +export async function createUser(payload, actor) { if (!payload.name || !payload.email || !payload.password || !payload.roleId) { throw new HttpError(400, 'Nombre, email, clave y rol son obligatorios'); } const existing = await repo.findByEmail(payload.email); if (existing) throw new HttpError(400, 'Ya existe un usuario con ese email'); + await checkRole(payload.roleId, actor); const passwordHash = await bcrypt.hash(payload.password, 12); const user = await repo.create({ ...payload, passwordHash }); - await logAudit({ userId: actorId, action: 'create', entityType: 'user', entityId: user.id }); + await logAudit({ userId: actor.id, action: 'create', entityType: 'user', entityId: user.id }); return user; } -export async function updateUser(id, payload, actorId) { +export async function updateUser(id, payload, actor) { const existing = await repo.findById(id); if (!existing) throw new HttpError(404, 'Usuario no encontrado'); - const user = await repo.update(id, payload); - await logAudit({ userId: actorId, action: 'update', entityType: 'user', entityId: id }); + checkTarget(existing, actor); + await checkRole(payload.roleId, actor); + requireEditVersion(payload.editVersion); + const user = requireUpdated(await repo.update(id, payload)); + await logAudit({ userId: actor.id, action: 'update', entityType: 'user', entityId: id }); return user; } -export async function setUserActive(id, isActive, actorId) { +export async function setUserActive(id, isActive, actor) { const existing = await repo.findById(id); if (!existing) throw new HttpError(404, 'Usuario no encontrado'); + checkTarget(existing, actor); const user = await repo.setActive(id, isActive); await logAudit({ - userId: actorId, + userId: actor.id, action: isActive ? 'activate' : 'deactivate', entityType: 'user', entityId: id, @@ -70,12 +87,16 @@ export async function setUserActive(id, isActive, actorId) { return user; } -export async function approveUser(id, payload, actorId) { +export async function approveUser(id, payload, actor) { const existing = await repo.findById(id); if (!existing) throw new HttpError(404, 'Usuario no encontrado'); + checkTarget(existing, actor); + requireEditVersion(payload.editVersion); + if (existing.edit_version !== payload.editVersion) requireUpdated(null); if (existing.status !== 'pending_approval') throw new HttpError(400, 'El usuario no esta pendiente de aprobacion'); - const user = await repo.approve(id, payload); - await logAudit({ userId: actorId, action: 'approve', entityType: 'user', entityId: id }); + if (payload.roleId) await checkRole(payload.roleId, actor); + const user = requireUpdated(await repo.approve(id, payload)); + await logAudit({ userId: actor.id, action: 'approve', entityType: 'user', entityId: id }); return user; } @@ -99,9 +120,7 @@ export async function resendUserEmail(id, actorId) { await sendMail({ to: existing.email, subject: 'Te invitaron a project.linkedcorp.net', - html: `

Te agregaron como miembro de un proyecto en project.linkedcorp.net.

-

Completa tu registro para acceder (elige tu nombre y una clave):

-

${inviteUrl}

Este enlace vence en 7 dias.

`, + html: invitationEmail(inviteUrl), }); } else { await repo.refreshVerificationToken(id, token, 24); @@ -109,7 +128,7 @@ export async function resendUserEmail(id, actorId) { await sendMail({ to: existing.email, subject: 'Confirma tu correo — project.linkedcorp.net', - html: `

Hola ${existing.name},

Confirma tu correo para continuar tu registro:

${verifyUrl}

Este enlace vence en 24 horas.

`, + html: verificationEmail(existing.name, verifyUrl), }); } diff --git a/backend/src/modules/vault/service.js b/backend/src/modules/vault/service.js index f3dceed..0c77426 100644 --- a/backend/src/modules/vault/service.js +++ b/backend/src/modules/vault/service.js @@ -8,6 +8,7 @@ import { encrypt, decrypt } from '../../utils/crypto.js'; import { HttpError } from '../../utils/http.js'; import { logAudit } from '../../utils/audit.js'; import { sendMail } from '../../utils/mailer.js'; +import { escapeHtml } from '../../utils/emailHtml.js'; const PIN_RE = /^\d{4}$/; const VAULT_TOKEN_EXPIRES = '30m'; @@ -272,11 +273,11 @@ export async function forgotPin(currentUser) { await sendMail({ to: currentUser.email, subject: 'Restablecer PIN de Contraseñas — project.linkedcorp.net', - html: `

Hola ${currentUser.name},

+ html: `

Hola ${escapeHtml(currentUser.name)},

Pediste restablecer el PIN de acceso a tu repositorio de Contraseñas. Esto solo cambia el PIN -- tus contraseñas guardadas no se tocan ni se pierden.

Elegi un PIN nuevo en este enlace (vence en 1 hora):

-

${resetUrl}

+

${escapeHtml(resetUrl)}

Si no pediste esto, ignora el correo.

`, }); } diff --git a/backend/src/scripts/create-admin.js b/backend/src/scripts/create-admin.js index 3fcf5b2..3aeb68e 100644 --- a/backend/src/scripts/create-admin.js +++ b/backend/src/scripts/create-admin.js @@ -30,7 +30,7 @@ async function run() { await db.query( `INSERT INTO users (name, email, password_hash, role_id, department_id, position, is_active) VALUES (?, ?, ?, ?, NULL, NULL, 1) - ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), is_active = 1`, + ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), session_version = session_version + 1, is_active = 1`, [name, email, passwordHash, adminRole[0].id] ); diff --git a/backend/src/utils/editVersion.js b/backend/src/utils/editVersion.js new file mode 100644 index 0000000..5b6bc4d --- /dev/null +++ b/backend/src/utils/editVersion.js @@ -0,0 +1,11 @@ +import { HttpError } from './http.js'; + +export function requireEditVersion(value) { + if (!Number.isSafeInteger(value) || value < 1) throw new HttpError(428, 'Falta la version de edicion'); + return value; +} + +export function requireUpdated(row) { + if (!row) throw new HttpError(409, 'El registro cambio en el servidor. Recarga antes de guardar'); + return row; +} diff --git a/backend/src/utils/emailHtml.js b/backend/src/utils/emailHtml.js new file mode 100644 index 0000000..af0dae1 --- /dev/null +++ b/backend/src/utils/emailHtml.js @@ -0,0 +1,20 @@ +export function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]); +} + +export function invitationEmail(url) { + const safeUrl = escapeHtml(url); + return `

Te agregaron como miembro de un proyecto en project.linkedcorp.net.

+

Completa tu registro para acceder (elige tu nombre y una clave):

+

${safeUrl}

Este enlace vence en 7 dias.

`; +} + +export function verificationEmail(name, url) { + const safeUrl = escapeHtml(url); + return `

Hola ${escapeHtml(name)},

Confirma tu correo para continuar tu registro:

${safeUrl}

Este enlace vence en 24 horas.

`; +} + +export function recoveryEmail(name, url) { + const safeUrl = escapeHtml(url); + return `

Hola ${escapeHtml(name)},

Para elegir una nueva clave entra a este enlace (vence en 1 hora):

${safeUrl}

Si no pediste esto, ignora el correo.

`; +} diff --git a/backend/src/utils/mailer.js b/backend/src/utils/mailer.js index de1479f..1c8c211 100644 --- a/backend/src/utils/mailer.js +++ b/backend/src/utils/mailer.js @@ -4,24 +4,24 @@ import { getSmtpConfigInternal } from '../modules/settings/service.js'; // Degradacion a modo dev cuando el SMTP no esta configurado (regla del orquestador): en vez de // fallar, se loguea el contenido/enlace y el flujo que llamo a esto sigue funcionando. export async function sendMail({ to, subject, html }) { - const config = await getSmtpConfigInternal(); + try { + const config = await getSmtpConfigInternal(); - if (!config) { - console.log(`[mail:dev] to=${to} subject="${subject}"\n${html}`); - return { sent: false, dev: true }; - } + if (!config) { + console.log(`[mail:dev] to=${to} subject="${subject}"\n${html}`); + return { sent: false, dev: true }; + } - const transporter = nodemailer.createTransport({ - host: config.host, - port: config.port, - secure: false, - requireTLS: true, - auth: { user: config.user, pass: config.pass }, - }); + const transporter = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: false, + requireTLS: true, + auth: { user: config.user, pass: config.pass }, + }); // Nunca dejar que un fallo de envio (SMTP mal configurado, host caido) tumbe al que llama — // critico para forgot-password, que siempre debe responder generico sin importar el resultado. - try { await transporter.sendMail({ from: config.from, to, subject, html }); return { sent: true, dev: false }; } catch (err) { diff --git a/backend/src/utils/redactUrl.js b/backend/src/utils/redactUrl.js new file mode 100644 index 0000000..53bac15 --- /dev/null +++ b/backend/src/utils/redactUrl.js @@ -0,0 +1,12 @@ +export function redactUrl(url = '') { + const value = String(url); + try { + const parsed = new URL(value, 'http://local.invalid'); + for (const key of parsed.searchParams.keys()) { + if (key.toLowerCase() === 'token') parsed.searchParams.set(key, '[REDACTED]'); + } + return parsed.pathname + parsed.search + parsed.hash; + } catch { + return value.replace(/([?&]token=)[^&#]*/gi, '$1[REDACTED]'); + } +} diff --git a/docs/ARQUITECTURA-INVENTARIO.md b/docs/ARQUITECTURA-INVENTARIO.md index ddb6c7a..ab60179 100644 --- a/docs/ARQUITECTURA-INVENTARIO.md +++ b/docs/ARQUITECTURA-INVENTARIO.md @@ -24,13 +24,22 @@ Navegador (superadmin/admin/it_manager) --JWT--> /api/devices -> menú "Equipos - **Handshake:** el agente primero manda un `contact` (JSON) o `PROLOG` (XML). Se responde sin guardar nada (`{"status":"ok","expiration":"24"}` o `SEND…`). -- **Ingesta autenticada por token**, no por JWT: tabla `inventory_tokens` (solo hash sha256). El +- **Ingesta autenticada por token**, no por JWT: tabla `inventory_tokens` (hash sha256 para validar + y copia cifrada para recuperar el instalador). El agente lo manda como contraseña Basic (`user`/`password` del `agent.cfg`), `Bearer`, `X-Ingest-Token` o `?token=`. Sin token válido → `401`. +- Compatibilidad: `?token=` sigue aceptado para agentes instalados; la aplicación censura ese + parámetro en sus logs. El proxy inverso debe censurarlo también en sus propios access logs. +- **Pendiente de seguridad:** hoy un instalador puede usar el mismo token en varios equipos. + Vincular automáticamente token y `deviceid` rompería esos agentes ya instalados. Antes de + activarlo se necesita inventariar instalaciones y migrar a un token por equipo; luego añadir + la asociación y rechazar inventarios de otro `deviceid`. - **Rutas:** `POST /api/inventory` (propia; pasa por el Custom Location `/api` de NPM sin cambios) y alias `POST /front/inventory.php` (ruta nativa de GLPI). Se montan ANTES de `express.json` porque usan cuerpo crudo. - **Límites:** cuerpo máx. 15 MB (`INVENTORY_MAX_BYTES`), inflado máx. 4× (anti zip-bomb). +- Cada IP tiene límite de 120 peticiones/minuto; máximo dos ingestiones simultáneas. El token se + valida antes de leer el cuerpo y el parseo se ejecuta en un worker con límite de tiempo. - **Upsert por `deviceid`** (estable), nunca por hostname. Componentes se reemplazan completos en una transacción; el diff (RAM, disco agregado/retirado, software instalado/desinstalado, SO, hostname) queda en `device_changes`. diff --git a/docs/SEGURIDAD-Y-CALIDAD.md b/docs/SEGURIDAD-Y-CALIDAD.md index c0a5b2d..25c5d00 100644 --- a/docs/SEGURIDAD-Y-CALIDAD.md +++ b/docs/SEGURIDAD-Y-CALIDAD.md @@ -58,6 +58,10 @@ - **Rate limiting** en `POST /api/auth/login`. - Sirviendo la SPA: CSP (helmet) — `script-src 'self'` (sin inline), `style-src 'self' 'unsafe-inline'`, `img-src 'self' data: blob:`, `object-src 'none'`. +- La SPA de producción recibe CSP desde `frontend/public/serve.json` (el servidor `serve` copia + ese archivo al build); el backend usa Helmet en `/api`. En Vite dev no se aplica el header del + servidor `serve`, para permitir el módulo de recarga de React. El logo de empresa solo admite + PNG/JPG/WEBP comprobados por firma y se entrega con `nosniff`. ### 1.6 Frontend / caché - El JWT en `localStorage` es un trade-off aceptado; mantener CSP y sanitización. diff --git a/docs/SESION-Y-BORRADORES.md b/docs/SESION-Y-BORRADORES.md new file mode 100644 index 0000000..d0f6e50 --- /dev/null +++ b/docs/SESION-Y-BORRADORES.md @@ -0,0 +1,69 @@ +# Sesión, retorno al contexto y borradores + +Solución compartida para toda la app (frontend). Los proyectos y comentarios también verifican +la versión en el backend (migración `029_optimistic_versions.sql`). + +## Piezas + +| Archivo | Qué hace | +|---|---| +| `utils/jwt.js` | Lee `exp` y `sub` del JWT (sin verificar firma; eso lo hace el backend). | +| `context/auth-context.jsx` | Vigila el vencimiento: temporizador al instante exacto, chequeo cada 30 s y al volver a la pestaña/ventana (`visibilitychange`, `focus`, `pageshow`). El navegador congela temporizadores en segundo plano, por eso el chequeo al volver es el que manda. | +| `services/api.js` | Nunca envía una petición con token vencido: corta la sesión antes. Un `401` con token también corta. | +| `utils/session.js` | Al vencer guarda `{ userId, ruta completa }` (`plc_return_ctx`, 24 h). Tras el login solo se usa si entra **el mismo usuario**; otro usuario la descarta. | +| `utils/drafts.js` + `hooks/useDraftForm.js` | Borradores por formulario (ver abajo). | +| `components/NavigationGuard.jsx` | Aviso al salir con cambios (`useBlocker`), advertencia nativa en F5/cierre, `DraftMark`, `useGuardedClose`. | +| `components/DraftNotice.jsx` | Aviso dentro del formulario: cambios recuperados / conflicto con el servidor. | +| `app/App.jsx` | Router de datos (`createBrowserRouter`, mismas rutas): requisito de `useBlocker`. | + +Pestañas y vistas que viven en la URL (para volver exactamente ahí): proyecto `?tab=`, actividad +abierta `?activity=&activityTab=`, Organización `?tab=`, Equipos `?tab=&device=&deviceTab=`. + +## Comportamiento + +- **Vencimiento:** se cierra la sesión al instante (aunque no haya peticiones) y aparece el login con + el aviso "Tu sesión venció…". Al volver a entrar, el mismo usuario regresa a la misma ruta, pestaña + y popup, con la edición reabierta y sus cambios. Salir con "Cerrar sesión" no guarda ruta de retorno. +- **Borradores:** clave `plc_draft:v1:::` en `localStorage`, vigencia 7 + días. Solo se guardan los campos permitidos de cada formulario (`draftAllowedFields`); el texto + libre de notas, comentarios, descripciones y observaciones no se conserva. Sobreviven a cambiar + de pestaña/menú, F5 y vencimiento. Se borran al guardar, descartar o cerrar sesión + voluntariamente. Solo se restauran si el registro sigue en lo que el backend + devuelve para ese usuario (si se borró o perdió acceso, no se abre nada). +- **Secretos:** nunca se guardan campos cuyo nombre parezca contraseña/token/PIN, archivos ni + campos fuera de la lista permitida. Al leer borradores antiguos se eliminan los campos excluidos. +- **Marcas:** campo modificado en rojo con el texto "· modificado"; pestaña, sección plegable, + botón "Nuevo …", tarjeta (Notas) y menú lateral con punto rojo + texto para lector de pantalla. +- **Salir con cambios** (menú, pestaña, cerrar popup, plegar sección): diálogo *Seguir editando* / + *Dejar pendiente* / *Descartar cambios*. Con "Dejar pendiente", al volver se reabre la edición con + los valores recuperados. F5 o cerrar la pestaña: advertencia nativa del navegador. +- **Integridad:** los borradores no se guardan automáticamente en el backend; Guardar envía los + campos del formulario con la versión leída. Si el registro cambió, se conserva el borrador y se + pide recargar para revisar la versión actual. En Info de proyecto/actividad, Equipos, SMTP y + Empresa además se consulta el servidor justo antes de guardar. + +## Formularios sin recuperación (a propósito) + +| Formulario | Motivo | +|---|---| +| Contraseñas (todo el módulo, incluido PIN y grupos) | Los datos están protegidos por PIN y cifrados; guardarlos en el navegador saltaría esa protección. | +| Cambiar contraseña (menú del usuario) | Solo contiene secretos. | +| Login, registro, olvidé/restablecer clave, aceptar invitación, restablecer PIN | Pantallas públicas: no hay usuario autenticado para aislar el borrador y contienen claves/tokens. | +| Documentos (subir archivo), logo y nombre de Empresa | Archivos y texto libre no se guardan en el navegador. | +| Equipos → Agentes (token de instalación) | Token secreto; generar uno es una acción de un paso. | +| Mover actividad, agregar dependencia, compartir nota, compartir Equipos | Acciones de una sola elección, sin captura de datos: se repiten en un clic. | +| Clave y texto libre en "Nuevo usuario"; clave SMTP | Solo se recuperan rol/departamento; las claves y el texto libre se vuelven a escribir. | +| Apariencia, filtros, selector de tarjetas del Panel | Se aplican al instante; no queda nada pendiente. | + +## Límites conocidos + +- Los borradores son de **este navegador**: no se sincronizan entre equipos. +- En un equipo compartido, los borradores de un usuario quedan en el navegador hasta guardarlos, + descartarlos, cerrar sesión voluntariamente o que venzan (7 días). Tras vencimiento de sesión + se conservan para permitir el retorno al contexto. +- Todos los formularios de edición de entidades, empresa y SMTP envían `editVersion`; el backend + rechaza con `409` una versión obsoleta. El borrador se conserva y el aviso pide revisar la + versión actual. Miembros no edita una entidad mediante formulario: sus acciones aplican cambios + puntuales de acceso. +- Los popups son modales: con uno abierto no se puede usar el menú lateral; se sale cerrándolo (pasa + por el mismo aviso) o con atrás del navegador. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dcc6a72..c289643 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "project-linkedcorp-frontend", - "version": "2.9.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "project-linkedcorp-frontend", - "version": "2.9.0", + "version": "3.0.0", "dependencies": { "dompurify": "^3.4.16", "highlight.js": "^11.12.0", diff --git a/frontend/package.json b/frontend/package.json index 530ebd3..b69952d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "2.19.0", + "version": "3.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/public/projectLinkedcorp.png b/frontend/public/projectLinkedcorp.png new file mode 100644 index 0000000..a5f39a0 Binary files /dev/null and b/frontend/public/projectLinkedcorp.png differ diff --git a/frontend/public/serve.json b/frontend/public/serve.json index 559e662..c975545 100644 --- a/frontend/public/serve.json +++ b/frontend/public/serve.json @@ -1,5 +1,9 @@ { "headers": [ + { + "source": "**", + "headers": [{ "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" }] + }, { "source": "**/*.@(html|webmanifest)", "headers": [{ "key": "Cache-Control", "value": "no-cache" }] diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index 4567907..baa686f 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -1,4 +1,4 @@ -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { createBrowserRouter, createRoutesFromElements, RouterProvider, Route } from 'react-router-dom'; import { AuthProvider } from '../context/auth-context.jsx'; import { UiProvider } from '../context/ui-context.jsx'; import { BrandingProvider } from '../context/branding-context.jsx'; @@ -24,34 +24,38 @@ import ContrasenasPage from '../pages/Contrasenas/ContrasenasPage.jsx'; import EquiposPage from '../pages/Equipos/EquiposPage.jsx'; import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx'; +// Router de datos (createBrowserRouter): necesario para useBlocker, que avisa al salir de una +// seccion con cambios sin guardar (ver components/NavigationGuard.jsx). Mismas rutas de antes. +const router = createBrowserRouter(createRoutesFromElements( + <> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + +)); + export default function App() { return ( - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + diff --git a/frontend/src/app/AppShell.jsx b/frontend/src/app/AppShell.jsx index caf6fec..127643d 100644 --- a/frontend/src/app/AppShell.jsx +++ b/frontend/src/app/AppShell.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { Outlet, useNavigate } from 'react-router-dom'; import { Menu, Sun, Moon, Bell, LogOut, User, Globe, KeyRound, Save } from 'lucide-react'; import { NavigationView } from '../components/NavigationView.jsx'; +import { NavigationGuard } from '../components/NavigationGuard.jsx'; import { Flyout, FlyoutItem, FlyoutSeparator } from '../components/Flyout.jsx'; import { DetailPopup } from '../components/DetailPopup.jsx'; import { Field } from '../components/Field.jsx'; @@ -167,6 +168,7 @@ export default function AppShell() {
+
© {new Date().getFullYear()} {t('appName')} diff --git a/frontend/src/app/ProtectedRoute.jsx b/frontend/src/app/ProtectedRoute.jsx index 7fa1c85..8451f00 100644 --- a/frontend/src/app/ProtectedRoute.jsx +++ b/frontend/src/app/ProtectedRoute.jsx @@ -1,9 +1,11 @@ -import { Navigate } from 'react-router-dom'; +import { Navigate, useLocation } from 'react-router-dom'; import { useAuth } from '../context/auth-context.jsx'; export function ProtectedRoute({ children }) { const { user, ready } = useAuth(); + const location = useLocation(); if (!ready) return null; - if (!user) return ; + // `from`: para volver a la ruta que se intentaba abrir despues de iniciar sesion. + if (!user) return ; return children; } diff --git a/frontend/src/components/ComboBox.jsx b/frontend/src/components/ComboBox.jsx index c033a1e..f5f4e71 100644 --- a/frontend/src/components/ComboBox.jsx +++ b/frontend/src/components/ComboBox.jsx @@ -1,15 +1,18 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ChevronDown, Search } from 'lucide-react'; +import { useUi } from '../context/ui-context.jsx'; // Combo con buscador (regla del grupo: nunca el setEditingText(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') saveEdit(c.id); if (e.key === 'Escape') cancelEdit(); }} /> @@ -105,7 +127,11 @@ export function CommentsPanel({ entityType, entityId, onChanged }) {
))}
- setMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()} /> + setMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()} + />
diff --git a/frontend/src/components/ContentDialog.jsx b/frontend/src/components/ContentDialog.jsx index 4e53f26..32bb168 100644 --- a/frontend/src/components/ContentDialog.jsx +++ b/frontend/src/components/ContentDialog.jsx @@ -1,6 +1,8 @@ import { createPortal } from 'react-dom'; import { useUi } from '../context/ui-context.jsx'; import { Button } from './Button.jsx'; +import { useId } from 'react'; +import { useModalFocus } from '../hooks/useModalFocus.js'; const STRIPE_COLOR = { info: 'accent', success: 'success', warning: 'warning', error: 'danger' }; @@ -8,6 +10,8 @@ const STRIPE_COLOR = { info: 'accent', success: 'success', warning: 'warning', e // openDialog({ tone, title, message, actions: [{ label, variant, keepOpen, onClick }] }) export function ContentDialogHost() { const { dialog, closeDialog, t } = useUi(); + const titleId = useId(); + const modalRef = useModalFocus(!!dialog); if (!dialog) return null; const tone = dialog.tone || 'info'; @@ -23,10 +27,10 @@ export function ContentDialogHost() { // encima de cualquier DetailPopup abierto, sin depender del punto del arbol donde se monta. return createPortal(
-
+
-
{dialog.title}
+
{dialog.title}
{dialog.message}
diff --git a/frontend/src/components/DetailPopup.jsx b/frontend/src/components/DetailPopup.jsx index 28a0f0c..9a360f7 100644 --- a/frontend/src/components/DetailPopup.jsx +++ b/frontend/src/components/DetailPopup.jsx @@ -1,5 +1,8 @@ import { createPortal } from 'react-dom'; import { X } from 'lucide-react'; +import { useId } from 'react'; +import { useModalFocus } from '../hooks/useModalFocus.js'; +import { useUi } from '../context/ui-context.jsx'; // Popup grande para ver/editar el detalle de un elemento de un listado (reemplaza el patron // "panel debajo del listado"). Reglas duras: @@ -9,13 +12,16 @@ import { X } from 'lucide-react'; // (ej. Info/Comentarios/Documentos) nunca debe cambiar su tamaño — solo el contenido interno // hace scroll. `height` es fijo por instancia; ajustar via prop segun cuanto contenido espera. export function DetailPopup({ open, title, onClose, children, width = 720, height = 640 }) { + const titleId = useId(); + const { t } = useUi(); + const modalRef = useModalFocus(open, onClose); if (!open) return null; return createPortal(
-
+
-
{title}
- +
{title}
+
{children}
diff --git a/frontend/src/components/DraftNotice.jsx b/frontend/src/components/DraftNotice.jsx new file mode 100644 index 0000000..6dc8daa --- /dev/null +++ b/frontend/src/components/DraftNotice.jsx @@ -0,0 +1,39 @@ +import { useUi } from '../context/ui-context.jsx'; +import { InfoBar } from './InfoBar.jsx'; +import { Button } from './Button.jsx'; + +// Aviso dentro de un formulario con borrador (ver hooks/useDraftForm.js): +// - conflicto: el registro cambio en el servidor desde que se empezo a editar. +// - recuperado: se restauraron cambios sin guardar de una sesion/visita anterior. +export function DraftNotice({ draft }) { + const { t } = useUi(); + if (!draft) return null; + if (draft.conflict) { + return ( + +
{t(draft.values?.editVersion ? 'draft_conflict_reload_msg' : 'draft_conflict_msg')}
+
+ {draft.values?.editVersion ? ( + + ) : ( + <> + + + + )} +
+
+ ); + } + if (draft.restored && draft.isDirty) { + return ( + +
+ {t('draft_restored_msg')} + +
+
+ ); + } + return null; +} diff --git a/frontend/src/components/Expander.jsx b/frontend/src/components/Expander.jsx index 300aa92..d1dc075 100644 --- a/frontend/src/components/Expander.jsx +++ b/frontend/src/components/Expander.jsx @@ -1,18 +1,36 @@ import { useState } from 'react'; import { ChevronRight } from 'lucide-react'; +import { useUserDrafts } from '../hooks/useDraftForm.js'; +import { getActiveEditors } from '../utils/drafts.js'; +import { DraftMark, useLeaveConfirm } from './NavigationGuard.jsx'; + +// draftForms: prefijos de formId de los formularios que viven dentro de esta seccion. Con eso la +// seccion se marca si tiene borradores, se abre sola al volver, y al plegarla con cambios en +// edicion pide confirmacion (plegar desmonta el contenido). +export function Expander({ icon: Icon, title, description, defaultOpen = false, draftForms, children }) { + const drafts = useUserDrafts(); + const matches = (formId) => (draftForms || []).some((p) => formId.startsWith(p)); + const hasDraft = drafts.some((d) => matches(d.formId)); + const [open, setOpen] = useState(defaultOpen || hasDraft); + const confirmLeave = useLeaveConfirm(); + + async function toggle() { + if (!open) { setOpen(true); return; } + const editors = getActiveEditors().filter((e) => (draftForms || []).some((p) => e.key.includes(`:${p}`))); + if (editors.length && (await confirmLeave(editors)) === 'stay') return; + setOpen(false); + } -export function Expander({ icon: Icon, title, description, defaultOpen = false, children }) { - const [open, setOpen] = useState(defaultOpen); return (
-
setOpen((v) => !v)}> +
+ {open &&
{children}
}
); diff --git a/frontend/src/components/Field.jsx b/frontend/src/components/Field.jsx index 656506e..79c30b1 100644 --- a/frontend/src/components/Field.jsx +++ b/frontend/src/components/Field.jsx @@ -1,8 +1,22 @@ -export function Field({ label, children, hint }) { +import { useUi } from '../context/ui-context.jsx'; +import { cloneElement, isValidElement, useId } from 'react'; +import { ComboBox } from './ComboBox.jsx'; + +// dirty: el valor difiere de lo guardado (borrador pendiente). Se marca en rojo y con texto para +// lectores de pantalla, no solo con color. +export function Field({ label, children, hint, dirty }) { + const { t } = useUi(); + const controlId = useId(); + const control = isValidElement(children) && (['input', 'textarea', 'select'].includes(children.type) || children.type === ComboBox); return ( -
- {label && } - {children} +
+ {label && ( + control ? :
{label}{dirty && · {t('draft_field_modified')}}
+ )} + {control ? cloneElement(children, { id: children.props.id || controlId }) : children} {hint && {hint}}
); diff --git a/frontend/src/components/ListView.jsx b/frontend/src/components/ListView.jsx index 7aa61d8..c14d59d 100644 --- a/frontend/src/components/ListView.jsx +++ b/frontend/src/components/ListView.jsx @@ -45,8 +45,8 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC {dragEnabled && } {columns.map((col) => ( - col.sortable !== false && toggleSort(col.key)}> - {col.label}{sort.key === col.key ? (sort.dir === 1 ? ' ▲' : ' ▼') : ''} + + {col.sortable === false ? col.label : } ))} {rowActions && } @@ -57,6 +57,8 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC onRowClick?.(row)} + tabIndex={onRowClick ? 0 : undefined} + onKeyDown={(event) => { if (onRowClick && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); onRowClick(row); } }} className={dragEnabled && isDragging(getRowKey(row)) ? 'dragging' : ''} {...(dragEnabled ? getRowProps(row) : {})} > @@ -71,7 +73,7 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC {rowActions && ( e.stopPropagation()}> ( - + )}> {rowActions(row).map((action, i) => ( diff --git a/frontend/src/components/NavigationGuard.jsx b/frontend/src/components/NavigationGuard.jsx new file mode 100644 index 0000000..f84e76e --- /dev/null +++ b/frontend/src/components/NavigationGuard.jsx @@ -0,0 +1,94 @@ +import { useCallback, useEffect } from 'react'; +import { useBlocker } from 'react-router-dom'; +import { useUi } from '../context/ui-context.jsx'; +import { getActiveEditors, isGuardBypassed } from '../utils/drafts.js'; + +const urlChanged = (a, b) => a.pathname !== b.pathname || a.search !== b.search; + +// Aviso de tres opciones cuando se sale de una seccion con cambios sin guardar. Devuelve +// 'stay' | 'keep' | 'discard'. Reutilizable para navegacion (menu/pestañas) y para cerrar popups. +export function useLeaveConfirm() { + const { t, openDialog } = useUi(); + return useCallback((editors) => new Promise((resolve) => { + const names = editors.map((e) => e.label).filter(Boolean); + openDialog({ + tone: 'warning', + title: t('draft_leave_title'), + message: ( +
+
{t('draft_leave_msg')}
+ {names.length > 0 && ( +
    + {names.map((n) =>
  • {n}
  • )} +
+ )} +
{t('draft_leave_hint')}
+
+ ), + actions: [ + { label: t('draft_discard'), variant: 'danger', onClick: () => { editors.forEach((e) => e.discard?.()); resolve('discard'); } }, + { label: t('draft_keep_pending'), variant: 'standard', onClick: () => resolve('keep') }, + { label: t('draft_keep_editing'), variant: 'accent', onClick: () => resolve('stay') }, + ], + }); + }), [openDialog, t]); +} + +// Cierre de un popup/editor con cambios: mismo aviso de tres opciones que al cambiar de menu. +// Uso: const guardedClose = useGuardedClose(); ... onClose={() => guardedClose(draft, () => setOpen(false))} +export function useGuardedClose() { + const confirmLeave = useLeaveConfirm(); + return useCallback(async (draft, close) => { + if (!draft?.isDirty) { draft?.discard?.(); close(); return; } + const choice = await confirmLeave([{ label: draft.label, discard: draft.discard }]); + if (choice !== 'stay') close(); + }, [confirmLeave]); +} + +// Montado una vez dentro del area autenticada (AppShell). Intercepta cambios de ruta o de +// parametros (?tab=, ?activity=, menu lateral, atras/adelante) mientras haya un formulario con +// cambios, y avisa con la advertencia nativa del navegador ante F5 o cierre de la pestaña. +export function NavigationGuard() { + const confirmLeave = useLeaveConfirm(); + const blocker = useBlocker(({ currentLocation, nextLocation }) => ( + !isGuardBypassed() && urlChanged(currentLocation, nextLocation) && getActiveEditors().length > 0 + )); + + useEffect(() => { + if (blocker.state !== 'blocked') return; + let active = true; + confirmLeave(getActiveEditors()).then((choice) => { + if (!active) return; + if (choice === 'stay') blocker.reset(); + else blocker.proceed(); + }); + return () => { active = false; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [blocker.state]); + + useEffect(() => { + function onBeforeUnload(e) { + if (isGuardBypassed() || getActiveEditors().length === 0) return; + // Los cambios ya quedan como borrador; la advertencia nativa evita una salida por error. + e.preventDefault(); + e.returnValue = ''; + } + window.addEventListener('beforeunload', onBeforeUnload); + return () => window.removeEventListener('beforeunload', onBeforeUnload); + }, []); + + return null; +} + +// Marca accesible de "cambios sin guardar" para pestañas, menus, tarjetas y titulos: punto rojo +// + texto para lectores de pantalla (no depende solo del color). +export function DraftMark({ title }) { + const { t } = useUi(); + const text = title || t('draft_pending_mark'); + return ( + + + ); +} diff --git a/frontend/src/components/NavigationView.jsx b/frontend/src/components/NavigationView.jsx index df0f86d..9f0d089 100644 --- a/frontend/src/components/NavigationView.jsx +++ b/frontend/src/components/NavigationView.jsx @@ -1,4 +1,6 @@ import { NavLink } from 'react-router-dom'; +import { useUserDrafts } from '../hooks/useDraftForm.js'; +import { DraftMark } from './NavigationGuard.jsx'; import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound, Monitor } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useUi } from '../context/ui-context.jsx'; @@ -34,6 +36,10 @@ export function NavigationView({ mode, mobileOpen, onNavigate }) { }, [user?.id]); // eslint-disable-line react-hooks/exhaustive-deps const items = ITEMS.filter((item) => (!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(); + const hasDraftUnder = (to) => drafts.some((d) => typeof d.path === 'string' + && (to === '/' ? d.path === '/' || d.path.startsWith('/?') : d.path === to || d.path.startsWith(`${to}/`) || d.path.startsWith(`${to}?`))); return (
diff --git a/frontend/src/components/Tabs.jsx b/frontend/src/components/Tabs.jsx index 98497aa..9e27b09 100644 --- a/frontend/src/components/Tabs.jsx +++ b/frontend/src/components/Tabs.jsx @@ -1,12 +1,47 @@ +import { DraftMark } from './NavigationGuard.jsx'; +import { useEffect, useId, useRef } from 'react'; + export function Tabs({ tabs, active, onChange }) { + const id = useId(); + const listRef = useRef(null); + const tabRefs = useRef({}); + useEffect(() => { + const panel = listRef.current?.nextElementSibling; + if (!panel) return; + panel.id = `${id}-panel`; + panel.setAttribute('role', 'tabpanel'); + panel.setAttribute('aria-labelledby', `${id}-${active}`); + }, [active, id]); + function onKeyDown(event, index) { + const delta = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0; + const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : delta ? (index + delta + tabs.length) % tabs.length : null; + if (next === null) return; + event.preventDefault(); + onChange(tabs[next].key); + tabRefs.current[tabs[next].key]?.focus(); + } return ( -
- {tabs.map((tab) => ( - ))}
diff --git a/frontend/src/context/auth-context.jsx b/frontend/src/context/auth-context.jsx index 47561f3..9a0bf3b 100644 --- a/frontend/src/context/auth-context.jsx +++ b/frontend/src/context/auth-context.jsx @@ -1,42 +1,100 @@ -import { createContext, useContext, useEffect, useMemo, useState, useCallback } from 'react'; +import { createContext, useContext, useEffect, useMemo, useState, useCallback, useRef } from 'react'; import { api, setUnauthorizedHandler } from '../services/api.js'; +import { tokenExpiresAt, isTokenExpired, tokenSubject } from '../utils/jwt.js'; +import { saveReturnContext, markSessionExpired, consumeReturnContext, clearSessionExpiredFlag } from '../utils/session.js'; +import { setGuardBypass, clearUserDrafts } from '../utils/drafts.js'; const AuthContext = createContext(null); +const CHECK_EVERY_MS = 30 * 1000; +const MAX_TIMEOUT_MS = 2 ** 31 - 1; export function AuthProvider({ children }) { const [user, setUser] = useState(null); const [ready, setReady] = useState(false); + const userRef = useRef(null); + userRef.current = user; - const logout = useCallback(() => { + const logout = useCallback((options = {}) => { + // Salida voluntaria o sesion invalida: la navegacion a /login no debe pedir confirmacion + // (los borradores ya estan guardados en el navegador). + setGuardBypass(true); + if (!options.preserveDrafts) clearUserDrafts(userRef.current?.id); api.setToken(null); setUser(null); }, []); - useEffect(() => { - setUnauthorizedHandler(() => logout()); + // Vencimiento: recuerda donde estaba el usuario (para volver ahi tras el login) y marca el + // aviso que muestra la pantalla de login. Idempotente. + const expireSession = useCallback(() => { + if (!localStorage.getItem('plc_token')) return; + if (userRef.current) saveReturnContext(userRef.current.id); + markSessionExpired(); + logout({ preserveDrafts: true }); }, [logout]); + useEffect(() => { + setUnauthorizedHandler(() => expireSession()); + }, [expireSession]); + useEffect(() => { const token = localStorage.getItem('plc_token'); if (!token) { setReady(true); return; } + if (isTokenExpired(token)) { + // Recarga con la sesion ya vencida (ej. pestaña dormida): igual se recuerda donde estaba. + saveReturnContext(tokenSubject(token)); + markSessionExpired(); + api.setToken(null); + setReady(true); + return; + } api.get('/auth/me') .then((me) => setUser(me)) .catch(() => logout()) .finally(() => setReady(true)); }, [logout]); - const login = useCallback(async (email, password) => { - const result = await api.post('/auth/login', { email, password }); - api.setToken(result.token); - setUser(result.user); - return result.user; + // Vigilancia del vencimiento aunque no haya peticiones: temporizador al instante exacto, un + // chequeo periodico y, sobre todo, al volver a la pestaña/ventana -- el navegador congela los + // temporizadores de pestañas en segundo plano, asi que no se puede confiar solo en ellos. + useEffect(() => { + if (!user) return undefined; + const check = () => { + if (isTokenExpired(localStorage.getItem('plc_token'), 0)) expireSession(); + }; + const exp = tokenExpiresAt(localStorage.getItem('plc_token')); + const timer = exp ? setTimeout(check, Math.min(Math.max(exp - Date.now(), 0) + 500, MAX_TIMEOUT_MS)) : null; + const interval = setInterval(check, CHECK_EVERY_MS); + const onVisible = () => { if (document.visibilityState === 'visible') check(); }; + document.addEventListener('visibilitychange', onVisible); + window.addEventListener('focus', check); + window.addEventListener('pageshow', check); + check(); + return () => { + if (timer) clearTimeout(timer); + clearInterval(interval); + document.removeEventListener('visibilitychange', onVisible); + window.removeEventListener('focus', check); + window.removeEventListener('pageshow', check); + }; + }, [user, expireSession]); + + // Devuelve la ruta a la que volver tras iniciar sesion (solo si la dejo este mismo usuario). + const startSession = useCallback((token, sessionUser) => { + api.setToken(token); + clearSessionExpiredFlag(); + setGuardBypass(false); + setUser(sessionUser); + return consumeReturnContext(sessionUser.id); }, []); + const login = useCallback(async (email, password) => { + const result = await api.post('/auth/login', { email, password }); + const returnTo = startSession(result.token, result.user); + return { user: result.user, returnTo }; + }, [startSession]); + // Usado por RegisterPage cuando el registro devuelve sesion directa (primer usuario = superadmin). - const setSession = useCallback((token, sessionUser) => { - api.setToken(token); - setUser(sessionUser); - }, []); + const setSession = useCallback((token, sessionUser) => { startSession(token, sessionUser); }, [startSession]); const value = useMemo(() => ({ user, ready, login, logout, setSession }), [user, ready, login, logout, setSession]); diff --git a/frontend/src/hooks/useAsync.js b/frontend/src/hooks/useAsync.js index 2408565..8749b36 100644 --- a/frontend/src/hooks/useAsync.js +++ b/frontend/src/hooks/useAsync.js @@ -33,7 +33,7 @@ export function useAction() { try { return await fn(); } catch (err) { - openDialog({ tone: 'error', title: t('common_error'), message: err.message }); + openDialog({ tone: err.status === 409 ? 'warning' : 'error', title: t(err.status === 409 ? 'draft_conflict_title' : 'common_error'), message: err.status === 409 ? t('draft_conflict_reload_msg') : err.message }); throw err; } }, [openDialog, t]); diff --git a/frontend/src/hooks/useDraftForm.js b/frontend/src/hooks/useDraftForm.js new file mode 100644 index 0000000..ced9e57 --- /dev/null +++ b/frontend/src/hooks/useDraftForm.js @@ -0,0 +1,196 @@ +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { useAuth } from '../context/auth-context.jsx'; +import { useUi } from '../context/ui-context.jsx'; +import { + draftKey, readDraft, writeDraft, removeDraft, listDrafts, subscribeDrafts, + sanitizeDraftValues, fingerprint, setActiveEditor, draftAllowedFields, +} from '../utils/drafts.js'; + +const same = (a, b) => String(a ?? '') === String(b ?? ''); + +/** + * Estado de un formulario con borrador persistente (ver utils/drafts.js). + * + * - formId: identificador estable del formulario (ej. 'project-info'). + * - entityId: id del registro que se edita; null/undefined para "nuevo". + * - initial: valores del servidor (o vacios para "nuevo"); puede ser un objeto nuevo en cada + * render, se compara por huella. + * - label: texto que se muestra en el aviso de cambios pendientes. + * - enabled: false mientras el formulario no esta abierto (no lee ni escribe borrador). + * - exclude: campos que nunca se guardan como borrador (ademas de los secretos por nombre). + * - onDiscard: se llama al descartar (ej. desde el aviso al salir) para cerrar la edicion. + */ +export function useDraftForm({ formId, entityId, initial, label, enabled = true, exclude = [], onDiscard }) { + const { user } = useAuth(); + const { t, openDialog } = useUi(); + const key = user ? draftKey(user.id, formId, entityId) : null; + const excludeKey = exclude.join(','); + const allowed = draftAllowedFields(formId); + const baseFp = fingerprint(initial, exclude, allowed); + const initialRef = useRef(initial); + initialRef.current = initial; + const onDiscardRef = useRef(onDiscard); + onDiscardRef.current = onDiscard; + + const [values, setValuesState] = useState(initial); + const [restored, setRestored] = useState(false); + const [conflict, setConflict] = useState(false); + // Huella del servidor desde la que se empezo a editar (para detectar cambios ajenos al guardar). + const startBaseRef = useRef(baseFp); + // Huella de lo que ya esta guardado como borrador: si no cambia, no se reescribe (asi se + // conserva la URL original donde se estaba editando, aunque se reabra desde otra pestaña). + const persistedFpRef = useRef(null); + + // Cargar borrador (o valores del servidor) al abrir el formulario / cambiar de registro. + // Se hace en el render (estado derivado), no en un efecto: asi el efecto que persiste nunca ve + // los valores viejos y no borra por error el borrador recien cargado. + const loadSig = `${key}|${baseFp}|${enabled}`; + const [loadedSig, setLoadedSig] = useState(null); + if (loadedSig !== loadSig) { + setLoadedSig(loadSig); + const draft = enabled && key ? readDraft(key) : null; + persistedFpRef.current = draft ? fingerprint(draft.values, exclude, allowed) : null; + if (draft) { + setValuesState({ ...initial, ...draft.values }); + setRestored(true); + startBaseRef.current = draft.base; + setConflict(entityId != null && draft.base !== baseFp); + } else { + setValuesState(initial); + setRestored(false); + setConflict(false); + startBaseRef.current = baseFp; + } + } + + const isFieldDirty = useCallback((name) => !same(values?.[name], initial?.[name]), [values, initial]); + const isDirty = useMemo( + () => !!values && Object.keys({ ...values, ...initial }).some((k) => !same(values[k], initial?.[k])), + [values, initial] + ); + + const discard = useCallback(() => { + // Sincronico: si a continuacion se navega (ej. tras guardar), el aviso no debe saltar. + if (key) { removeDraft(key); setActiveEditor(key, null); } + persistedFpRef.current = null; + setValuesState(initialRef.current); + setRestored(false); + setConflict(false); + onDiscardRef.current?.(); + }, [key]); + + // Persistir y registrar el editor activo mientras haya cambios. + useEffect(() => { + if (!enabled || !key) return undefined; + const clean = sanitizeDraftValues(values, exclude, allowed); + const fp = fingerprint(clean); + if (fp === persistedFpRef.current) { + // Ya guardado tal cual (ej. recien recuperado): no se reescribe. + } else if (fp !== baseFp) { + persistedFpRef.current = fp; + writeDraft(key, { + values: clean, + base: startBaseRef.current, + label, + path: `${window.location.pathname}${window.location.search}`, + }); + } else { + persistedFpRef.current = null; + removeDraft(key); + } + if (isDirty) setActiveEditor(key, { label, discard }); + else setActiveEditor(key, null); + return () => setActiveEditor(key, null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [values, isDirty, enabled, key, baseFp, label, excludeKey]); + + const setValues = useCallback((updater) => { + setValuesState((prev) => (typeof updater === 'function' ? updater(prev) : updater)); + }, []); + const setField = useCallback((name, value) => setValues((v) => ({ ...v, [name]: value })), [setValues]); + + // Tras guardar con exito: el borrador ya no hace falta. + const clear = discard; + + const keepMine = useCallback(() => { + startBaseRef.current = baseFp; + setConflict(false); + if (key) { + const draft = readDraft(key); + if (draft) writeDraft(key, { ...draft, base: baseFp }); + } + }, [key, baseFp]); + + // Envuelve el guardado: si el registro cambio en el servidor desde que se empezo a editar, + // pide confirmacion antes de sobrescribir. fetchCurrent (opcional) trae los valores actuales + // del servidor (mismo formato que `initial`) para detectarlo tambien sin recargar la pagina. + const guardSave = useCallback(async (save, { fetchCurrent } = {}) => { + let changedOnServer = conflict; + if (!changedOnServer && fetchCurrent && entityId != null) { + try { + const current = await fetchCurrent(); + changedOnServer = fingerprint(current, exclude, allowed) !== startBaseRef.current; + } catch { + openDialog({ tone: 'error', title: t('common_error'), message: t('draft_conflict_check_failed') }); + return undefined; + } + } + if (!changedOnServer) return save(); + return new Promise((resolve) => { + openDialog({ + tone: 'warning', + title: t('draft_conflict_title'), + message: values?.editVersion ? t('draft_conflict_reload_msg') : t('draft_conflict_save_msg'), + actions: values?.editVersion ? [ + { label: t('common_cancel'), variant: 'standard', onClick: () => resolve(undefined) }, + ] : [ + { label: t('common_cancel'), variant: 'standard', onClick: () => resolve(undefined) }, + { label: t('draft_conflict_overwrite'), variant: 'danger', onClick: async () => { keepMine(); resolve(await save()); } }, + ], + }); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [conflict, entityId, excludeKey, openDialog, t, keepMine, values]); + + return { + values, setValues, setField, isDirty, isFieldDirty, restored, conflict, + keepMine, discard, clear, guardSave, draftKey: key, label, + }; +} + +// Lista reactiva de borradores del usuario actual (para marcar pestañas, menus, tarjetas). +export function useUserDrafts() { + const { user } = useAuth(); + const snapshotRef = useRef({ fp: '', list: [] }); + const getSnapshot = useCallback(() => { + const list = listDrafts(user?.id); + const fp = list.map((d) => `${d.key}@${d.savedAt}`).sort().join('|'); + if (fp !== snapshotRef.current.fp) snapshotRef.current = { fp, list }; + return snapshotRef.current.list; + }, [user?.id]); + return useSyncExternalStore(subscribeDrafts, getSnapshot, getSnapshot); +} + +export function hasDraftFor(drafts, formId, entityId) { + return drafts.some((d) => d.formId === formId && (entityId === undefined || String(d.entityId) === String(entityId ?? null))); +} + +// Reabre, una sola vez al entrar a la pantalla, el popup que quedo con borrador: +// - newFormId: borrador de "Nuevo ..." -> openNew() +// - editFormId: borrador de edicion -> openEdit(item), SOLO si el registro esta en `items` (la +// lista que el backend devolvio para este usuario: si ya no existe o perdio el acceso, no se +// restaura nada). +export function useReopenDraft({ newFormId, editFormId, items, openNew, openEdit }) { + const drafts = useUserDrafts(); + const done = useRef(false); + useEffect(() => { + if (done.current) return; + if (newFormId && hasDraftFor(drafts, newFormId, null)) { done.current = true; openNew(); return; } + if (!editFormId) { done.current = true; return; } + if (!items) return; + done.current = true; + const d = drafts.find((x) => x.formId === editFormId && items.some((i) => String(i.id) === String(x.entityId))); + if (d) openEdit(items.find((i) => String(i.id) === String(d.entityId))); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items, drafts]); +} diff --git a/frontend/src/hooks/useModalFocus.js b/frontend/src/hooks/useModalFocus.js new file mode 100644 index 0000000..89c0ad7 --- /dev/null +++ b/frontend/src/hooks/useModalFocus.js @@ -0,0 +1,35 @@ +import { useEffect, useRef } from 'react'; + +export function useModalFocus(open, onEscape) { + const ref = useRef(null); + const escapeRef = useRef(onEscape); + escapeRef.current = onEscape; + useEffect(() => { + if (!open) return undefined; + const previous = document.activeElement; + const frame = requestAnimationFrame(() => { + const root = ref.current; + if (root && !root.contains(document.activeElement)) { + (root.querySelector('[autofocus]') || root.querySelector('button, input, textarea, select, [tabindex="0"]') || root).focus(); + } + }); + const handleKey = (event) => { + const root = ref.current; + if (!root) return; + const dialogs = [...document.querySelectorAll('[aria-modal="true"]')]; + if (dialogs.at(-1) !== root) return; + if (event.key === 'Escape' && escapeRef.current) { event.preventDefault(); escapeRef.current(); return; } + if (event.key !== 'Tab') return; + const focusable = [...root.querySelectorAll('button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href], [tabindex="0"]')] + .filter((element) => element.getClientRects().length); + if (!focusable.length) { event.preventDefault(); root.focus(); return; } + const first = focusable[0]; + const last = focusable.at(-1); + if (event.shiftKey && (document.activeElement === first || !root.contains(document.activeElement))) { event.preventDefault(); last.focus(); } + else if (!event.shiftKey && (document.activeElement === last || !root.contains(document.activeElement))) { event.preventDefault(); first.focus(); } + }; + document.addEventListener('keydown', handleKey); + return () => { cancelAnimationFrame(frame); document.removeEventListener('keydown', handleKey); previous?.focus?.(); }; + }, [open]); + return ref; +} diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 3213a71..c0d0574 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -54,7 +54,7 @@ export const messages = { config_company: 'Empresa', company_name: 'Nombre de la empresa', company_logo: 'Logo', - company_logo_hint: 'PNG, JPG, WEBP o SVG. Se usa en el menu lateral y en las pantallas de acceso.', + company_logo_hint: 'PNG, JPG o WEBP. Se usa en el menu lateral y en las pantallas de acceso.', company_logo_none: 'Sin logo', company_logo_choose: 'Elegir imagen', @@ -117,6 +117,8 @@ export const messages = { common_delete: 'Eliminar', common_close: 'Cerrar', common_search: 'Buscar...', + common_select: 'Seleccionar', + common_actions: 'Acciones', common_search_no_results: 'Sin resultados', common_actions: 'Acciones', common_view: 'Ver', @@ -155,11 +157,31 @@ export const messages = { dashboard_today_activities: 'Para hoy', dashboard_average_progress: 'Avance promedio', common_of: 'de', + draft_leave_title: 'Cambios sin guardar', + draft_leave_msg: 'Vas a salir de una sección con cambios que todavía no guardaste:', + draft_leave_hint: 'Si los dejas pendientes, se conservan en este navegador y se reabre la edición al volver.', + draft_discard: 'Descartar cambios', + draft_keep_pending: 'Dejar pendiente', + draft_keep_editing: 'Seguir editando', + draft_pending_mark: 'Cambios sin guardar', + draft_field_modified: 'modificado', + draft_restored_msg: 'Se recuperaron cambios que no habías guardado. Revisa y guarda, o descártalos.', + draft_conflict_title: 'El registro cambió', + draft_conflict_reload_msg: 'El registro cambió en el servidor. Recarga para revisar la versión actual antes de guardar. Tus cambios pendientes se conservarán.', + draft_conflict_reload: 'Descartar y recargar', + draft_conflict_msg: 'Este registro cambió en el servidor desde que empezaste a editarlo. Si guardas tus cambios pendientes, reemplazarás esos cambios.', + draft_conflict_save_msg: 'Este registro cambió en el servidor desde que empezaste a editarlo (otra persona u otra pestaña). ¿Quieres sobrescribirlo con tus cambios?', + draft_conflict_overwrite: 'Sobrescribir', + draft_keep_mine: 'Mantener mis cambios', + draft_use_server: 'Descartar mis cambios', + session_expired_login_msg: 'Tu sesión venció. Inicia sesión otra vez para volver a donde estabas; los cambios sin guardar se conservaron.', dashboard_cards: 'Tarjetas', dashboard_no_cards: 'No hay tarjetas seleccionadas. Elige cuáles mostrar en "Tarjetas".', dashboard_projects_by_status: 'Proyectos por estado', projects_title: 'Proyectos', + project_page_range: 'Mostrando', project_page_of: 'de', project_page_previous: 'Anterior', project_page_next: 'Siguiente', + draft_conflict_check_failed: 'No se pudo comprobar la versión actual. Intenta de nuevo.', project_code: 'Codigo', project_new: 'Nuevo proyecto', project_objective: 'Objetivo', @@ -541,7 +563,7 @@ export const messages = { config_company: 'Company', company_name: 'Company name', company_logo: 'Logo', - company_logo_hint: 'PNG, JPG, WEBP or SVG. Used in the sidebar and on the sign-in screens.', + company_logo_hint: 'PNG, JPG or WEBP. Used in the sidebar and on the sign-in screens.', company_logo_none: 'No logo', company_logo_choose: 'Choose image', @@ -604,6 +626,8 @@ export const messages = { common_delete: 'Delete', common_close: 'Close', common_search: 'Search...', + common_select: 'Select', + common_actions: 'Actions', common_search_no_results: 'No results', common_actions: 'Actions', common_view: 'View', @@ -642,11 +666,31 @@ export const messages = { dashboard_today_activities: 'Due today', dashboard_average_progress: 'Average progress', common_of: 'of', + draft_leave_title: 'Unsaved changes', + draft_leave_msg: 'You are leaving a section with changes you have not saved yet:', + draft_leave_hint: 'If you leave them pending, they are kept in this browser and editing reopens when you come back.', + draft_discard: 'Discard changes', + draft_keep_pending: 'Leave pending', + draft_keep_editing: 'Keep editing', + draft_pending_mark: 'Unsaved changes', + draft_field_modified: 'modified', + draft_restored_msg: 'Unsaved changes were recovered. Review and save them, or discard them.', + draft_conflict_title: 'The record changed', + draft_conflict_reload_msg: 'The record changed on the server. Reload to review the current version before saving. Your pending changes will be kept.', + draft_conflict_reload: 'Discard and reload', + draft_conflict_msg: 'This record changed on the server since you started editing it. Saving your pending changes will replace those changes.', + draft_conflict_save_msg: 'This record changed on the server since you started editing it (someone else or another tab). Do you want to overwrite it with your changes?', + draft_conflict_overwrite: 'Overwrite', + draft_keep_mine: 'Keep my changes', + draft_use_server: 'Discard my changes', + session_expired_login_msg: 'Your session expired. Sign in again to return where you were; unsaved changes were kept.', dashboard_cards: 'Cards', dashboard_no_cards: 'No cards selected. Choose which ones to show in "Cards".', dashboard_projects_by_status: 'Projects by status', projects_title: 'Projects', + project_page_range: 'Showing', project_page_of: 'of', project_page_previous: 'Previous', project_page_next: 'Next', + draft_conflict_check_failed: 'Could not check the current version. Try again.', project_code: 'Code', project_new: 'New project', project_objective: 'Objective', diff --git a/frontend/src/pages/Configuracion/CatalogEditor.jsx b/frontend/src/pages/Configuracion/CatalogEditor.jsx index e9ab6a1..d85bf8c 100644 --- a/frontend/src/pages/Configuracion/CatalogEditor.jsx +++ b/frontend/src/pages/Configuracion/CatalogEditor.jsx @@ -9,6 +9,9 @@ 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 { DraftNotice } from '../../components/DraftNotice.jsx'; +import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx'; +import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js'; const COLOR_OPTIONS = ['accent', 'success', 'warning', 'danger', 'muted'].map((c) => ({ value: c, label: c })); @@ -25,30 +28,39 @@ export function CatalogEditor({ catalogKey, fields, title }) { const { data: items, reload } = useAsync(() => listCatalog(catalogKey), [catalogKey]); const [editing, setEditing] = useState(false); const [selected, setSelected] = useState(null); - const [form, setForm] = useState(emptyForm(fields)); + const guardedClose = useGuardedClose(); + // Borrador del item (nuevo o edicion), separado por catalogo. + const fromItem = (item) => { const next = {}; for (const f of fields) next[f.key] = item[f.key]; next.editVersion = item.edit_version; return next; }; + const draft = useDraftForm({ + formId: `catalog-${catalogKey}-${selected ? 'edit' : 'new'}`, entityId: selected?.id ?? null, + initial: selected ? fromItem(selected) : emptyForm(fields), + label: `${title}: ${selected ? t('common_edit') : t('common_new')}`, enabled: editing, + }); + const form = draft.values; + const setForm = draft.setValues; + const pendingNew = hasDraftFor(useUserDrafts(), `catalog-${catalogKey}-new`, null); + useReopenDraft({ newFormId: `catalog-${catalogKey}-new`, editFormId: `catalog-${catalogKey}-edit`, items, openNew: () => startNew(), openEdit: (i) => startEdit(i) }); function startNew() { setSelected(null); - setForm(emptyForm(fields)); setEditing(true); } function startEdit(item) { setSelected(item); - const next = {}; - for (const f of fields) next[f.key] = item[f.key]; - setForm(next); setEditing(true); } async function save() { - await runAction(async () => { + const run = () => runAction(async () => { if (selected) await updateCatalogItem(catalogKey, selected.id, form); else await createCatalogItem(catalogKey, form); + draft.clear(); await reload(); setEditing(false); pushToast({ tone: 'success', message: t('common_saved') }); }); + if (selected) await draft.guardSave(run); else await run(); } function confirmDelete(item) { @@ -60,6 +72,7 @@ export function CatalogEditor({ catalogKey, fields, title }) { { label: t('common_cancel'), variant: 'standard' }, { label: t('common_delete'), variant: 'danger', onClick: () => runAction(async () => { await deleteCatalogItem(catalogKey, item.id); + draft.discard(); await reload(); setEditing(false); }) }, @@ -81,7 +94,7 @@ export function CatalogEditor({ catalogKey, fields, title }) {

{title}

- +
- setEditing(false)} width={480} height={380}> + guardedClose(draft, () => setEditing(false))} width={480} height={420}> + {fields.map((f) => ( - + {f.type === 'color' ? ( setForm((s) => ({ ...s, [f.key]: v }))} /> ) : f.type === 'checkbox' ? ( @@ -118,7 +132,7 @@ export function CatalogEditor({ catalogKey, fields, title }) {
- +
diff --git a/frontend/src/pages/Configuracion/CompanySettingsPanel.jsx b/frontend/src/pages/Configuracion/CompanySettingsPanel.jsx index 16dc1b8..d45e2e6 100644 --- a/frontend/src/pages/Configuracion/CompanySettingsPanel.jsx +++ b/frontend/src/pages/Configuracion/CompanySettingsPanel.jsx @@ -6,21 +6,25 @@ import { useAsync, useAction } from '../../hooks/useAsync.js'; import { getCompanyInfo, updateCompanyInfo } from '../../services/company.js'; import { Field } from '../../components/Field.jsx'; import { Button } from '../../components/Button.jsx'; +import { DraftNotice } from '../../components/DraftNotice.jsx'; +import { useDraftForm } from '../../hooks/useDraftForm.js'; export default function CompanySettingsPanel() { const { t, pushToast } = useUi(); const branding = useBranding(); const runAction = useAction(); const { data: company, reload } = useAsync(getCompanyInfo, []); - const [name, setName] = useState(''); + // Borrador del nombre. El logo (archivo) no se puede guardar como borrador: se vuelve a elegir. + const draft = useDraftForm({ + formId: 'company-settings', entityId: 'system', initial: { name: company?.name || '', editVersion: company?.edit_version }, + label: t('config_company'), enabled: !!company, + }); + const name = draft.values.name; + const setName = (v) => draft.setField('name', v); const [logoFile, setLogoFile] = useState(null); const [preview, setPreview] = useState(null); const fileInputRef = useRef(null); - useEffect(() => { - if (company) setName(company.name); - }, [company]); - useEffect(() => { if (!logoFile) { setPreview(null); return; } const url = URL.createObjectURL(logoFile); @@ -29,14 +33,15 @@ export default function CompanySettingsPanel() { }, [logoFile]); async function save() { - await runAction(async () => { - await updateCompanyInfo({ name, logoFile }); + await draft.guardSave(() => runAction(async () => { + await updateCompanyInfo({ name, logoFile, editVersion: draft.values.editVersion }); + draft.clear(); setLogoFile(null); if (fileInputRef.current) fileInputRef.current.value = ''; await reload(); await branding.reload(); pushToast({ tone: 'success', message: t('common_saved') }); - }); + }), { fetchCurrent: async () => { const c = await getCompanyInfo(); return { name: c.name || '', editVersion: c.edit_version }; } }); } if (!company) return
{t('common_loading')}
; @@ -45,7 +50,8 @@ export default function CompanySettingsPanel() { return (
- + + setName(e.target.value)} placeholder="LinkedCorp" /> @@ -56,7 +62,7 @@ export default function CompanySettingsPanel() { setLogoFile(e.target.files?.[0] || null)} /> diff --git a/frontend/src/pages/Configuracion/ConfiguracionPage.jsx b/frontend/src/pages/Configuracion/ConfiguracionPage.jsx index a8d4ba5..13a0f54 100644 --- a/frontend/src/pages/Configuracion/ConfiguracionPage.jsx +++ b/frontend/src/pages/Configuracion/ConfiguracionPage.jsx @@ -38,7 +38,7 @@ export default function ConfiguracionPage() {

{t('config_title')}

- + @@ -69,7 +69,7 @@ export default function ConfiguracionPage() { - + - + - + - + - + diff --git a/frontend/src/pages/Configuracion/SmtpSettingsPanel.jsx b/frontend/src/pages/Configuracion/SmtpSettingsPanel.jsx index fd400ab..cde9063 100644 --- a/frontend/src/pages/Configuracion/SmtpSettingsPanel.jsx +++ b/frontend/src/pages/Configuracion/SmtpSettingsPanel.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Save, Send } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; import { useAsync, useAction } from '../../hooks/useAsync.js'; @@ -7,27 +7,32 @@ import { Field, FieldRow } from '../../components/Field.jsx'; import { Button } from '../../components/Button.jsx'; import { InfoBar } from '../../components/InfoBar.jsx'; import { PasswordInput } from '../../components/PasswordInput.jsx'; +import { DraftNotice } from '../../components/DraftNotice.jsx'; +import { useDraftForm } from '../../hooks/useDraftForm.js'; export default function SmtpSettingsPanel() { const { t, pushToast } = useUi(); const runAction = useAction(); const { data: config, reload } = useAsync(getSmtpConfig, []); - const [form, setForm] = useState({ host: '', port: 587, user: '', from: '', pass: '' }); + // Borrador de la configuracion SMTP; la clave (pass) nunca se guarda como borrador. + const draft = useDraftForm({ + formId: 'smtp-config', entityId: 'system', + initial: config ? { host: config.host, port: config.port, user: config.user, from: config.from, pass: '', editVersion: config.edit_version } : { host: '', port: 587, user: '', from: '', pass: '' }, + label: t('config_smtp'), enabled: !!config, exclude: ['pass'], + }); + const form = draft.values; + const setForm = draft.setValues; const [testTo, setTestTo] = useState(''); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); // { ok: bool, message: string } - useEffect(() => { - if (config) setForm({ host: config.host, port: config.port, user: config.user, from: config.from, pass: '' }); - }, [config]); - async function save() { - await runAction(async () => { + await draft.guardSave(() => runAction(async () => { await updateSmtpConfig(form); + draft.clear(); await reload(); - setForm((f) => ({ ...f, pass: '' })); pushToast({ tone: 'success', message: t('common_saved') }); - }); + }), { fetchCurrent: async () => { const c = await getSmtpConfig(); return { host: c.host, port: c.port, user: c.user, from: c.from, pass: '', editVersion: c.edit_version }; } }); } async function sendTest() { @@ -50,23 +55,24 @@ export default function SmtpSettingsPanel() { return (
{!config.configured && {t('smtp_not_configured')}} + - + setForm((f) => ({ ...f, host: e.target.value }))} placeholder="192.168.222.10" /> - + setForm((f) => ({ ...f, port: Number(e.target.value) }))} /> - + setForm((f) => ({ ...f, user: e.target.value }))} placeholder="project@linkedcorp.net" /> - + setForm((f) => ({ ...f, from: e.target.value }))} placeholder="project@linkedcorp.net" /> - + setForm((f) => ({ ...f, pass: e.target.value }))} /> diff --git a/frontend/src/pages/Equipos/EquiposPage.jsx b/frontend/src/pages/Equipos/EquiposPage.jsx index 0070a9f..4281e41 100644 --- a/frontend/src/pages/Equipos/EquiposPage.jsx +++ b/frontend/src/pages/Equipos/EquiposPage.jsx @@ -1,4 +1,5 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { Navigate } from 'react-router-dom'; import { Pencil, Save, Plus, Trash2, Power, Copy, Download } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; @@ -15,6 +16,8 @@ import { Button } from '../../components/Button.jsx'; import { Field } from '../../components/Field.jsx'; import { ComboBox } from '../../components/ComboBox.jsx'; import { DetailPopup } from '../../components/DetailPopup.jsx'; +import { DraftNotice } from '../../components/DraftNotice.jsx'; +import { useDraftForm, useUserDrafts } from '../../hooks/useDraftForm.js'; import { SearchInput } from '../../components/SearchInput.jsx'; import { ListView } from '../../components/ListView.jsx'; import { Tabs } from '../../components/Tabs.jsx'; @@ -53,56 +56,56 @@ function SimpleTable({ columns, rows, empty }) { ); } -function DeviceDetail({ id, onClose, onChanged, canEdit }) { +function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabChange }) { const { t, pushToast } = useUi(); const runAction = useAction(); const { data: device, reload } = useAsync(() => getDevice(id), [id]); const { data: users } = useAsync(listUsers, []); const { data: departments } = useAsync(listDepartments, []); - const [tab, setTab] = useState('general'); + const setTab = onTabChange; // la pestaña vive en la URL (?deviceTab=) const [searches, setSearches] = useState({}); const [showComponents, setShowComponents] = useState(false); const [editing, setEditing] = useState(false); - const [form, setForm] = useState({}); + // Borrador de los datos editables del equipo (estado, asignado, departamento, notas). + const draft = useDraftForm({ + formId: 'device-edit', entityId: id, + initial: device ? { status: device.status, assignedUserId: device.assigned_user_id || '', departmentId: device.department_id || '', notes: device.notes || '', editVersion: device.edit_version } : {}, + label: `${t('eq_detail')}: ${device?.hostname || id}`, enabled: !!device && canEdit && tab === 'general', onDiscard: () => setEditing(false), + }); + const form = draft.values; + const setForm = draft.setValues; + useEffect(() => { if (draft.restored) setEditing(true); }, [draft.restored]); const flat = (list) => (list || []).flatMap((d) => [d, ...(d.children ? flat(d.children) : [])]); const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name })); const deptOptions = flat(departments).map((d) => ({ value: d.id, label: d.name })); function startEdit() { - setForm({ - status: device.status, - assignedUserId: device.assigned_user_id || '', - departmentId: device.department_id || '', - notes: device.notes || '', - }); setEditing(true); } function save() { - const changed = form.status !== device.status - || Number(form.assignedUserId || 0) !== Number(device.assigned_user_id || 0) - || Number(form.departmentId || 0) !== Number(device.department_id || 0) - || form.notes !== (device.notes || ''); - if (!changed) { setEditing(false); return; } - runAction(async () => { + if (!draft.isDirty) { draft.discard(); setEditing(false); return; } + draft.guardSave(() => runAction(async () => { await updateDevice(id, { status: form.status, assignedUserId: form.assignedUserId ? Number(form.assignedUserId) : null, departmentId: form.departmentId ? Number(form.departmentId) : null, notes: form.notes, + editVersion: form.editVersion, }); + draft.clear(); setEditing(false); await reload(); onChanged(); pushToast({ tone: 'success', message: t('common_saved') }); - }); + }), { fetchCurrent: async () => { const d = await getDevice(id); return { status: d.status, assignedUserId: d.assigned_user_id || '', departmentId: d.department_id || '', notes: d.notes || '' }; } }); } if (!device) return
{t('common_loading')}
; const tabs = [ - { key: 'general', label: t('eq_tab_general') }, + { key: 'general', label: t('eq_tab_general'), dirty: editing && draft.isDirty }, { key: 'users', label: t('eq_tab_users'), count: device.accounts.filter((a) => a.kind === 'local_user').length }, { key: 'hardware', label: t('eq_tab_hardware') }, { key: 'network', label: t('eq_tab_network'), count: device.networks.length }, @@ -191,26 +194,27 @@ function DeviceDetail({ id, onClose, onChanged, canEdit }) { ) : ( <> - + + setForm({ ...form, status: v })} /> - + setForm({ ...form, assignedUserId: v })} /> - + setForm({ ...form, departmentId: v })} /> {fmtDate(device.first_seen)} {fmtDate(device.last_seen)} - +