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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "4.2.2",
|
||||
"version": "4.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { env } from '../config/env.js';
|
||||
import { pool } from '../config/db.js';
|
||||
import { fail } from '../utils/http.js';
|
||||
import { loadModules } from './modules.js';
|
||||
import { validateSession } from '../modules/auth/sessions-service.js';
|
||||
|
||||
export async function requireAuth(req, res, next) {
|
||||
const header = req.headers.authorization || '';
|
||||
@@ -18,21 +19,30 @@ export async function requireAuth(req, res, next) {
|
||||
|
||||
// Revalidar en cada request que la cuenta siga activa (un JWT firmado no basta).
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.name, u.email, u.is_active, u.session_version, u.department_id, r.code AS role_code
|
||||
`SELECT u.id, u.name, u.email, u.is_active, u.session_version, u.department_id, d.name AS department_name, r.code AS role_code, r.name AS role_name
|
||||
FROM users u JOIN roles r ON r.id = u.role_id
|
||||
LEFT JOIN departments d ON d.id = u.department_id
|
||||
WHERE u.id = ? LIMIT 1`,
|
||||
[payload.sub]
|
||||
);
|
||||
const user = rows[0];
|
||||
if (!user || !user.is_active || payload.sv !== user.session_version) return fail(res, 401, 'Sesion invalida o revocada');
|
||||
if (!user || !user.is_active || payload.sv !== user.session_version) return fail(res, 401, 'Sesion invalida o revocada', 'SESSION_REVOKED');
|
||||
// Token emitido antes de las sesiones registradas (sin sid): se trata como vencido, no como cerrado.
|
||||
if (!payload.sid) return fail(res, 401, 'Sesion vencida');
|
||||
const session = await validateSession(payload.sid, user.id);
|
||||
if (!session) return fail(res, 401, 'Sesion cerrada o vencida', 'SESSION_REVOKED');
|
||||
|
||||
req.currentUser = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
roleCode: user.role_code,
|
||||
roleName: user.role_name,
|
||||
departmentId: user.department_id,
|
||||
departmentName: user.department_name,
|
||||
modules: await loadModules(user.id, user.role_code),
|
||||
sessionId: session.id,
|
||||
sessionPublicId: payload.sid,
|
||||
};
|
||||
next();
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "4.2.2",
|
||||
"version": "4.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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>} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' : ''}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user