feat: version 4.3.0 -- Sesiones activas y pagina Mi cuenta

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-26 16:42:19 -05:00
parent 544928d04a
commit a6cc41666e
34 changed files with 464 additions and 110 deletions
+1
View File
@@ -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
+11 -3
View File
@@ -9,8 +9,10 @@
- Tipo: Bearer JWT (`Authorization: Bearer <token>`), 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.
+9 -3
View File
@@ -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`
+18
View File
@@ -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
+8
View File
@@ -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
+4 -1
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
4.2.2
4.3.0
@@ -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;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "4.2.2",
"version": "4.3.0",
"private": true,
"type": "module",
"main": "src/server.js",
+12 -2
View File
@@ -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();
}
+15 -6
View File
@@ -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)));
+4 -1
View File
@@ -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;
+17 -13
View File
@@ -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 });
}
@@ -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)`);
}
@@ -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');
}
+18
View File
@@ -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 });
});
+4 -1
View File
@@ -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;
+2
View File
@@ -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',
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "4.2.2",
"version": "4.3.0",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -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(
<Route path="/aceptar-invitacion" element={<AcceptInvitePage />} />
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
<Route index element={<DashboardPage />} />
<Route path="mi-cuenta" element={<MiCuentaPage />} />
<Route path="proyectos" element={<ModuleRoute module="projects"><ProyectosPage /></ModuleRoute>} />
<Route path="proyectos/:id" element={<ModuleRoute module="projects"><ProyectoDetallePage /></ModuleRoute>} />
<Route path="tareas-recurrentes" element={<ModuleRoute module="recurring_tasks"><RecurringTasksPage /></ModuleRoute>} />
+5 -56
View File
@@ -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 (
<div className="app-shell">
<NavigationView mode={navMode} mobileOpen={mobileNavOpen} onNavigate={closeMobileNav} />
@@ -157,10 +122,10 @@ export default function AppShell() {
<div style={{ fontSize: '.7rem', color: 'rgb(var(--muted))' }}>{user?.roleName || user?.roleCode}</div>
</div>
<FlyoutSeparator />
<FlyoutItem icon={KeyRound} onClick={openChangePassword}>
{t('change_password_title')}
<FlyoutItem icon={UserCog} onClick={() => navigate('/mi-cuenta')}>
{t('account_title')}
</FlyoutItem>
<FlyoutItem icon={LogOut} danger onClick={() => { logout(); navigate('/login'); }}>
<FlyoutItem icon={LogOut} danger onClick={() => { logout({ remote: true }); navigate('/login'); }}>
{t('logout')}
</FlyoutItem>
</Flyout>
@@ -177,22 +142,6 @@ export default function AppShell() {
</div>
</div>
<DetailPopup open={changingPassword} title={t('change_password_title')} onClose={() => setChangingPassword(false)} width={420} height={380}>
{passwordError && <div className="login-error">{passwordError}</div>}
<Field label={t('change_password_current')}>
<PasswordInput value={passwordForm.currentPassword} onChange={(e) => setPasswordForm((f) => ({ ...f, currentPassword: e.target.value }))} autoFocus />
</Field>
<Field label={t('reset_password_new')}>
<PasswordInput value={passwordForm.newPassword} onChange={(e) => setPasswordForm((f) => ({ ...f, newPassword: e.target.value }))} />
</Field>
<Field label={t('register_password_confirm')}>
<PasswordInput value={passwordForm.confirmPassword} onChange={(e) => setPasswordForm((f) => ({ ...f, confirmPassword: e.target.value }))} />
</Field>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Save} onClick={submitChangePassword}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setChangingPassword(false)}>{t('common_cancel')}</Button>
</div>
</DetailPopup>
</div>
);
}
+1
View File
@@ -57,6 +57,7 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
<tr
key={getRowKey(row)}
onClick={() => 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' : ''}
+49
View File
@@ -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 && <Badge color="accent">{t('sessions_current')}</Badge>}</> },
{ 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) => <Button size="sm" variant="danger" icon={LogOut} onClick={(event) => { event.stopPropagation(); confirm(() => revoke(row.id), t('sessions_confirm'), row.current); }}>{t('sessions_close')}</Button> },
];
return <div>
<div className="toolbar"><div className="toolbar-left">
<Button icon={RefreshCw} onClick={reload}>{t('sessions_refresh')}</Button>
{sessions.some((session) => allLabel || !session.current) && <Button variant="danger" icon={LogOut} onClick={() => confirm(revokeAll, t('sessions_confirm_all'))}>{allLabel || t('sessions_close_others')}</Button>}
</div></div>
{loading ? <div className="text-muted">{t('common_loading')}</div> : <ListView columns={columns} rows={sessions} getRowKey={(row) => row.id}
rowActions={(row) => [{ label: t('sessions_close'), icon: LogOut, danger: true, onClick: () => confirm(() => revoke(row.id), t('sessions_confirm'), row.current) }]} />}
</div>;
}
+14 -6
View File
@@ -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);
+20
View File
@@ -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',
+6 -2
View File
@@ -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 && (
<InfoBar tone="warning" dismissible={false}>{t('session_expired_login_msg')}</InfoBar>
)}
{sessionRevoked && !error && (
<InfoBar tone="warning" dismissible={false}>{t('session_revoked_login_msg')}</InfoBar>
)}
{error && <div className="login-error">{error}</div>}
<Field label={t('login_email')}>
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
@@ -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 <div>
<h2 className="page-title mt-0">{t('account_title')}</h2>
<Tabs tabs={TABS.map((key) => ({ key, label: t(`account_tab_${key}`) }))} active={tab} onChange={(key) => setParams({ tab: key })} />
<div style={{ paddingTop: 20 }}>
{tab === 'profile' && <div className="card" style={{ padding: 20 }}><FieldRow>
<Field label={t('common_name')}><div>{user?.name || '—'}</div></Field>
<Field label={t('user_email')}><div>{user?.email || '—'}</div></Field>
<Field label={t('user_role')}><div>{user?.roleName || user?.roleCode || '—'}</div></Field>
<Field label={t('common_department')}><div>{user?.departmentName || '—'}</div></Field>
</FieldRow></div>}
{tab === 'preferences' && <div className="card" style={{ padding: 20 }}><FieldRow>
<Field label={t('account_language')}><ComboBox value={lang} onChange={setLang} options={[{ value: 'es-EC', label: t('lang_es') }, { value: 'en-US', label: t('lang_en') }]} /></Field>
<Field label={t('account_theme')}><ComboBox value={theme} onChange={setTheme} options={[{ value: 'dark', label: t('theme_dark') }, { value: 'light', label: t('theme_light') }]} /></Field>
<Field label={t('account_buttons')}><ComboBox value={buttonMode} onChange={setButtonMode} options={[{ value: 'icon-text', label: t('button_mode_icon_text') }, { value: 'icon', label: t('button_mode_icon') }, { value: 'text', label: t('button_mode_text') }]} /></Field>
</FieldRow></div>}
{tab === 'security' && <form className="card" style={{ padding: 20, maxWidth: 480 }} onSubmit={submit}>
<h3>{t('change_password_title')}</h3>
{error && <div className="login-error">{error}</div>}
<Field label={t('change_password_current')}><PasswordInput value={form.currentPassword} onChange={(e) => setForm((f) => ({ ...f, currentPassword: e.target.value }))} required /></Field>
<Field label={t('reset_password_new')}><PasswordInput value={form.newPassword} onChange={(e) => setForm((f) => ({ ...f, newPassword: e.target.value }))} required /></Field>
<Field label={t('register_password_confirm')}><PasswordInput value={form.confirmPassword} onChange={(e) => setForm((f) => ({ ...f, confirmPassword: e.target.value }))} required /></Field>
<Button type="submit" variant="accent">{t('common_save')}</Button>
</form>}
{tab === 'sessions' && <SessionsList load={load} revoke={revoke} revokeAll={revokeOthers} onCurrentRevoked={() => { logout(); navigate('/login'); }} />}
</div>
</div>;
}
+16 -4
View File
@@ -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 && <UserModulesDialog user={modulesUser} onClose={() => setModulesUser(null)} />}
<DetailPopup open={!!sessionsUser} title={`${t('sessions_title')}: ${sessionsUser?.name || ''}`} onClose={() => setSessionsUser(null)} width={850} height={540}>
{sessionsUser && <SessionsList load={loadSessions} revoke={revokeSession} revokeAll={revokeAllSessions}
allLabel={t('sessions_close_all')} onCurrentRevoked={() => { logout(); navigate('/login'); }} />}
</DetailPopup>
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={560} height={500}>
<DraftNotice draft={draft} />
+3 -4
View File
@@ -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;
+3
View File
@@ -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 });
+3
View File
@@ -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`);
+1
View File
@@ -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'],
+11 -3
View File
@@ -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 */ }
}