diff --git a/web/backend/routes/admin.js b/web/backend/routes/admin.js
index 0e31d44..2564b38 100644
--- a/web/backend/routes/admin.js
+++ b/web/backend/routes/admin.js
@@ -30,6 +30,8 @@ router.get('/sessions', basicAuthMiddleware, adminAuthMiddleware, async (req, re
const sessionsByUser = {};
let activeSessions = 0;
let expiredSessions = 0;
+ let connectedSessions = 0; // Sesiones con actividad reciente
+ let inactiveSessions = 0; // Sesiones sin actividad pero no expiradas
sessions.forEach(session => {
if (!sessionsByUser[session.username]) {
@@ -41,6 +43,13 @@ router.get('/sessions', basicAuthMiddleware, adminAuthMiddleware, async (req, re
expiredSessions++;
} else {
activeSessions++;
+
+ // Contar sesiones conectadas (con actividad reciente)
+ if (session.isActive) {
+ connectedSessions++;
+ } else {
+ inactiveSessions++;
+ }
}
});
@@ -50,6 +59,8 @@ router.get('/sessions', basicAuthMiddleware, adminAuthMiddleware, async (req, re
total: sessions.length,
active: activeSessions,
expired: expiredSessions,
+ connected: connectedSessions,
+ inactive: inactiveSessions,
byUser: sessionsByUser,
},
});
diff --git a/web/backend/routes/users.js b/web/backend/routes/users.js
index d8b0196..29a090a 100644
--- a/web/backend/routes/users.js
+++ b/web/backend/routes/users.js
@@ -1,9 +1,10 @@
import express from 'express';
import bcrypt from 'bcrypt';
-import { getDB, getUser, createUser, deleteUser as deleteUserFromDB, getAllUsers, updateUserPassword } from '../services/mongodb.js';
+import { getDB, getUser, createUser, deleteUser as deleteUserFromDB, getAllUsers, updateUserPassword, getActiveSessions } from '../services/mongodb.js';
import { basicAuthMiddleware, createSession, invalidateSession, invalidateUserSessions } from '../middlewares/auth.js';
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
import { combineFingerprint } from '../utils/fingerprint.js';
+import { getActiveUsers } from '../services/websocket.js';
const router = express.Router();
@@ -375,5 +376,71 @@ router.delete('/:username', basicAuthMiddleware, adminAuthMiddleware, async (req
}
});
+// Obtener usuarios activos/conectados (requiere autenticación)
+router.get('/active', basicAuthMiddleware, async (req, res) => {
+ try {
+ const db = getDB();
+ if (!db) {
+ return res.status(500).json({ error: 'MongoDB no está disponible' });
+ }
+
+ // Obtener usuarios activos del WebSocket
+ const activeUsersWS = getActiveUsers();
+
+ // Obtener sesiones activas de la base de datos
+ const activeSessions = await getActiveSessions();
+
+ // Combinar información y eliminar duplicados
+ const userMap = new Map();
+
+ // Añadir usuarios del WebSocket
+ for (const user of activeUsersWS) {
+ if (!userMap.has(user.username)) {
+ userMap.set(user.username, {
+ username: user.username,
+ role: user.role,
+ status: user.status,
+ lastActivity: user.lastActivity,
+ connectedViaWebSocket: true
+ });
+ }
+ }
+
+ // Añadir usuarios de sesiones activas (que podrían no estar en WebSocket)
+ for (const session of activeSessions) {
+ if (!userMap.has(session.username)) {
+ const user = await getUser(session.username);
+ userMap.set(session.username, {
+ username: session.username,
+ role: user?.role || 'user',
+ status: 'active',
+ lastActivity: session.lastActivity?.toISOString() || null,
+ connectedViaWebSocket: false,
+ deviceInfo: session.deviceInfo
+ });
+ }
+ }
+
+ // Convertir a array
+ const activeUsers = Array.from(userMap.values());
+
+ // Ordenar por última actividad (más recientes primero)
+ activeUsers.sort((a, b) => {
+ const dateA = a.lastActivity ? new Date(a.lastActivity).getTime() : 0;
+ const dateB = b.lastActivity ? new Date(b.lastActivity).getTime() : 0;
+ return dateB - dateA;
+ });
+
+ res.json({
+ activeUsers,
+ total: activeUsers.length,
+ timestamp: new Date().toISOString()
+ });
+ } catch (error) {
+ console.error('Error obteniendo usuarios activos:', error);
+ res.status(500).json({ error: error.message });
+ }
+});
+
export default router;
diff --git a/web/backend/services/mongodb.js b/web/backend/services/mongodb.js
index 192d3ec..f3a4be9 100644
--- a/web/backend/services/mongodb.js
+++ b/web/backend/services/mongodb.js
@@ -1101,6 +1101,30 @@ export async function getSession(token) {
}
}
+// Actualizar actividad de una sesión
+export async function updateSessionActivity(token, isActive = true) {
+ if (!db) {
+ return false;
+ }
+
+ try {
+ const sessionsCollection = db.collection('sessions');
+ await sessionsCollection.updateOne(
+ { token },
+ {
+ $set: {
+ lastActivity: new Date(),
+ isActive: isActive,
+ },
+ }
+ );
+ return true;
+ } catch (error) {
+ console.error('Error actualizando actividad de sesión:', error.message);
+ return false;
+ }
+}
+
export async function deleteSession(token) {
if (!db) {
return false;
@@ -1139,21 +1163,63 @@ export async function getAllSessions() {
try {
const sessionsCollection = db.collection('sessions');
const sessions = await sessionsCollection.find({}).toArray();
- return sessions.map(session => ({
- token: session.token,
- username: session.username,
- fingerprint: session.fingerprint || null,
- deviceInfo: session.deviceInfo || null,
- createdAt: session.createdAt,
- expiresAt: session.expiresAt,
- isExpired: session.expiresAt ? new Date(session.expiresAt) < new Date() : false,
- }));
+ const now = new Date();
+ const INACTIVE_TIMEOUT = 5 * 60 * 1000; // 5 minutos
+
+ return sessions.map(session => {
+ const isExpired = session.expiresAt ? new Date(session.expiresAt) < now : false;
+ const timeSinceActivity = session.lastActivity ? now - new Date(session.lastActivity) : null;
+ const isActive = session.isActive && timeSinceActivity && timeSinceActivity < INACTIVE_TIMEOUT;
+
+ return {
+ token: session.token,
+ username: session.username,
+ fingerprint: session.fingerprint || null,
+ deviceInfo: session.deviceInfo || null,
+ createdAt: session.createdAt,
+ expiresAt: session.expiresAt,
+ lastActivity: session.lastActivity || null,
+ isActive: isActive || false,
+ isExpired: isExpired,
+ };
+ });
} catch (error) {
console.error('Error obteniendo todas las sesiones:', error.message);
return [];
}
}
+// Obtener sesiones activas (usuarios conectados)
+export async function getActiveSessions() {
+ if (!db) {
+ return [];
+ }
+
+ try {
+ const sessionsCollection = db.collection('sessions');
+ const now = new Date();
+ const INACTIVE_TIMEOUT = 5 * 60 * 1000; // 5 minutos
+ const recentTime = new Date(now - INACTIVE_TIMEOUT);
+
+ // Buscar sesiones activas (con actividad reciente)
+ const sessions = await sessionsCollection.find({
+ isActive: true,
+ lastActivity: { $gte: recentTime },
+ expiresAt: { $gt: now }
+ }).toArray();
+
+ return sessions.map(session => ({
+ username: session.username,
+ lastActivity: session.lastActivity,
+ deviceInfo: session.deviceInfo || null,
+ fingerprint: session.fingerprint || null,
+ }));
+ } catch (error) {
+ console.error('Error obteniendo sesiones activas:', error.message);
+ return [];
+ }
+}
+
// Funciones para artículos
export async function getArticle(platform, id, currentUsername = null) {
if (!db) {
diff --git a/web/backend/services/websocket.js b/web/backend/services/websocket.js
index d053521..625f5a0 100644
--- a/web/backend/services/websocket.js
+++ b/web/backend/services/websocket.js
@@ -1,11 +1,17 @@
import { WebSocketServer } from 'ws';
-import { getDB, getSession, getUser, deleteSession as deleteSessionFromDB } from './mongodb.js';
+import { getDB, getSession, getUser, deleteSession as deleteSessionFromDB, updateSessionActivity } from './mongodb.js';
let wss = null;
// Duración de la sesión en milisegundos (24 horas)
const SESSION_DURATION = 24 * 60 * 60 * 1000;
+// Tiempo de inactividad para marcar como inactivo (5 minutos)
+const INACTIVE_TIMEOUT = 5 * 60 * 1000;
+
+// Intervalo para limpiar conexiones inactivas (1 minuto)
+const CLEANUP_INTERVAL = 60 * 1000;
+
// Inicializar WebSocket Server
export function initWebSocket(server) {
wss = new WebSocketServer({ server, path: '/ws' });
@@ -73,18 +79,90 @@ export function initWebSocket(server) {
role: user.role || 'user',
token: token
};
+ ws.isAlive = true;
+ ws.lastActivity = new Date();
+
+ // Actualizar estado de conexión en la base de datos
+ await updateSessionActivity(token, true);
console.log(`Cliente WebSocket conectado: ${session.username} (${user.role || 'user'})`);
+ // Enviar confirmación de conexión
+ ws.send(JSON.stringify({
+ type: 'connection',
+ status: 'connected',
+ timestamp: new Date().toISOString()
+ }));
+
+ // Broadcast a otros clientes que este usuario se conectó
+ broadcastUserStatus(session.username, 'online');
+
} catch (error) {
console.error('Error validando token WebSocket:', error);
ws.close(1011, 'Error de autenticación');
return;
}
- ws.on('close', () => {
+ // Manejar mensajes del cliente
+ ws.on('message', async (data) => {
+ try {
+ const message = JSON.parse(data.toString());
+
+ // Manejar diferentes tipos de mensajes
+ switch (message.type) {
+ case 'ping':
+ case 'heartbeat':
+ // Actualizar actividad
+ ws.isAlive = true;
+ ws.lastActivity = new Date();
+
+ // Actualizar en base de datos
+ if (ws.user && ws.user.token) {
+ await updateSessionActivity(ws.user.token, true);
+ }
+
+ // Responder con pong
+ ws.send(JSON.stringify({
+ type: 'pong',
+ timestamp: new Date().toISOString()
+ }));
+ break;
+
+ case 'activity':
+ // El usuario realizó alguna actividad (click, scroll, etc.)
+ ws.isAlive = true;
+ ws.lastActivity = new Date();
+
+ if (ws.user && ws.user.token) {
+ await updateSessionActivity(ws.user.token, true);
+ }
+ break;
+
+ default:
+ console.log(`Mensaje WebSocket no manejado: ${message.type}`);
+ }
+ } catch (error) {
+ console.error('Error procesando mensaje WebSocket:', error);
+ }
+ });
+
+ // Manejar pong nativo de WebSocket
+ ws.on('pong', () => {
+ ws.isAlive = true;
+ ws.lastActivity = new Date();
+ });
+
+ ws.on('close', async () => {
if (ws.user) {
console.log(`Cliente WebSocket desconectado: ${ws.user.username}`);
+
+ // Actualizar estado en base de datos
+ if (ws.user.token) {
+ await updateSessionActivity(ws.user.token, false);
+ }
+
+ // Broadcast a otros clientes que este usuario se desconectó
+ broadcastUserStatus(ws.user.username, 'offline');
} else {
console.log('Cliente WebSocket desconectado');
}
@@ -95,9 +173,81 @@ export function initWebSocket(server) {
});
});
+ // Iniciar limpieza periódica de conexiones inactivas
+ startCleanupInterval();
+
return wss;
}
+// Limpiar conexiones inactivas periódicamente
+let cleanupIntervalId = null;
+
+function startCleanupInterval() {
+ if (cleanupIntervalId) {
+ clearInterval(cleanupIntervalId);
+ }
+
+ cleanupIntervalId = setInterval(() => {
+ if (!wss) return;
+
+ const now = new Date();
+
+ wss.clients.forEach(async (ws) => {
+ // Si el cliente no ha respondido, marcarlo como inactivo
+ if (ws.isAlive === false) {
+ console.log(`Cerrando conexión inactiva: ${ws.user?.username || 'desconocido'}`);
+
+ // Actualizar estado en base de datos antes de cerrar
+ if (ws.user && ws.user.token) {
+ await updateSessionActivity(ws.user.token, false);
+ }
+
+ return ws.terminate();
+ }
+
+ // Verificar si ha pasado el timeout de inactividad
+ if (ws.lastActivity && (now - ws.lastActivity) > INACTIVE_TIMEOUT) {
+ console.log(`Usuario inactivo detectado: ${ws.user?.username || 'desconocido'}`);
+
+ // Actualizar estado en base de datos pero mantener conexión
+ if (ws.user && ws.user.token) {
+ await updateSessionActivity(ws.user.token, false);
+ }
+ }
+
+ // Marcar como no vivo y enviar ping
+ ws.isAlive = false;
+ ws.ping();
+ });
+ }, CLEANUP_INTERVAL);
+}
+
+// Detener limpieza
+function stopCleanupInterval() {
+ if (cleanupIntervalId) {
+ clearInterval(cleanupIntervalId);
+ cleanupIntervalId = null;
+ }
+}
+
+// Broadcast del estado de un usuario específico
+function broadcastUserStatus(username, status) {
+ if (!wss) return;
+
+ const message = JSON.stringify({
+ type: 'user_status',
+ username: username,
+ status: status, // 'online' | 'offline' | 'inactive'
+ timestamp: new Date().toISOString()
+ });
+
+ wss.clients.forEach((client) => {
+ if (client.readyState === 1) { // WebSocket.OPEN
+ client.send(message);
+ }
+ });
+}
+
// Broadcast a todos los clientes WebSocket
export function broadcast(data) {
if (!wss) return;
@@ -114,3 +264,51 @@ export function getWebSocketServer() {
return wss;
}
+// Obtener usuarios activos conectados
+export function getActiveUsers() {
+ if (!wss) return [];
+
+ const activeUsers = [];
+ const now = new Date();
+
+ wss.clients.forEach((ws) => {
+ if (ws.user && ws.readyState === 1) { // WebSocket.OPEN
+ const isActive = ws.lastActivity && (now - ws.lastActivity) <= INACTIVE_TIMEOUT;
+
+ activeUsers.push({
+ username: ws.user.username,
+ role: ws.user.role,
+ status: isActive ? 'active' : 'inactive',
+ lastActivity: ws.lastActivity?.toISOString() || null,
+ connectedAt: ws.lastActivity?.toISOString() || null
+ });
+ }
+ });
+
+ // Eliminar duplicados (un usuario puede tener múltiples conexiones)
+ const uniqueUsers = [];
+ const seenUsernames = new Set();
+
+ for (const user of activeUsers) {
+ if (!seenUsernames.has(user.username)) {
+ seenUsernames.add(user.username);
+ uniqueUsers.push(user);
+ }
+ }
+
+ return uniqueUsers;
+}
+
+// Cerrar todas las conexiones y limpiar
+export function closeWebSocket() {
+ stopCleanupInterval();
+
+ if (wss) {
+ wss.clients.forEach((ws) => {
+ ws.close(1001, 'Servidor cerrando');
+ });
+ wss.close();
+ wss = null;
+ }
+}
+
diff --git a/web/dashboard/src/App.vue b/web/dashboard/src/App.vue
index b9dd644..0e84e03 100644
--- a/web/dashboard/src/App.vue
+++ b/web/dashboard/src/App.vue
@@ -206,6 +206,8 @@ const pushEnabled = ref(false);
const currentUser = ref(authService.getUsername() || null);
const isAdmin = ref(false);
let ws = null;
+let heartbeatInterval = null;
+let activityThrottleTimeout = null;
const isDark = computed(() => darkMode.value);
const isAuthenticated = computed(() => authService.hasCredentials());
@@ -335,6 +337,13 @@ onMounted(async () => {
window.addEventListener('auth-login', handleAuthChange);
window.addEventListener('auth-logout', handleAuthChange);
+ // Escuchar eventos de actividad del usuario para enviar al servidor
+ // Estos eventos ayudan a mantener la sesión activa
+ window.addEventListener('click', sendUserActivity);
+ window.addEventListener('keydown', sendUserActivity);
+ window.addEventListener('scroll', sendUserActivity, { passive: true });
+ window.addEventListener('mousemove', sendUserActivity, { passive: true });
+
// Si hay credenciales, validar y conectar websocket
if (authService.hasCredentials()) {
// Validar si el token sigue siendo válido
@@ -358,6 +367,15 @@ onUnmounted(() => {
window.removeEventListener('auth-login', handleAuthChange);
window.removeEventListener('auth-logout', handleAuthChange);
+ // Limpiar listeners de actividad
+ window.removeEventListener('click', sendUserActivity);
+ window.removeEventListener('keydown', sendUserActivity);
+ window.removeEventListener('scroll', sendUserActivity);
+ window.removeEventListener('mousemove', sendUserActivity);
+
+ // Detener heartbeat
+ stopHeartbeat();
+
if (ws) {
ws.close();
}
@@ -370,6 +388,12 @@ function connectWebSocket() {
ws = null;
}
+ // Limpiar heartbeat interval previo
+ if (heartbeatInterval) {
+ clearInterval(heartbeatInterval);
+ heartbeatInterval = null;
+ }
+
// Verificar si hay token de autenticación
const token = authService.getToken();
if (!token) {
@@ -404,11 +428,17 @@ function connectWebSocket() {
ws.onopen = () => {
wsConnected.value = true;
console.log('WebSocket conectado');
+
+ // Iniciar heartbeat cada 30 segundos
+ startHeartbeat();
};
ws.onclose = (event) => {
wsConnected.value = false;
+ // Detener heartbeat
+ stopHeartbeat();
+
// Si el cierre fue por autenticación fallida (código 1008), no reintentar
if (event.code === 1008) {
console.log('WebSocket cerrado: autenticación fallida');
@@ -434,9 +464,73 @@ function connectWebSocket() {
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
+ // Manejar respuesta de pong
+ if (data.type === 'pong') {
+ // El servidor respondió al heartbeat
+ return;
+ }
+
+ // Manejar confirmación de conexión
+ if (data.type === 'connection' && data.status === 'connected') {
+ console.log('Conexión WebSocket confirmada');
+ return;
+ }
+
+ // Manejar cambios de estado de usuarios
+ if (data.type === 'user_status') {
+ console.log(`Usuario ${data.username} cambió a estado: ${data.status}`);
+ // Emitir evento para que otros componentes lo manejen
+ window.dispatchEvent(new CustomEvent('user-status-change', { detail: data }));
+ return;
+ }
+
// Los componentes individuales manejarán los mensajes (incluyendo ToastContainer)
window.dispatchEvent(new CustomEvent('ws-message', { detail: data }));
};
}
+
+// Enviar heartbeat al servidor periódicamente
+function startHeartbeat() {
+ // Detener heartbeat previo si existe
+ stopHeartbeat();
+
+ // Enviar heartbeat cada 30 segundos
+ heartbeatInterval = setInterval(() => {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({
+ type: 'heartbeat',
+ timestamp: new Date().toISOString()
+ }));
+ }
+ }, 30000); // 30 segundos
+}
+
+function stopHeartbeat() {
+ if (heartbeatInterval) {
+ clearInterval(heartbeatInterval);
+ heartbeatInterval = null;
+ }
+}
+
+// Enviar actividad del usuario al servidor (throttled)
+function sendUserActivity() {
+ // Si ya hay un timeout pendiente, no hacer nada
+ if (activityThrottleTimeout) {
+ return;
+ }
+
+ // Enviar actividad y crear throttle de 10 segundos
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({
+ type: 'activity',
+ timestamp: new Date().toISOString()
+ }));
+ }
+
+ // Throttle: no enviar más actividad por 10 segundos
+ activityThrottleTimeout = setTimeout(() => {
+ activityThrottleTimeout = null;
+ }, 10000);
+}
diff --git a/web/dashboard/src/components/ActiveUsers.vue b/web/dashboard/src/components/ActiveUsers.vue
new file mode 100644
index 0000000..8577b02
--- /dev/null
+++ b/web/dashboard/src/components/ActiveUsers.vue
@@ -0,0 +1,188 @@
+
+ Cargando usuarios activos... No hay usuarios activos en este momento
+ {{ user.username }}
+
+
+ Última actividad: {{ formatTime(user.lastActivity) }}
+
+ Sin actividad reciente
+
+ Total: {{ activeUsers.length }} {{ activeUsers.length === 1 ? 'usuario activo' : 'usuarios activos' }}
+ • Actualizado {{ formatTime(lastUpdate) }}
+
+ Usuarios Activos
+
+
+