From a6cc41666ee05add3a26c693e7a0d43fb8c42a0e Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Mon, 26 Oct 2026 16:42:19 -0500 Subject: [PATCH] feat: version 4.3.0 -- Sesiones activas y pagina Mi cuenta Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + API.md | 14 +++- BASE_DE_DATOS.md | 12 +++- CHANGELOG.md | 18 +++++ ESTADO.md | 8 +++ SPEC.md | 5 +- VERSION | 2 +- .../database/migrations/033_user_sessions.sql | 16 +++++ backend/package.json | 2 +- backend/src/middleware/auth.js | 14 +++- backend/src/modules/auth/controller.js | 21 ++++-- backend/src/modules/auth/routes.js | 5 +- backend/src/modules/auth/service.js | 30 ++++---- .../src/modules/auth/sessions-repository.js | 54 +++++++++++++++ backend/src/modules/auth/sessions-service.js | 63 +++++++++++++++++ backend/src/modules/users/controller.js | 18 +++++ backend/src/modules/users/routes.js | 5 +- backend/src/modules/users/service.js | 2 + backend/src/utils/http.js | 4 +- frontend/package.json | 2 +- frontend/src/app/App.jsx | 2 + frontend/src/app/AppShell.jsx | 61 ++-------------- frontend/src/components/ListView.jsx | 1 + frontend/src/components/SessionsList.jsx | 49 +++++++++++++ frontend/src/context/auth-context.jsx | 20 ++++-- frontend/src/i18n/messages.js | 20 ++++++ frontend/src/pages/Login/LoginPage.jsx | 8 ++- frontend/src/pages/MiCuenta/MiCuentaPage.jsx | 69 +++++++++++++++++++ .../src/pages/Organizacion/UsersPanel.jsx | 20 ++++-- frontend/src/services/api.js | 7 +- frontend/src/services/auth.js | 3 + frontend/src/services/users.js | 3 + frontend/src/utils/drafts.js | 1 + frontend/src/utils/session.js | 14 +++- 34 files changed, 464 insertions(+), 110 deletions(-) create mode 100644 backend/database/migrations/033_user_sessions.sql create mode 100644 backend/src/modules/auth/sessions-repository.js create mode 100644 backend/src/modules/auth/sessions-service.js create mode 100644 frontend/src/components/SessionsList.jsx create mode 100644 frontend/src/pages/MiCuenta/MiCuentaPage.jsx diff --git a/AGENTS.md b/AGENTS.md index 7a2cf3c..2d7bc90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,6 +115,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador): directamente (ya activos, sin pasar por este flujo) desde Organización → Usuarios. - `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. +- Cada JWT lleva un `sid` ligado a `user_sessions`; se puede consultar y revocar cada sesión desde Mi cuenta o por un administrador. - 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: el SMTP del sistema NO vive en `.env` — lo carga el `superadmin` desde Configuración → Correo diff --git a/API.md b/API.md index fb39158..5b4ddd2 100644 --- a/API.md +++ b/API.md @@ -9,8 +9,10 @@ - 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. +- El JWT incluye `sv` y `sid`. Cada petición valida la cuenta y la fila activa de `user_sessions`. + Cambiar la clave conserva la sesión actual y cierra las demás; restablecerla cierra todas. + Una sesión revocada responde `401` con `code: "SESSION_REVOKED"`; los JWT antiguos sin `sid` + requieren volver a iniciar sesión tras aplicar la migración `033`. ## Formato de respuesta @@ -50,11 +52,17 @@ el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`. - `POST /api/auth/reset-password` — público. `{ token, password }`. - `PUT /api/auth/change-password` — autenticado. `{ currentPassword, newPassword }`, exige la clave actual (distinto de `reset-password`, que usa un token de correo sin sesión). -- `POST /api/auth/logout` +- `POST /api/auth/logout` — revoca la sesión actual. - `GET /api/auth/me` — usuario actual +- `GET /api/auth/sessions` — sesiones activas propias (`id`, navegador, SO, IP, fechas, `current`). +- `DELETE /api/auth/sessions/:id` — cierra una sesión propia; la actual equivale a logout. +- `POST /api/auth/sessions/revoke-others` — cierra las demás sesiones propias. ### 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` +- `GET /api/users/:id/sessions` · `DELETE /api/users/:id/sessions/:sessionId` · + `POST /api/users/:id/sessions/revoke-all` — solo admin/superadmin; un admin no puede administrar + sesiones de un superadmin. Desactivar un usuario también revoca sus sesiones. - `POST /api/users/:id/approve` — `{ roleId?, departmentId?, editVersion }`, solo sobre usuarios `pending_approval`; también rechaza cambios concurrentes con `409`. - `POST /api/users/:id/reject` — borra al usuario `pending_approval` (se audita el email antes) - `GET /api/users/:id/modules` · `PUT /api/users/:id/modules` `{ modules: [...] }` — solo Superadministrador. diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index cf4018b..9b2b7af 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -73,8 +73,12 @@ 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. +- `users.session_version` (migración `028`) comienza en 1 y aumenta al restablecer clave; + cada JWT de sesión debe tener la misma versión. Cambiar clave desde la aplicación conserva la sesión actual y revoca las demás. +- `user_sessions` (migración `033`) guarda cada sesión por `public_id` (el `sid` del JWT), usuario, + IP, agente, inicio, última actividad, vencimiento y revocación. Su FK a `users` usa `ON DELETE CASCADE`. + El backend limpia al iniciar sesión las filas vencidas o revocadas hace más de 30 días. + Aplicar `033_user_sessions.sql` antes de iniciar el backend nuevo: los JWT anteriores, sin `sid`, exigirán un nuevo inicio de sesión. - 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 @@ -216,7 +220,9 @@ node src/scripts/create-admin.js "Nombre" correo@dominio # solo la primera vez Método: archivos SQL idempotentes en `backend/database/migrations/00X-nombre.sql` (`CREATE TABLE IF NOT EXISTS` / `information_schema` guards), aplicados con -`node src/scripts/migrate-db.js`. Siempre backup (`mysqldump --single-transaction`) antes de migrar +`node src/scripts/migrate-db.js`. El runner lee todos los `.sql`, los ordena por nombre y los +ejecuta **todos en cada corrida**; no lleva tabla de historial. Por eso `033_user_sessions.sql` +usa `CREATE TABLE IF NOT EXISTS`. Siempre backup (`mysqldump --single-transaction`) antes de migrar en producción. - Equipos (desde v2.15.0, migración `023_devices.sql`): `devices.deviceid` UNIQUE; `assigned_user_id` diff --git a/CHANGELOG.md b/CHANGELOG.md index f464478..2fbcaec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog - project.linkedcorp.net +## [4.3.0] - 2026-10-26 +### Added +- Sesiones activas: cada inicio de sesión queda registrado (`user_sessions`, migración 033) con + navegador/SO, IP, inicio y última actividad. El JWT lleva el id de su sesión (`sid`) y el backend + rechaza las sesiones cerradas. "Cerrar sesión" ahora la revoca también en el servidor. +- Página "Mi cuenta" (menú del usuario): Perfil (solo lectura), Preferencias (idioma, tema, + apariencia de botones), Seguridad (cambio de contraseña, antes un popup) y Sesiones activas + (cerrar una o todas las demás). +- Organización → Usuarios → "Sesiones" (admin/superadmin): ver y cerrar las sesiones de cualquier + usuario; un admin no puede tocar las de un superadmin. Todo queda en auditoría. +- Una sesión cerrada desde otro lado se corta en máximo un minuto aunque no haya actividad, y el + login avisa que fue cerrada (distinto del aviso de vencimiento). + +### Changed +- Cambiar la contraseña ya no cierra la sesión actual: cierra solo las demás. Recuperarla por + correo o desactivar al usuario cierra todas. +- Al desplegar, los usuarios deben iniciar sesión una vez (los tokens anteriores no traen `sid`). + ## [4.2.2] - 2026-10-26 ### Fixed - Equipos: guardar cambios (usuario asignado, departamento, estado, notas) siempre mostraba "El diff --git a/ESTADO.md b/ESTADO.md index 90c3cd6..874001c 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -23,6 +23,14 @@ autorregistro/aprobacion/SMTP. actividades, tareas, departamentos, usuarios, los cuatro catálogos, notas, tareas recurrentes, equipos, empresa y SMTP. La migración 029 se amplió; sigue **sin aplicar**. +## Sesiones activas y Mi cuenta (v4.3.0, sin desplegar) + +- Migración `033_user_sessions.sql` **aplicada en dev** (2026-10-26), **no en producción**. Aplicarla + antes de arrancar el backend nuevo; al desplegar, todos inician sesión una vez (JWT sin `sid`). +- Probado en dev contra una instancia temporal (puerto 4099, usuario de prueba ya borrado): listar, + cerrar una/las demás/todas, cambio de clave (mantiene la actual), logout real, token sin `sid`, + admin vs superadmin. El backend de dev en :4019 sigue con el código anterior hasta reiniciarlo. + ## Como retomar Actualizador v3.0.1: el script publica `logs/update.status`; el panel espera su fin y comprueba diff --git a/SPEC.md b/SPEC.md index 2aa1c12..18341ff 100644 --- a/SPEC.md +++ b/SPEC.md @@ -25,7 +25,10 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa ## Modulos (MVP) ### 1. Seguridad -- Login/logout con JWT, sesión persistida en el navegador. +- Login/logout con JWT (`sub`, `sv`, `sid`) y fila activa por sesión en `user_sessions`. + Mi cuenta muestra perfil, preferencias, seguridad y sesiones activas. Cada usuario puede cerrar + una sesión propia o todas las demás; admin/superadmin pueden cerrar sesiones de usuarios desde + Organización, pero solo superadmin puede hacerlo sobre otro superadmin. Cada cierre se audita. - Usuarios: alta/edición directa por el Administrador (nombre, email, rol, departamento, cargo, activo/inactivo) **o** autorregistro público (`/registro`) con doble candado: el propio usuario confirma su correo (enlace de un solo uso, vence en 24h) y luego un Administrador/Super diff --git a/VERSION b/VERSION index af8c8ec..8089590 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.2.2 +4.3.0 diff --git a/backend/database/migrations/033_user_sessions.sql b/backend/database/migrations/033_user_sessions.sql new file mode 100644 index 0000000..bc99492 --- /dev/null +++ b/backend/database/migrations/033_user_sessions.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS user_sessions ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + public_id CHAR(32) NOT NULL UNIQUE, + user_id INT NOT NULL, + ip VARCHAR(45) NULL, + user_agent VARCHAR(512) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + revoked_at DATETIME NULL, + revoked_by INT NULL, + revoke_reason VARCHAR(32) NULL, + INDEX idx_user_sessions_user (user_id, revoked_at, expires_at), + CONSTRAINT fk_user_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_user_sessions_revoked_by FOREIGN KEY (revoked_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/package.json b/backend/package.json index 680ba4a..68c8cc2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "4.2.2", + "version": "4.3.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index f6a6b42..f6eda1e 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -3,6 +3,7 @@ import { env } from '../config/env.js'; import { pool } from '../config/db.js'; import { fail } from '../utils/http.js'; import { loadModules } from './modules.js'; +import { validateSession } from '../modules/auth/sessions-service.js'; export async function requireAuth(req, res, next) { const header = req.headers.authorization || ''; @@ -18,21 +19,30 @@ 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.session_version, u.department_id, r.code AS role_code + `SELECT u.id, u.name, u.email, u.is_active, u.session_version, u.department_id, d.name AS department_name, r.code AS role_code, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id + LEFT JOIN departments d ON d.id = u.department_id WHERE u.id = ? LIMIT 1`, [payload.sub] ); const user = rows[0]; - if (!user || !user.is_active || payload.sv !== user.session_version) return fail(res, 401, 'Sesion invalida o revocada'); + if (!user || !user.is_active || payload.sv !== user.session_version) return fail(res, 401, 'Sesion invalida o revocada', 'SESSION_REVOKED'); + // Token emitido antes de las sesiones registradas (sin sid): se trata como vencido, no como cerrado. + if (!payload.sid) return fail(res, 401, 'Sesion vencida'); + const session = await validateSession(payload.sid, user.id); + if (!session) return fail(res, 401, 'Sesion cerrada o vencida', 'SESSION_REVOKED'); req.currentUser = { id: user.id, name: user.name, email: user.email, roleCode: user.role_code, + roleName: user.role_name, departmentId: user.department_id, + departmentName: user.department_name, modules: await loadModules(user.id, user.role_code), + sessionId: session.id, + sessionPublicId: payload.sid, }; next(); } diff --git a/backend/src/modules/auth/controller.js b/backend/src/modules/auth/controller.js index 6494e30..7d742ce 100644 --- a/backend/src/modules/auth/controller.js +++ b/backend/src/modules/auth/controller.js @@ -1,24 +1,26 @@ import { ok, fail, asyncHandler } from '../../utils/http.js'; import * as authService from './service.js'; +import * as sessions from './sessions-service.js'; export const postLogin = asyncHandler(async (req, res) => { const { email, password } = req.body || {}; if (!email || !password) return fail(res, 400, 'Email y clave son obligatorios'); - const result = await authService.login(email, password); + const result = await authService.login(email, password, req); ok(res, result); }); export const postLogout = asyncHandler(async (req, res) => { - // Stateless JWT: el cliente descarta el token. Se deja el endpoint para uso futuro (blacklist). + await sessions.revokeSession(req.currentUser.id, req.currentUser.sessionId, req.currentUser.id, 'logout', req.currentUser.sessionPublicId); ok(res, { loggedOut: true }); }); export const getMe = asyncHandler(async (req, res) => { - ok(res, req.currentUser); + const { sessionId, sessionPublicId, ...user } = req.currentUser; + ok(res, user); }); export const postRegister = asyncHandler(async (req, res) => { - const result = await authService.register(req.body || {}); + const result = await authService.register(req.body || {}, req); ok(res, result, 201); }); @@ -33,7 +35,7 @@ export const getInvite = asyncHandler(async (req, res) => { export const postAcceptInvite = asyncHandler(async (req, res) => { const { token, name, password } = req.body || {}; - const result = await authService.acceptInvite(token, name, password); + const result = await authService.acceptInvite(token, name, password, req); ok(res, result, 201); }); @@ -52,6 +54,13 @@ export const postResetPassword = asyncHandler(async (req, res) => { export const putChangePassword = asyncHandler(async (req, res) => { const { currentPassword, newPassword } = req.body || {}; - await authService.changePassword(req.currentUser.id, currentPassword, newPassword); + await authService.changePassword(req.currentUser.id, currentPassword, newPassword, req.currentUser.sessionPublicId); ok(res, { changed: true }); }); + +export const getSessions = asyncHandler(async (req, res) => ok(res, + await sessions.listSessions(req.currentUser.id, req.currentUser.sessionPublicId))); +export const deleteSession = asyncHandler(async (req, res) => ok(res, + await sessions.revokeSession(req.currentUser.id, req.params.id, req.currentUser.id, 'user', req.currentUser.sessionPublicId))); +export const postRevokeOthers = asyncHandler(async (req, res) => ok(res, + await sessions.revokeAll(req.currentUser.id, req.currentUser.id, 'user', req.currentUser.sessionPublicId))); diff --git a/backend/src/modules/auth/routes.js b/backend/src/modules/auth/routes.js index 0d7d8de..745b036 100644 --- a/backend/src/modules/auth/routes.js +++ b/backend/src/modules/auth/routes.js @@ -3,7 +3,7 @@ import rateLimit from 'express-rate-limit'; import { requireAuth } from '../../middleware/auth.js'; import { postLogin, postLogout, getMe, postRegister, getVerifyEmail, postForgotPassword, postResetPassword, - putChangePassword, getInvite, postAcceptInvite, + putChangePassword, getInvite, postAcceptInvite, getSessions, deleteSession, postRevokeOthers, } from './controller.js'; const router = Router(); @@ -26,5 +26,8 @@ router.post('/accept-invite', loginLimiter, postAcceptInvite); router.post('/logout', requireAuth, postLogout); router.get('/me', requireAuth, getMe); router.put('/change-password', requireAuth, loginLimiter, putChangePassword); +router.get('/sessions', requireAuth, getSessions); +router.delete('/sessions/:id', requireAuth, deleteSession); +router.post('/sessions/revoke-others', requireAuth, postRevokeOthers); export default router; diff --git a/backend/src/modules/auth/service.js b/backend/src/modules/auth/service.js index 0b1f493..a22476d 100644 --- a/backend/src/modules/auth/service.js +++ b/backend/src/modules/auth/service.js @@ -1,6 +1,5 @@ import crypto from 'node:crypto'; import bcrypt from 'bcryptjs'; -import jwt from 'jsonwebtoken'; import { pool } from '../../config/db.js'; import { env } from '../../config/env.js'; import { HttpError } from '../../utils/http.js'; @@ -9,6 +8,7 @@ import { loadModules } from '../../middleware/modules.js'; import { sendMail } from '../../utils/mailer.js'; import { createNotification } from '../notifications/service.js'; import { verificationEmail, recoveryEmail } from '../../utils/emailHtml.js'; +import { issueSession, revokeAll } from './sessions-service.js'; const STATUS_MESSAGES = { pending_verification: 'Debes confirmar tu correo antes de iniciar sesion. Revisa tu bandeja de entrada.', @@ -20,11 +20,12 @@ function newToken() { return crypto.randomBytes(32).toString('hex'); } -export async function login(email, password) { +export async function login(email, password, req) { const [rows] = await pool.query( `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 + r.code AS role_code, r.name AS role_name, d.name AS department_name FROM users u JOIN roles r ON r.id = u.role_id + LEFT JOIN departments d ON d.id = u.department_id WHERE u.email = ? LIMIT 1`, [email] ); @@ -37,7 +38,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, sv: user.session_version }, env.jwtSecret, { expiresIn: env.jwtExpires }); + const token = await issueSession(user.id, user.session_version, req); await logAudit({ userId: user.id, action: 'login', entityType: 'auth', entityId: user.id }); @@ -50,12 +51,13 @@ export async function login(email, password) { roleCode: user.role_code, roleName: user.role_name, departmentId: user.department_id, + departmentName: user.department_name, modules: await loadModules(user.id, user.role_code), }, }; } -export async function register({ name, email, password }) { +export async function register({ name, email, password }, req) { if (!name || !email || !password || password.length < 8) { throw new HttpError(400, 'Nombre, correo y una clave de al menos 8 caracteres son obligatorios'); } @@ -89,7 +91,7 @@ export async function register({ name, email, password }) { } 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, sv: 1 }, env.jwtSecret, { expiresIn: env.jwtExpires }); + const token = await issueSession(result.insertId, 1, req); return { bootstrap: true, token, user: { id: result.insertId, name, email, roleCode: 'superadmin', roleName: 'Super Administrador', departmentId: null, modules: await loadModules(result.insertId, 'superadmin') } }; } await logAudit({ userId: result.insertId, action: 'register', entityType: 'user', entityId: result.insertId }); @@ -153,7 +155,7 @@ export async function getInviteInfo(token) { // inviteUser): la persona elige su nombre y clave. A diferencia del autorregistro publico, no // pasa por aprobacion de un admin — quien la invito ya la vetó al agregarla a su proyecto. Devuelve // sesion iniciada de una vez, igual que el bootstrap del primer usuario. -export async function acceptInvite(token, name, password) { +export async function acceptInvite(token, name, password, req) { if (!token || !name || !password || password.length < 8) { throw new HttpError(400, 'Nombre, token y una clave de al menos 8 caracteres son obligatorios'); } @@ -174,16 +176,16 @@ 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, 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 = ?`, + `SELECT u.id, u.name, u.email, u.department_id, u.session_version, r.code AS role_code, r.name AS role_name, d.name AS department_name + FROM users u JOIN roles r ON r.id = u.role_id LEFT JOIN departments d ON d.id = u.department_id WHERE u.id = ?`, [user.id] ); - const jwtToken = jwt.sign({ sub: user.id, sv: full[0].session_version }, env.jwtSecret, { expiresIn: env.jwtExpires }); + const jwtToken = await issueSession(user.id, full[0].session_version, req); return { token: jwtToken, user: { id: full[0].id, name: full[0].name, email: full[0].email, - roleCode: full[0].role_code, roleName: full[0].role_name, departmentId: full[0].department_id, + roleCode: full[0].role_code, roleName: full[0].role_name, departmentId: full[0].department_id, departmentName: full[0].department_name, modules: await loadModules(full[0].id, full[0].role_code), }, }; @@ -227,11 +229,12 @@ export async function resetPassword(token, newPassword) { `UPDATE users SET password_hash = ?, session_version = session_version + 1, reset_token = NULL, reset_token_expires = NULL WHERE id = ?`, [passwordHash, user.id] ); + await revokeAll(user.id, user.id, 'password_reset'); await logAudit({ userId: user.id, action: 'reset_password', entityType: 'user', entityId: user.id }); } // Cambio de clave desde la app, con sesion activa (distinto del olvide-clave: exige la clave actual). -export async function changePassword(userId, currentPassword, newPassword) { +export async function changePassword(userId, currentPassword, newPassword, currentSessionId) { if (!currentPassword || !newPassword || newPassword.length < 8) { throw new HttpError(400, 'La clave actual y una clave nueva de al menos 8 caracteres son obligatorias'); } @@ -243,6 +246,7 @@ 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 = ?, session_version = session_version + 1 WHERE id = ?', [passwordHash, userId]); + await pool.query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, userId]); + await revokeAll(userId, userId, 'password_change', currentSessionId); await logAudit({ userId, action: 'change_password', entityType: 'user', entityId: userId }); } diff --git a/backend/src/modules/auth/sessions-repository.js b/backend/src/modules/auth/sessions-repository.js new file mode 100644 index 0000000..ec41e90 --- /dev/null +++ b/backend/src/modules/auth/sessions-repository.js @@ -0,0 +1,54 @@ +import { pool } from '../../config/db.js'; + +export async function create({ publicId, userId, ip, userAgent, lifetimeSeconds }) { + await pool.query( + `INSERT INTO user_sessions (public_id, user_id, ip, user_agent, expires_at) + VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND))`, + [publicId, userId, ip, userAgent, lifetimeSeconds] + ); +} + +export async function findValid(publicId, userId) { + const [rows] = await pool.query( + `SELECT id FROM user_sessions WHERE public_id = ? AND user_id = ? + AND revoked_at IS NULL AND expires_at > NOW() LIMIT 1`, [publicId, userId] + ); + return rows[0] || null; +} + +export async function touch(id) { + await pool.query(`UPDATE user_sessions SET last_seen_at = NOW() + WHERE id = ? AND last_seen_at < DATE_SUB(NOW(), INTERVAL 60 SECOND)`, [id]); +} + +export async function list(userId) { + const [rows] = await pool.query( + `SELECT id, public_id, user_id, ip, user_agent, created_at, last_seen_at, expires_at + FROM user_sessions WHERE user_id = ? AND revoked_at IS NULL AND expires_at > NOW() + ORDER BY last_seen_at DESC`, [userId] + ); + return rows; +} + +export async function findActive(id, userId) { + const [rows] = await pool.query( + `SELECT id, public_id, user_id, ip, user_agent FROM user_sessions + WHERE id = ? AND user_id = ? AND revoked_at IS NULL AND expires_at > NOW() LIMIT 1`, [id, userId] + ); + return rows[0] || null; +} + +export async function revoke(id, userId, actorId, reason) { + const [result] = await pool.query( + `UPDATE user_sessions SET revoked_at = NOW(), revoked_by = ?, revoke_reason = ? + WHERE id = ? AND user_id = ? AND revoked_at IS NULL AND expires_at > NOW()`, + [actorId, reason, id, userId] + ); + return result.affectedRows > 0; +} + +export async function cleanup() { + await pool.query(`DELETE FROM user_sessions + WHERE expires_at < DATE_SUB(NOW(), INTERVAL 30 DAY) + OR revoked_at < DATE_SUB(NOW(), INTERVAL 30 DAY)`); +} diff --git a/backend/src/modules/auth/sessions-service.js b/backend/src/modules/auth/sessions-service.js new file mode 100644 index 0000000..00c00ca --- /dev/null +++ b/backend/src/modules/auth/sessions-service.js @@ -0,0 +1,63 @@ +import crypto from 'node:crypto'; +import jwt from 'jsonwebtoken'; +import { env } from '../../config/env.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; +import { pool } from '../../config/db.js'; +import * as repo from './sessions-repository.js'; + +function clientName(agent = '') { + const browser = /Edg\//.test(agent) ? 'Edge' : /OPR\//.test(agent) ? 'Opera' : /Chrome\//.test(agent) ? 'Chrome' : /Firefox\//.test(agent) ? 'Firefox' : /Safari\//.test(agent) ? 'Safari' : 'unknown'; + const os = /Windows/.test(agent) ? 'Windows' : /Android/.test(agent) ? 'Android' : /iPhone|iPad/.test(agent) ? 'iOS' : /Mac OS/.test(agent) ? 'macOS' : /Linux/.test(agent) ? 'Linux' : 'unknown'; + return { browser, os }; +} + +export async function issueSession(userId, sessionVersion, req) { + // Limpieza al iniciar sesion: el backend no tiene un programador de tareas propio. + await repo.cleanup(); + const sid = crypto.randomBytes(16).toString('hex'); + const token = jwt.sign({ sub: userId, sv: sessionVersion, sid }, env.jwtSecret, { expiresIn: env.jwtExpires }); + const decoded = jwt.decode(token); + await repo.create({ publicId: sid, userId, ip: req.ip?.slice(0, 45) || null, + userAgent: req.get('user-agent')?.slice(0, 512) || null, lifetimeSeconds: decoded.exp - decoded.iat }); + return token; +} + +export async function validateSession(sid, userId) { + if (typeof sid !== 'string' || !/^[a-f0-9]{32}$/.test(sid)) return null; + const session = await repo.findValid(sid, userId); + if (session) await repo.touch(session.id); + return session; +} + +export async function listSessions(userId, currentSessionId = null) { + return (await repo.list(userId)).map(({ public_id, user_agent, ...row }) => ({ + ...row, ...clientName(user_agent), current: public_id === currentSessionId, + })); +} + +export async function revokeSession(userId, sessionId, actorId, reason, currentSessionId = null) { + if (!/^[1-9]\d*$/.test(String(sessionId))) throw new HttpError(404, 'Sesion no encontrada'); + const session = await repo.findActive(sessionId, userId); + if (!session) throw new HttpError(404, 'Sesion no encontrada'); + if (await repo.revoke(session.id, userId, actorId, reason)) { + await logAudit({ userId: actorId, action: 'revoke_session', entityType: 'user_session', entityId: session.id, + detail: { affectedUserId: userId, ...clientName(session.user_agent), ip: session.ip, reason } }); + } + return { revoked: true, current: session.public_id === currentSessionId }; +} + +export async function revokeAll(userId, actorId, reason, exceptSessionId = null) { + const sessions = await repo.list(userId); + for (const session of sessions) { + if (session.public_id !== exceptSessionId) await revokeSession(userId, session.id, actorId, reason, exceptSessionId); + } + return { revoked: sessions.filter((s) => s.public_id !== exceptSessionId).length }; +} + +export async function assertManageTarget(userId, actor) { + if (!Number.isSafeInteger(userId) || userId < 1) throw new HttpError(404, 'Usuario no encontrado'); + const [rows] = await pool.query(`SELECT u.id, r.code AS role_code FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`, [userId]); + if (!rows[0]) throw new HttpError(404, 'Usuario no encontrado'); + if (rows[0].role_code === 'superadmin' && actor.roleCode !== 'superadmin') throw new HttpError(403, 'No puedes administrar sesiones de un superadministrador'); +} diff --git a/backend/src/modules/users/controller.js b/backend/src/modules/users/controller.js index 4bbd581..0f87338 100644 --- a/backend/src/modules/users/controller.js +++ b/backend/src/modules/users/controller.js @@ -1,5 +1,6 @@ import { ok, asyncHandler } from '../../utils/http.js'; import * as service from './service.js'; +import * as sessions from '../auth/sessions-service.js'; export const getUsers = asyncHandler(async (req, res) => ok(res, await service.listUsers())); export const getRoles = asyncHandler(async (req, res) => ok(res, await service.listRoles())); @@ -38,3 +39,20 @@ export const getUserModules = asyncHandler(async (req, res) => ok(res, await ser export const putUserModules = asyncHandler(async (req, res) => { ok(res, await service.setUserModules(Number(req.params.id), req.body?.modules, req.currentUser)); }); + +export const getUserSessions = asyncHandler(async (req, res) => { + const userId = Number(req.params.id); + await sessions.assertManageTarget(userId, req.currentUser); + ok(res, await sessions.listSessions(userId, req.currentUser.id === userId ? req.currentUser.sessionPublicId : null)); +}); +export const deleteUserSession = asyncHandler(async (req, res) => { + const userId = Number(req.params.id); + await sessions.assertManageTarget(userId, req.currentUser); + ok(res, await sessions.revokeSession(userId, req.params.sessionId, req.currentUser.id, 'admin', req.currentUser.sessionPublicId)); +}); +export const postRevokeUserSessions = asyncHandler(async (req, res) => { + const userId = Number(req.params.id); + await sessions.assertManageTarget(userId, req.currentUser); + const result = await sessions.revokeAll(userId, req.currentUser.id, 'admin'); + ok(res, { ...result, current: userId === req.currentUser.id }); +}); diff --git a/backend/src/modules/users/routes.js b/backend/src/modules/users/routes.js index 00fd123..4103c64 100644 --- a/backend/src/modules/users/routes.js +++ b/backend/src/modules/users/routes.js @@ -3,7 +3,7 @@ import { requireAuth, requireRole, CAN_MANAGE_USERS, ROLES } from '../../middlew import { requireModule } from '../../middleware/modules.js'; import { getUsers, getRoles, postUser, putUser, patchUserActive, postApproveUser, postRejectUser, postResendUserEmail, - getUserModules, putUserModules, + getUserModules, putUserModules, getUserSessions, deleteUserSession, postRevokeUserSessions, } from './controller.js'; const router = Router(); @@ -19,5 +19,8 @@ router.post('/:id/reject', requireModule('organization'), requireRole(...CAN_MAN router.get('/:id/modules', requireRole(ROLES.SUPERADMIN), getUserModules); router.put('/:id/modules', requireRole(ROLES.SUPERADMIN), putUserModules); router.post('/:id/resend', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), postResendUserEmail); +router.get('/:id/sessions', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), getUserSessions); +router.delete('/:id/sessions/:sessionId', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), deleteUserSession); +router.post('/:id/sessions/revoke-all', requireModule('organization'), requireRole(...CAN_MANAGE_USERS), postRevokeUserSessions); export default router; diff --git a/backend/src/modules/users/service.js b/backend/src/modules/users/service.js index 6e7edd5..0aee0b9 100644 --- a/backend/src/modules/users/service.js +++ b/backend/src/modules/users/service.js @@ -8,6 +8,7 @@ import { MODULES, loadModules } from '../../middleware/modules.js'; import { sendMail } from '../../utils/mailer.js'; import { env } from '../../config/env.js'; import { invitationEmail, verificationEmail } from '../../utils/emailHtml.js'; +import { revokeAll } from '../auth/sessions-service.js'; export const listUsers = () => repo.findAll(); export const listRoles = () => repo.listRoles(); @@ -79,6 +80,7 @@ export async function setUserActive(id, isActive, actor) { if (!existing) throw new HttpError(404, 'Usuario no encontrado'); checkTarget(existing, actor); const user = await repo.setActive(id, isActive); + if (!isActive) await revokeAll(id, actor.id, 'admin'); await logAudit({ userId: actor.id, action: isActive ? 'activate' : 'deactivate', diff --git a/backend/src/utils/http.js b/backend/src/utils/http.js index a9dba10..ff82c94 100644 --- a/backend/src/utils/http.js +++ b/backend/src/utils/http.js @@ -2,8 +2,8 @@ export function ok(res, data = null, status = 200) { return res.status(status).json({ success: true, data, error: null }); } -export function fail(res, status, message) { - return res.status(status).json({ success: false, data: null, error: message }); +export function fail(res, status, message, code) { + return res.status(status).json({ success: false, data: null, error: message, ...(code ? { code } : {}) }); } export class HttpError extends Error { diff --git a/frontend/package.json b/frontend/package.json index ff0bc4b..bc22067 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "4.2.2", + "version": "4.3.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index bfb99ea..5f04315 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -23,6 +23,7 @@ import NotasPage from '../pages/Notas/NotasPage.jsx'; import ContrasenasPage from '../pages/Contrasenas/ContrasenasPage.jsx'; import EquiposPage from '../pages/Equipos/EquiposPage.jsx'; import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx'; +import MiCuentaPage from '../pages/MiCuenta/MiCuentaPage.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. @@ -37,6 +38,7 @@ const router = createBrowserRouter(createRoutesFromElements( } /> }> } /> + } /> } /> } /> } /> diff --git a/frontend/src/app/AppShell.jsx b/frontend/src/app/AppShell.jsx index 127643d..4e0d243 100644 --- a/frontend/src/app/AppShell.jsx +++ b/frontend/src/app/AppShell.jsx @@ -1,37 +1,27 @@ 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 { Menu, Sun, Moon, Bell, LogOut, User, Globe, UserCog } 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'; -import { Button } from '../components/Button.jsx'; -import { PasswordInput } from '../components/PasswordInput.jsx'; import { useAuth } from '../context/auth-context.jsx'; import { useUi } from '../context/ui-context.jsx'; import { useAsync } from '../hooks/useAsync.js'; import { listNotifications, markAllNotificationsRead, markNotificationRead } from '../services/notifications.js'; -import { changePassword } from '../services/auth.js'; import { getUpdateStatus } from '../services/updater.js'; -const EMPTY_PASSWORD_FORM = { currentPassword: '', newPassword: '', confirmPassword: '' }; - const MOBILE_BREAKPOINT = 768; export default function AppShell() { const [navMode, setNavMode] = useState('expanded'); const [mobileNavOpen, setMobileNavOpen] = useState(false); const { user, logout } = useAuth(); - const { t, theme, setTheme, lang, setLang, pushToast, openDialog } = useUi(); + const { t, theme, setTheme, lang, setLang, openDialog } = useUi(); const navigate = useNavigate(); const { data: notifData, reload: reloadNotifications } = useAsync(listNotifications, []); const unread = notifData?.unread || 0; - const [changingPassword, setChangingPassword] = useState(false); - const [passwordForm, setPasswordForm] = useState(EMPTY_PASSWORD_FORM); - const [passwordError, setPasswordError] = useState(''); // Al entrar (una vez por sesion, no en cada navegacion): si es superadmin y hay una // actualizacion pendiente, avisar con un popup y un acceso directo al panel. Silencioso si @@ -80,31 +70,6 @@ export default function AppShell() { else if (notification.entity_type === 'activity') navigate(`/proyectos`); } - function openChangePassword() { - setPasswordForm(EMPTY_PASSWORD_FORM); - setPasswordError(''); - setChangingPassword(true); - } - - async function submitChangePassword() { - setPasswordError(''); - if (passwordForm.newPassword !== passwordForm.confirmPassword) { - setPasswordError(t('register_password_mismatch')); - return; - } - if (passwordForm.newPassword.length < 8) { - setPasswordError(t('register_password_short')); - return; - } - try { - await changePassword(passwordForm.currentPassword, passwordForm.newPassword); - setChangingPassword(false); - pushToast({ tone: 'success', message: t('change_password_success') }); - } catch (err) { - setPasswordError(err.message || t('common_error')); - } - } - return (
@@ -157,10 +122,10 @@ export default function AppShell() {
{user?.roleName || user?.roleCode}
- - {t('change_password_title')} + navigate('/mi-cuenta')}> + {t('account_title')} - { logout(); navigate('/login'); }}> + { logout({ remote: true }); navigate('/login'); }}> {t('logout')} @@ -177,22 +142,6 @@ export default function AppShell() { - setChangingPassword(false)} width={420} height={380}> - {passwordError &&
{passwordError}
} - - setPasswordForm((f) => ({ ...f, currentPassword: e.target.value }))} autoFocus /> - - - setPasswordForm((f) => ({ ...f, newPassword: e.target.value }))} /> - - - setPasswordForm((f) => ({ ...f, confirmPassword: e.target.value }))} /> - -
- - -
-
); } diff --git a/frontend/src/components/ListView.jsx b/frontend/src/components/ListView.jsx index c14d59d..b955f3f 100644 --- a/frontend/src/components/ListView.jsx +++ b/frontend/src/components/ListView.jsx @@ -57,6 +57,7 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC onRowClick?.(row)} + onContextMenu={rowActions ? (event) => { event.preventDefault(); event.currentTarget.querySelector('.row-menu-btn')?.click(); } : undefined} 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' : ''} diff --git a/frontend/src/components/SessionsList.jsx b/frontend/src/components/SessionsList.jsx new file mode 100644 index 0000000..72deb8e --- /dev/null +++ b/frontend/src/components/SessionsList.jsx @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useState } from 'react'; +import { LogOut, RefreshCw } from 'lucide-react'; +import { useUi } from '../context/ui-context.jsx'; +import { ListView } from './ListView.jsx'; +import { Button } from './Button.jsx'; +import { Badge } from './Badge.jsx'; + +export function SessionsList({ load, revoke, revokeAll, onCurrentRevoked, allLabel }) { + const { t, lang, openDialog, pushToast } = useUi(); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const reload = useCallback(async () => { + setLoading(true); + try { setSessions(await load()); } + catch (error) { pushToast({ tone: 'error', message: error.message }); } + finally { setLoading(false); } + }, [load, pushToast]); + useEffect(() => { reload(); }, [reload]); + + function confirm(action, message, current = false) { + openDialog({ tone: 'warning', title: t('sessions_close'), message, + actions: [ + { label: t('common_cancel'), variant: 'standard' }, + { label: t('sessions_close'), variant: 'danger', onClick: async () => { + try { + const result = await action(); + if (current || result?.current) onCurrentRevoked?.(); + else { await reload(); pushToast({ tone: 'success', message: t('sessions_closed') }); } + } catch (error) { pushToast({ tone: 'error', message: error.message }); } + } }, + ] }); + } + const date = (value) => value ? new Date(value.replace(' ', 'T')).toLocaleString(lang) : '—'; + const columns = [ + { key: 'browser', label: t('sessions_device'), render: (row) => <>{row.browser === 'unknown' ? t('sessions_unknown') : row.browser} · {row.os === 'unknown' ? t('sessions_unknown') : row.os} {row.current && {t('sessions_current')}} }, + { key: 'ip', label: t('sessions_ip'), render: (row) => row.ip || '—' }, + { key: 'created_at', label: t('sessions_started'), render: (row) => date(row.created_at) }, + { key: 'last_seen_at', label: t('sessions_seen'), render: (row) => date(row.last_seen_at) }, + { key: 'close', label: t('sessions_close'), sortable: false, render: (row) => }, + ]; + return
+
+ + {sessions.some((session) => allLabel || !session.current) && } +
+ {loading ?
{t('common_loading')}
: row.id} + rowActions={(row) => [{ label: t('sessions_close'), icon: LogOut, danger: true, onClick: () => confirm(() => revoke(row.id), t('sessions_confirm'), row.current) }]} />} +
; +} diff --git a/frontend/src/context/auth-context.jsx b/frontend/src/context/auth-context.jsx index 9a0bf3b..5a45c24 100644 --- a/frontend/src/context/auth-context.jsx +++ b/frontend/src/context/auth-context.jsx @@ -12,6 +12,7 @@ export function AuthProvider({ children }) { const [user, setUser] = useState(null); const [ready, setReady] = useState(false); const userRef = useRef(null); + const lastPingRef = useRef(0); userRef.current = user; const logout = useCallback((options = {}) => { @@ -19,21 +20,22 @@ export function AuthProvider({ children }) { // (los borradores ya estan guardados en el navegador). setGuardBypass(true); if (!options.preserveDrafts) clearUserDrafts(userRef.current?.id); + if (options.remote) api.post('/auth/logout').catch(() => {}); api.setToken(null); setUser(null); }, []); // 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(() => { + const expireSession = useCallback((info = {}) => { if (!localStorage.getItem('plc_token')) return; - if (userRef.current) saveReturnContext(userRef.current.id); - markSessionExpired(); + saveReturnContext(userRef.current?.id || tokenSubject(localStorage.getItem('plc_token'))); + markSessionExpired(info.code === 'SESSION_REVOKED'); logout({ preserveDrafts: true }); }, [logout]); useEffect(() => { - setUnauthorizedHandler(() => expireSession()); + setUnauthorizedHandler((info) => expireSession(info)); }, [expireSession]); useEffect(() => { @@ -47,9 +49,10 @@ export function AuthProvider({ children }) { setReady(true); return; } + lastPingRef.current = Date.now(); api.get('/auth/me') .then((me) => setUser(me)) - .catch(() => logout()) + .catch((error) => { if (error.status !== 401) logout(); }) .finally(() => setReady(true)); }, [logout]); @@ -59,7 +62,11 @@ export function AuthProvider({ children }) { useEffect(() => { if (!user) return undefined; const check = () => { - if (isTokenExpired(localStorage.getItem('plc_token'), 0)) expireSession(); + if (isTokenExpired(localStorage.getItem('plc_token'), 0)) { expireSession(); return; } + if (document.visibilityState === 'visible' && Date.now() - lastPingRef.current >= 60000) { + lastPingRef.current = Date.now(); + api.get('/auth/me').catch(() => {}); + } }; 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; @@ -81,6 +88,7 @@ export function AuthProvider({ children }) { // 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); + lastPingRef.current = Date.now(); clearSessionExpiredFlag(); setGuardBypass(false); setUser(sessionUser); diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index e345cc0..55e889f 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -267,6 +267,16 @@ export const messages = { 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.', + session_revoked_login_msg: 'Tu sesión fue cerrada desde otro dispositivo o por un administrador. Inicia sesión otra vez para volver a donde estabas.', + account_title: 'Mi cuenta', + account_tab_profile: 'Perfil', account_tab_preferences: 'Preferencias', account_tab_security: 'Seguridad', account_tab_sessions: 'Sesiones activas', + account_language: 'Idioma', account_theme: 'Tema', account_buttons: 'Apariencia de botones', + change_password_sessions_closed: 'Clave actualizada. Se cerraron las demás sesiones.', + sessions_title: 'Sesiones', sessions_device: 'Navegador y sistema', sessions_ip: 'IP', sessions_started: 'Inicio', sessions_seen: 'Última actividad', + sessions_current: 'Esta sesión', sessions_close: 'Cerrar sesión', sessions_close_others: 'Cerrar todas las demás', sessions_close_all: 'Cerrar todas', + sessions_confirm: '¿Quieres cerrar esta sesión?', sessions_confirm_all: '¿Quieres cerrar estas sesiones?', sessions_closed: 'Sesiones cerradas.', + sessions_refresh: 'Actualizar', + sessions_unknown: 'Desconocido', dashboard_cards: 'Tarjetas', dashboard_no_cards: 'No hay tarjetas seleccionadas. Elige cuáles mostrar en "Tarjetas".', dashboard_projects_by_status: 'Proyectos por estado', @@ -868,6 +878,16 @@ export const messages = { 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.', + session_revoked_login_msg: 'Your session was closed from another device or by an administrator. Sign in again to return where you were.', + account_title: 'My account', + account_tab_profile: 'Profile', account_tab_preferences: 'Preferences', account_tab_security: 'Security', account_tab_sessions: 'Active sessions', + account_language: 'Language', account_theme: 'Theme', account_buttons: 'Button appearance', + change_password_sessions_closed: 'Password updated. Your other sessions were closed.', + sessions_title: 'Sessions', sessions_device: 'Browser and system', sessions_ip: 'IP', sessions_started: 'Started', sessions_seen: 'Last activity', + sessions_current: 'This session', sessions_close: 'Close session', sessions_close_others: 'Close all others', sessions_close_all: 'Close all', + sessions_confirm: 'Do you want to close this session?', sessions_confirm_all: 'Do you want to close these sessions?', sessions_closed: 'Sessions closed.', + sessions_refresh: 'Refresh', + sessions_unknown: 'Unknown', dashboard_cards: 'Cards', dashboard_no_cards: 'No cards selected. Choose which ones to show in "Cards".', dashboard_projects_by_status: 'Projects by status', diff --git a/frontend/src/pages/Login/LoginPage.jsx b/frontend/src/pages/Login/LoginPage.jsx index 8655b84..7019ee3 100644 --- a/frontend/src/pages/Login/LoginPage.jsx +++ b/frontend/src/pages/Login/LoginPage.jsx @@ -8,7 +8,7 @@ import { Field } from '../../components/Field.jsx'; import { Button } from '../../components/Button.jsx'; import { PasswordInput } from '../../components/PasswordInput.jsx'; import { InfoBar } from '../../components/InfoBar.jsx'; -import { readSessionExpiredFlag } from '../../utils/session.js'; +import { readSessionExpiredFlag, readSessionRevokedFlag } from '../../utils/session.js'; import logoDark from '../../assets/logoLinkedCorpTxt.png'; import logoLight from '../../assets/logoLinkedCorpTxtBlack.png'; import logoLinkedCorpIcon from '../../assets/logoLinkedTrackIcon.png'; @@ -21,6 +21,7 @@ export default function LoginPage() { const location = useLocation(); // Aviso de sesion vencida (se lee una sola vez al montar el login). const [sessionExpired] = useState(() => readSessionExpiredFlag()); + const [sessionRevoked] = useState(() => readSessionRevokedFlag()); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); @@ -36,7 +37,7 @@ export default function LoginPage() { const { returnTo } = await login(email, password); // Tras un vencimiento solo vale returnTo (verificado contra el usuario que entra): la ruta // "from" de la redireccion podria ser la de OTRO usuario que uso este navegador. - const from = sessionExpired ? null : location.state?.from; + const from = sessionExpired || sessionRevoked ? null : location.state?.from; navigate(returnTo || (typeof from === 'string' && from.startsWith('/') ? from : '/'), { replace: true }); } catch (err) { setError(err.message || t('login_error')); @@ -55,6 +56,9 @@ export default function LoginPage() { {sessionExpired && !error && ( {t('session_expired_login_msg')} )} + {sessionRevoked && !error && ( + {t('session_revoked_login_msg')} + )} {error &&
{error}
} setEmail(e.target.value)} required autoFocus /> diff --git a/frontend/src/pages/MiCuenta/MiCuentaPage.jsx b/frontend/src/pages/MiCuenta/MiCuentaPage.jsx new file mode 100644 index 0000000..2fb619a --- /dev/null +++ b/frontend/src/pages/MiCuenta/MiCuentaPage.jsx @@ -0,0 +1,69 @@ +import { useCallback, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useAuth } from '../../context/auth-context.jsx'; +import { useUi } from '../../context/ui-context.jsx'; +import { Tabs } from '../../components/Tabs.jsx'; +import { Field, FieldRow } from '../../components/Field.jsx'; +import { ComboBox } from '../../components/ComboBox.jsx'; +import { Button } from '../../components/Button.jsx'; +import { PasswordInput } from '../../components/PasswordInput.jsx'; +import { SessionsList } from '../../components/SessionsList.jsx'; +import { useDraftForm } from '../../hooks/useDraftForm.js'; +import { changePassword, listSessions, revokeSession, revokeOtherSessions } from '../../services/auth.js'; + +const EMPTY = { currentPassword: '', newPassword: '', confirmPassword: '' }; +const TABS = ['profile', 'preferences', 'security', 'sessions']; + +export default function MiCuentaPage() { + const { user, logout } = useAuth(); + const { t, lang, setLang, theme, setTheme, buttonMode, setButtonMode, pushToast } = useUi(); + const [params, setParams] = useSearchParams(); + const navigate = useNavigate(); + const [error, setError] = useState(''); + const tab = TABS.includes(params.get('tab')) ? params.get('tab') : 'profile'; + const draft = useDraftForm({ formId: 'account-password', initial: EMPTY, label: t('change_password_title'), enabled: tab === 'security' }); + const form = draft.values; + const setForm = draft.setValues; + const load = useCallback(() => listSessions(), []); + const revoke = useCallback((id) => revokeSession(id), []); + const revokeOthers = useCallback(() => revokeOtherSessions(), []); + + async function submit(event) { + event.preventDefault(); + setError(''); + if (form.newPassword !== form.confirmPassword) { setError(t('register_password_mismatch')); return; } + if (form.newPassword.length < 8) { setError(t('register_password_short')); return; } + try { + await changePassword(form.currentPassword, form.newPassword); + draft.clear(); + pushToast({ tone: 'success', message: t('change_password_sessions_closed') }); + } catch (err) { setError(err.message || t('common_error')); } + } + + return
+

{t('account_title')}

+ ({ key, label: t(`account_tab_${key}`) }))} active={tab} onChange={(key) => setParams({ tab: key })} /> +
+ {tab === 'profile' &&
+
{user?.name || '—'}
+
{user?.email || '—'}
+
{user?.roleName || user?.roleCode || '—'}
+
{user?.departmentName || '—'}
+
} + {tab === 'preferences' &&
+ + + +
} + {tab === 'security' &&
+

{t('change_password_title')}

+ {error &&
{error}
} + setForm((f) => ({ ...f, currentPassword: e.target.value }))} required /> + setForm((f) => ({ ...f, newPassword: e.target.value }))} required /> + setForm((f) => ({ ...f, confirmPassword: e.target.value }))} required /> + +
} + {tab === 'sessions' && { logout(); navigate('/login'); }} />} +
+
; +} diff --git a/frontend/src/pages/Organizacion/UsersPanel.jsx b/frontend/src/pages/Organizacion/UsersPanel.jsx index 210200c..88051b2 100644 --- a/frontend/src/pages/Organizacion/UsersPanel.jsx +++ b/frontend/src/pages/Organizacion/UsersPanel.jsx @@ -1,9 +1,10 @@ -import { useState } from 'react'; -import { Plus, Save, UserX, UserCheck, Check, X as XIcon, Mail, LayoutGrid } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Plus, Save, UserX, UserCheck, Check, X as XIcon, Mail, LayoutGrid, Monitor } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; import { useAuth } from '../../context/auth-context.jsx'; import { useAsync, useAction } from '../../hooks/useAsync.js'; -import { listUsers, listRoles, createUser, updateUser, setUserActive, approveUser, rejectUser, resendUserEmail } from '../../services/users.js'; +import { listUsers, listRoles, createUser, updateUser, setUserActive, approveUser, rejectUser, resendUserEmail, listUserSessions, revokeUserSession, revokeAllUserSessions } from '../../services/users.js'; import { listDepartments } from '../../services/departments.js'; import { ListView } from '../../components/ListView.jsx'; import { SearchInput } from '../../components/SearchInput.jsx'; @@ -17,6 +18,7 @@ import { DraftNotice } from '../../components/DraftNotice.jsx'; import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx'; import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js'; import { PasswordInput } from '../../components/PasswordInput.jsx'; +import { SessionsList } from '../../components/SessionsList.jsx'; function flatten(nodes, acc = []) { for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); } @@ -28,7 +30,8 @@ const EMPTY_FORM = { name: '', email: '', roleId: '', departmentId: '', position export default function UsersPanel() { const { t, pushToast, openDialog } = useUi(); - const { user: currentUser } = useAuth(); + const { user: currentUser, logout } = useAuth(); + const navigate = useNavigate(); const runAction = useAction(); const { data: users, reload } = useAsync(listUsers, []); const { data: roles } = useAsync(listRoles, []); @@ -37,6 +40,10 @@ export default function UsersPanel() { const canManage = ['admin', 'superadmin'].includes(currentUser?.roleCode); const isSuperadmin = currentUser?.roleCode === 'superadmin'; const [modulesUser, setModulesUser] = useState(null); + const [sessionsUser, setSessionsUser] = useState(null); + const loadSessions = useCallback(() => listUserSessions(sessionsUser.id), [sessionsUser]); + const revokeSession = useCallback((id) => revokeUserSession(sessionsUser.id, id), [sessionsUser]); + const revokeAllSessions = useCallback(() => revokeAllUserSessions(sessionsUser.id), [sessionsUser]); const [search, setSearch] = useState(''); const [editing, setEditing] = useState(false); @@ -182,6 +189,7 @@ export default function UsersPanel() { } return [ { label: t('common_edit'), onClick: () => startEdit(u) }, + ...(u.role_code !== 'superadmin' || isSuperadmin ? [{ label: t('sessions_title'), icon: Monitor, onClick: () => setSessionsUser(u) }] : []), ...(isSuperadmin && u.role_code !== 'superadmin' ? [{ label: t('user_modules'), icon: LayoutGrid, onClick: () => setModulesUser(u) }] : []), u.is_active ? { label: t('common_inactive'), icon: UserX, danger: true, onClick: () => toggleActive(u) } @@ -191,6 +199,10 @@ export default function UsersPanel() { /> {modulesUser && setModulesUser(null)} />} + setSessionsUser(null)} width={850} height={540}> + {sessionsUser && { logout(); navigate('/login'); }} />} + guardedClose(draft, () => setEditing(false))} width={560} height={500}> diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index f76ca1f..dd09b91 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -33,19 +33,18 @@ async function request(method, path, { body, isForm, extraHeaders, signal } = {} const refreshedVaultToken = res.headers.get('x-vault-token'); if (refreshedVaultToken) sessionStorage.setItem('plc_vault_token', refreshedVaultToken); - if (res.status === 401 && token) { - onUnauthorized({ expired: true }); - } - if (res.status === 204) return null; const isJson = (res.headers.get('content-type') || '').includes('application/json'); const payload = isJson ? await res.json() : null; + if (res.status === 401 && token) onUnauthorized({ code: payload?.code }); + if (!res.ok) { const message = payload?.error || `Error ${res.status}`; const error = new Error(message); error.status = res.status; + error.code = payload?.code; throw error; } return payload?.data; diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js index e91e29f..77fec61 100644 --- a/frontend/src/services/auth.js +++ b/frontend/src/services/auth.js @@ -5,5 +5,8 @@ export const verifyEmail = (token) => api.get(`/auth/verify-email?token=${encode export const forgotPassword = (email) => api.post('/auth/forgot-password', { email }); export const resetPassword = (token, password) => api.post('/auth/reset-password', { token, password }); export const changePassword = (currentPassword, newPassword) => api.put('/auth/change-password', { currentPassword, newPassword }); +export const listSessions = () => api.get('/auth/sessions'); +export const revokeSession = (id) => api.delete(`/auth/sessions/${id}`); +export const revokeOtherSessions = () => api.post('/auth/sessions/revoke-others'); export const getInviteInfo = (token) => api.get(`/auth/invite?token=${encodeURIComponent(token)}`); export const acceptInvite = (token, name, password) => api.post('/auth/accept-invite', { token, name, password }); diff --git a/frontend/src/services/users.js b/frontend/src/services/users.js index d1c4674..598500f 100644 --- a/frontend/src/services/users.js +++ b/frontend/src/services/users.js @@ -10,3 +10,6 @@ export const rejectUser = (id) => api.post(`/users/${id}/reject`); export const resendUserEmail = (id) => api.post(`/users/${id}/resend`); export const getUserModules = (id) => api.get(`/users/${id}/modules`); export const setUserModules = (id, modules) => api.put(`/users/${id}/modules`, { modules }); +export const listUserSessions = (id) => api.get(`/users/${id}/sessions`); +export const revokeUserSession = (id, sessionId) => api.delete(`/users/${id}/sessions/${sessionId}`); +export const revokeAllUserSessions = (id) => api.post(`/users/${id}/sessions/revoke-all`); diff --git a/frontend/src/utils/drafts.js b/frontend/src/utils/drafts.js index a55616a..91a4117 100644 --- a/frontend/src/utils/drafts.js +++ b/frontend/src/utils/drafts.js @@ -22,6 +22,7 @@ const DRAFT_FIELDS = { 'user-new': ['roleId', 'departmentId'], 'user-edit': ['editVersion', 'roleId', 'departmentId'], 'user-approve': ['editVersion', 'roleId', 'departmentId'], + 'account-password': [], 'recurring-new': ['projectId', 'responsibleUserId', 'intervalUnit', 'intervalValue', 'nextDueDate', 'endDate', 'reminderDaysBefore', 'reminderHour'], 'recurring-edit': ['editVersion', 'projectId', 'responsibleUserId', 'intervalUnit', 'intervalValue', 'nextDueDate', 'endDate', 'reminderDaysBefore', 'reminderHour'], 'device-edit': ['editVersion', 'status', 'assignedUserId', 'departmentId'], diff --git a/frontend/src/utils/session.js b/frontend/src/utils/session.js index 6db8ad9..c92a3db 100644 --- a/frontend/src/utils/session.js +++ b/frontend/src/utils/session.js @@ -3,6 +3,7 @@ // limitado; otro usuario que inicie sesion en el mismo navegador la descarta sin verla. const RETURN_KEY = 'plc_return_ctx'; const EXPIRED_FLAG = 'plc_session_expired'; +const REVOKED_FLAG = 'plc_session_revoked'; const RETURN_TTL_MS = 24 * 60 * 60 * 1000; export function saveReturnContext(userId) { @@ -30,8 +31,15 @@ export function consumeReturnContext(userId) { } } -export function markSessionExpired() { - try { sessionStorage.setItem(EXPIRED_FLAG, '1'); } catch { /* aviso opcional */ } +export function markSessionExpired(revoked = false) { + try { + sessionStorage.setItem(revoked ? REVOKED_FLAG : EXPIRED_FLAG, '1'); + sessionStorage.removeItem(revoked ? EXPIRED_FLAG : REVOKED_FLAG); + } catch { /* aviso opcional */ } +} + +export function readSessionRevokedFlag() { + try { return sessionStorage.getItem(REVOKED_FLAG) === '1'; } catch { return false; } } export function readSessionExpiredFlag() { @@ -39,5 +47,5 @@ export function readSessionExpiredFlag() { } export function clearSessionExpiredFlag() { - try { sessionStorage.removeItem(EXPIRED_FLAG); } catch { /* nada */ } + try { sessionStorage.removeItem(EXPIRED_FLAG); sessionStorage.removeItem(REVOKED_FLAG); } catch { /* nada */ } }