Compare commits

..

2 Commits

Author SHA1 Message Date
Milton Gonzalez a6cc41666e feat: version 4.3.0 -- Sesiones activas y pagina Mi cuenta
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:42:19 -05:00
Milton Gonzalez 544928d04a fix: version 4.2.2 -- Equipos ya no da falso conflicto al guardar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:28:26 -05:00
35 changed files with 477 additions and 112 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`
+25
View File
@@ -1,5 +1,30 @@
# 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
registro cambió" aunque nadie más lo hubiera editado. La verificación previa al guardado
comparaba los datos sin la versión de edición contra un borrador que sí la tenía; ahora ambos
usan el mismo formato.
## [4.2.1] - 2026-10-23
### Changed
- La IA ya no está orientada solo a TI: planifica e informa proyectos de cualquier área
+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.1
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.1",
"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.1",
"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
@@ -56,6 +56,10 @@ function SimpleTable({ columns, rows, empty }) {
);
}
// Valores editables del equipo; el mismo formato para `initial` y para `fetchCurrent` (incluida
// editVersion: si falta en uno de los dos, las huellas nunca coinciden y todo guardado da conflicto).
const toForm = (d) => ({ status: d.status, assignedUserId: d.assigned_user_id || '', departmentId: d.department_id || '', notes: d.notes || '', editVersion: d.edit_version });
function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabChange }) {
const { t, pushToast } = useUi();
const runAction = useAction();
@@ -69,7 +73,7 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
// Borrador de los datos editables del equipo (estado, asignado, departamento, notas).
const draft = useDraftForm({
formId: 'device-edit', entityId: id,
initial: device ? { status: device.status, assignedUserId: device.assigned_user_id || '', departmentId: device.department_id || '', notes: device.notes || '', editVersion: device.edit_version } : {},
initial: device ? toForm(device) : {},
label: `${t('eq_detail')}: ${device?.hostname || id}`, enabled: !!device && canEdit && tab === 'general', onDiscard: () => setEditing(false),
});
const form = draft.values;
@@ -99,7 +103,7 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
await reload();
onChanged();
pushToast({ tone: 'success', message: t('common_saved') });
}), { fetchCurrent: async () => { const d = await getDevice(id); return { status: d.status, assignedUserId: d.assigned_user_id || '', departmentId: d.department_id || '', notes: d.notes || '' }; } });
}), { fetchCurrent: async () => toForm(await getDevice(id)) });
}
if (!device) return <div className="text-muted">{t('common_loading')}</div>;
+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 */ }
}