diff --git a/web/frontend/src/App.vue b/web/frontend/src/App.vue index dce5ecd..dcc2d77 100644 --- a/web/frontend/src/App.vue +++ b/web/frontend/src/App.vue @@ -176,11 +176,14 @@ import { BellSlashIcon, ArrowRightOnRectangleIcon, } from '@heroicons/vue/24/outline'; -import pushNotificationService from './services/pushNotifications'; -import authService from './services/auth'; +import pushNotificationService from './application/services/PushNotificationService.js'; +import authService from './application/services/AuthService.js'; +import webSocketService from './core/websocket/WebSocketService.js'; import { useRouter } from 'vue-router'; -import api from './services/api'; -import ToastContainer from './components/ToastContainer.vue'; +import { useAuth } from './presentation/composables/useAuth.js'; +import { useWebSocket } from './presentation/composables/useWebSocket.js'; +import { useDarkMode } from './presentation/composables/useDarkMode.js'; +import ToastContainer from './presentation/components/ToastContainer.vue'; const allNavItems = [ { path: '/', name: 'Dashboard', icon: HomeIcon, adminOnly: false }, @@ -194,16 +197,11 @@ const allNavItems = [ ]; const router = useRouter(); -const wsConnected = ref(false); +const { currentUser, isAuthenticated, isAdmin, username } = useAuth(); +const { isConnected: wsConnected } = useWebSocket(); +const { isDark, toggle: toggleDarkMode } = useDarkMode(); const sidebarCollapsed = ref(false); -const darkMode = ref(false); const pushEnabled = ref(false); -const currentUser = ref(authService.getUsername() || null); -const isAdmin = ref(false); -let ws = null; - -const isDark = computed(() => darkMode.value); -const isAuthenticated = computed(() => authService.hasCredentials()); // Filtrar navItems según el rol del usuario const navItems = computed(() => { @@ -217,27 +215,6 @@ const navItems = computed(() => { }); -function toggleDarkMode() { - darkMode.value = !darkMode.value; - if (darkMode.value) { - document.documentElement.classList.add('dark'); - localStorage.setItem('darkMode', 'true'); - } else { - document.documentElement.classList.remove('dark'); - localStorage.setItem('darkMode', 'false'); - } -} - -function initDarkMode() { - const saved = localStorage.getItem('darkMode'); - if (saved === 'true' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) { - darkMode.value = true; - document.documentElement.classList.add('dark'); - } else { - darkMode.value = false; - document.documentElement.classList.remove('dark'); - } -} async function enablePushNotifications() { try { @@ -287,60 +264,35 @@ function getCurrentPageTitle() { } function handleAuthChange() { - currentUser.value = authService.getUsername() || null; - isAdmin.value = authService.isAdmin(); - // Reconectar websocket cuando cambie la autenticación (login) - if (authService.hasCredentials()) { - connectWebSocket(); + // WebSocket will be connected automatically by AuthService + const token = authService.getToken(); + if (token) { + webSocketService.connect(token); } } async function handleLogout() { - // Cerrar conexión WebSocket antes de hacer logout - if (ws) { - ws.close(); - ws = null; - wsConnected.value = false; - } - - // Llamar al endpoint de logout e invalidar token await authService.logout(); - - // Redirigir a login después del logout router.push('/login'); - - // Disparar evento para que los componentes se actualicen - window.dispatchEvent(new CustomEvent('auth-logout')); - - // Mostrar mensaje informativo - console.log('Sesión cerrada correctamente'); } onMounted(async () => { - initDarkMode(); - currentUser.value = authService.getUsername() || null; - isAdmin.value = authService.isAdmin(); await checkPushStatus(); // Escuchar eventos de autenticación window.addEventListener('auth-login', handleAuthChange); window.addEventListener('auth-logout', handleAuthChange); - // Si hay credenciales, validar y conectar websocket + // Escuchar eventos de WebSocket + window.addEventListener('ws-connected', () => { + // Connection state is managed by useWebSocket composable + }); + + // Si hay credenciales, conectar websocket if (authService.hasCredentials()) { - // Validar si el token sigue siendo válido - const isValid = await authService.validateSession(); - if (!isValid) { - // Si el token expiró, limpiar sesión y redirigir a login - authService.clearSession(); - currentUser.value = authService.getUsername() || null; - isAdmin.value = authService.isAdmin(); - if (router.currentRoute.value.path !== '/login') { - router.push('/login'); - } - } else { - // Solo conectar websocket si el token es válido - connectWebSocket(); + const token = authService.getToken(); + if (token) { + webSocketService.connect(token); } } }); @@ -348,86 +300,6 @@ onMounted(async () => { onUnmounted(() => { window.removeEventListener('auth-login', handleAuthChange); window.removeEventListener('auth-logout', handleAuthChange); - - if (ws) { - ws.close(); - } }); - -function connectWebSocket() { - // Cerrar conexión existente si hay una - if (ws) { - ws.close(); - ws = null; - } - - // Verificar si hay token de autenticación - const token = authService.getToken(); - if (!token) { - console.log('No hay token de autenticación, no se conectará WebSocket'); - wsConnected.value = false; - return; - } - - let wsUrl; - - // Si hay una URL de API configurada, usarla para WebSocket también - const apiBaseUrl = import.meta.env.VITE_API_BASE_URL; - if (apiBaseUrl && apiBaseUrl !== '/api') { - // Extraer el host de la URL de la API y construir la URL del WebSocket - try { - const url = new URL(apiBaseUrl); - const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; - wsUrl = `${protocol}//${url.host}/ws?token=${encodeURIComponent(token)}`; - } catch (e) { - // Si falla el parsing, usar la configuración por defecto - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; - } - } else { - // Use relative path so Vite proxy can handle it - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; - } - - ws = new WebSocket(wsUrl); - - ws.onopen = () => { - wsConnected.value = true; - console.log('WebSocket conectado'); - }; - - ws.onclose = (event) => { - wsConnected.value = false; - - // Si el cierre fue por autenticación fallida (código 1008), no reintentar - if (event.code === 1008) { - console.log('WebSocket cerrado: autenticación fallida'); - // Si el token aún existe, intentar reconectar después de un delay más largo - // para dar tiempo a que el usuario se autentique de nuevo - if (authService.hasCredentials()) { - setTimeout(connectWebSocket, 5000); - } - } else { - // Para otros errores, reintentar después de 3 segundos si hay token - if (authService.hasCredentials()) { - console.log('WebSocket desconectado, reintentando...'); - setTimeout(connectWebSocket, 3000); - } - } - }; - - ws.onerror = (error) => { - console.error('Error WebSocket:', error); - wsConnected.value = false; - }; - - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - - // Los componentes individuales manejarán los mensajes (incluyendo ToastContainer) - window.dispatchEvent(new CustomEvent('ws-message', { detail: data })); - }; -} diff --git a/web/frontend/src/application/services/AdminService.js b/web/frontend/src/application/services/AdminService.js new file mode 100644 index 0000000..5b68d42 --- /dev/null +++ b/web/frontend/src/application/services/AdminService.js @@ -0,0 +1,17 @@ +import adminRepository from '../../domain/repositories/AdminRepository.js'; + +class AdminService { + async getRateLimiterInfo() { + return await adminRepository.getRateLimiterInfo(); + } + + async getSessions() { + return await adminRepository.getSessions(); + } + + async deleteSession(token) { + await adminRepository.deleteSession(token); + } +} + +export default new AdminService(); diff --git a/web/frontend/src/application/services/ArticleService.js b/web/frontend/src/application/services/ArticleService.js new file mode 100644 index 0000000..f9e1962 --- /dev/null +++ b/web/frontend/src/application/services/ArticleService.js @@ -0,0 +1,21 @@ +import articleRepository from '../../domain/repositories/ArticleRepository.js'; + +class ArticleService { + async getArticles(limit = 100, offset = 0, filters = {}) { + return await articleRepository.getArticles(limit, offset, filters); + } + + async getArticleFacets() { + return await articleRepository.getArticleFacets(); + } + + async searchArticles(query, mode = 'AND') { + return await articleRepository.searchArticles(query, mode); + } + + async getArticle(platform, id) { + return await articleRepository.getArticle(platform, id); + } +} + +export default new ArticleService(); diff --git a/web/frontend/src/application/services/AuthService.js b/web/frontend/src/application/services/AuthService.js new file mode 100644 index 0000000..f55d52e --- /dev/null +++ b/web/frontend/src/application/services/AuthService.js @@ -0,0 +1,124 @@ +import authRepository from '../../domain/repositories/AuthRepository.js'; +import storageService from '../../core/storage/StorageService.js'; +import { User } from '../../domain/entities/User.js'; +import apiClient from '../../core/http/ApiClient.js'; +import webSocketService from '../../core/websocket/WebSocketService.js'; + +const AUTH_STORAGE_KEY = 'wallabicher_auth'; + +class AuthService { + constructor() { + this.currentUser = this.loadUser(); + this.setupApiClient(); + } + + loadUser() { + const authData = storageService.get(AUTH_STORAGE_KEY); + if (authData && authData.token) { + return new User(authData); + } + return null; + } + + setupApiClient() { + if (this.currentUser?.token) { + apiClient.setAuthToken(this.currentUser.token); + } + } + + async login(username, password) { + const data = await authRepository.login(username, password); + + if (data.success && data.token) { + const user = new User({ + username: data.username, + role: data.role || 'user', + token: data.token, + }); + + this.currentUser = user; + storageService.set(AUTH_STORAGE_KEY, user.toJSON()); + apiClient.setAuthToken(user.token); + webSocketService.connect(user.token); + + window.dispatchEvent(new CustomEvent('auth-login', { detail: user })); + + return user; + } + + throw new Error('Respuesta inválida del servidor'); + } + + async logout() { + if (this.currentUser?.token) { + try { + await authRepository.logout(this.currentUser.token); + } catch (error) { + console.error('Error al cerrar sesión en el servidor:', error); + } + } + + this.currentUser = null; + storageService.remove(AUTH_STORAGE_KEY); + apiClient.clearAuthToken(); + webSocketService.close(); + + window.dispatchEvent(new CustomEvent('auth-logout')); + } + + async validateSession() { + if (!this.currentUser?.token) { + return false; + } + + const data = await authRepository.validateSession(this.currentUser.token); + + if (data) { + if (data.role) { + this.currentUser.role = data.role; + storageService.set(AUTH_STORAGE_KEY, this.currentUser.toJSON()); + } + return true; + } + + this.clearSession(); + return false; + } + + clearSession() { + this.currentUser = null; + storageService.remove(AUTH_STORAGE_KEY); + apiClient.clearAuthToken(); + webSocketService.close(); + } + + getCurrentUser() { + return this.currentUser; + } + + getToken() { + return this.currentUser?.token || null; + } + + getUsername() { + return this.currentUser?.username || null; + } + + getRole() { + return this.currentUser?.role || null; + } + + isAdmin() { + return this.currentUser?.isAdmin || false; + } + + hasCredentials() { + return !!this.currentUser?.token; + } + + getAuthHeader() { + return this.currentUser?.token ? `Bearer ${this.currentUser.token}` : null; + } +} + +export default new AuthService(); diff --git a/web/frontend/src/application/services/FavoriteService.js b/web/frontend/src/application/services/FavoriteService.js new file mode 100644 index 0000000..510e0f7 --- /dev/null +++ b/web/frontend/src/application/services/FavoriteService.js @@ -0,0 +1,19 @@ +import favoriteRepository from '../../domain/repositories/FavoriteRepository.js'; +import { Favorite } from '../../domain/entities/Favorite.js'; + +class FavoriteService { + async getFavorites() { + return await favoriteRepository.getFavorites(); + } + + async addFavorite(platform, id) { + const favorite = new Favorite({ platform, id }); + return await favoriteRepository.addFavorite(favorite); + } + + async removeFavorite(platform, id) { + await favoriteRepository.removeFavorite(platform, id); + } +} + +export default new FavoriteService(); diff --git a/web/frontend/src/application/services/LogService.js b/web/frontend/src/application/services/LogService.js new file mode 100644 index 0000000..0f45ac2 --- /dev/null +++ b/web/frontend/src/application/services/LogService.js @@ -0,0 +1,9 @@ +import logRepository from '../../domain/repositories/LogRepository.js'; + +class LogService { + async getLogs(limit = 500, sinceLine = null) { + return await logRepository.getLogs(limit, sinceLine); + } +} + +export default new LogService(); diff --git a/web/frontend/src/services/pushNotifications.js b/web/frontend/src/application/services/PushNotificationService.js similarity index 77% rename from web/frontend/src/services/pushNotifications.js rename to web/frontend/src/application/services/PushNotificationService.js index 9313f19..6b4bd35 100644 --- a/web/frontend/src/services/pushNotifications.js +++ b/web/frontend/src/application/services/PushNotificationService.js @@ -1,11 +1,10 @@ -// Servicio para manejar notificaciones push +// Push Notification Service class PushNotificationService { constructor() { this.registration = null; this.subscription = null; } - // Registrar Service Worker async registerServiceWorker() { if ('serviceWorker' in navigator) { try { @@ -13,22 +12,17 @@ class PushNotificationService { scope: '/' }); this.registration = registration; - console.log('Service Worker registrado:', registration.scope); return registration; } catch (error) { console.error('Error registrando Service Worker:', error); return null; } - } else { - console.warn('Service Workers no están soportados en este navegador'); - return null; } + return null; } - // Solicitar permisos de notificación async requestPermission() { if (!('Notification' in window)) { - console.warn('Este navegador no soporta notificaciones'); return 'denied'; } @@ -41,15 +35,13 @@ class PushNotificationService { } try { - const permission = await Notification.requestPermission(); - return permission; + return await Notification.requestPermission(); } catch (error) { console.error('Error solicitando permiso:', error); return 'denied'; } } - // Suscribirse a notificaciones push async subscribe() { if (!this.registration) { await this.registerServiceWorker(); @@ -60,30 +52,22 @@ class PushNotificationService { } try { - // Verificar si ya existe una suscripción this.subscription = await this.registration.pushManager.getSubscription(); if (this.subscription) { - console.log('Ya existe una suscripción push'); return this.subscription; } - // Obtener la clave pública del servidor const response = await fetch('/api/push/public-key'); const { publicKey } = await response.json(); - // Convertir la clave pública a formato ArrayBuffer const applicationServerKey = this.urlBase64ToUint8Array(publicKey); - // Crear nueva suscripción this.subscription = await this.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: applicationServerKey }); - console.log('Suscripción push creada:', this.subscription); - - // Enviar la suscripción al servidor await this.sendSubscriptionToServer(this.subscription); return this.subscription; @@ -93,7 +77,6 @@ class PushNotificationService { } } - // Enviar suscripción al servidor async sendSubscriptionToServer(subscription) { try { const response = await fetch('/api/push/subscribe', { @@ -108,7 +91,6 @@ class PushNotificationService { throw new Error('Error enviando suscripción al servidor'); } - console.log('Suscripción enviada al servidor'); return await response.json(); } catch (error) { console.error('Error enviando suscripción:', error); @@ -116,7 +98,6 @@ class PushNotificationService { } } - // Cancelar suscripción async unsubscribe() { if (this.subscription) { try { @@ -129,7 +110,6 @@ class PushNotificationService { body: JSON.stringify(this.subscription), }); this.subscription = null; - console.log('Suscripción cancelada'); return true; } catch (error) { console.error('Error cancelando suscripción:', error); @@ -139,7 +119,6 @@ class PushNotificationService { return false; } - // Verificar estado de suscripción async checkSubscription() { if (!this.registration) { await this.registerServiceWorker(); @@ -158,7 +137,6 @@ class PushNotificationService { } } - // Convertir clave pública de base64 URL a Uint8Array urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding) @@ -174,7 +152,6 @@ class PushNotificationService { return outputArray; } - // Inicializar todo el proceso async init() { const permission = await this.requestPermission(); if (permission === 'granted') { @@ -191,5 +168,4 @@ class PushNotificationService { } } -// Exportar instancia singleton export default new PushNotificationService(); diff --git a/web/frontend/src/application/services/StatsService.js b/web/frontend/src/application/services/StatsService.js new file mode 100644 index 0000000..2135af6 --- /dev/null +++ b/web/frontend/src/application/services/StatsService.js @@ -0,0 +1,9 @@ +import statsRepository from '../../domain/repositories/StatsRepository.js'; + +class StatsService { + async getStats() { + return await statsRepository.getStats(); + } +} + +export default new StatsService(); diff --git a/web/frontend/src/application/services/TelegramService.js b/web/frontend/src/application/services/TelegramService.js new file mode 100644 index 0000000..bfa1d99 --- /dev/null +++ b/web/frontend/src/application/services/TelegramService.js @@ -0,0 +1,17 @@ +import telegramRepository from '../../domain/repositories/TelegramRepository.js'; + +class TelegramService { + async getConfig() { + return await telegramRepository.getConfig(); + } + + async setConfig(config) { + return await telegramRepository.setConfig(config); + } + + async getThreads() { + return await telegramRepository.getThreads(); + } +} + +export default new TelegramService(); diff --git a/web/frontend/src/application/services/UserService.js b/web/frontend/src/application/services/UserService.js new file mode 100644 index 0000000..75b5267 --- /dev/null +++ b/web/frontend/src/application/services/UserService.js @@ -0,0 +1,21 @@ +import userRepository from '../../domain/repositories/UserRepository.js'; + +class UserService { + async getUsers() { + return await userRepository.getUsers(); + } + + async createUser(userData) { + return await userRepository.createUser(userData); + } + + async deleteUser(username) { + await userRepository.deleteUser(username); + } + + async changePassword(passwordData) { + return await userRepository.changePassword(passwordData); + } +} + +export default new UserService(); diff --git a/web/frontend/src/application/services/WorkerService.js b/web/frontend/src/application/services/WorkerService.js new file mode 100644 index 0000000..7c58124 --- /dev/null +++ b/web/frontend/src/application/services/WorkerService.js @@ -0,0 +1,13 @@ +import workerRepository from '../../domain/repositories/WorkerRepository.js'; + +class WorkerService { + async getWorkers() { + return await workerRepository.getWorkers(); + } + + async updateWorkers(workers) { + return await workerRepository.updateWorkers(workers); + } +} + +export default new WorkerService(); diff --git a/web/frontend/src/core/config/index.js b/web/frontend/src/core/config/index.js new file mode 100644 index 0000000..b26c464 --- /dev/null +++ b/web/frontend/src/core/config/index.js @@ -0,0 +1,3 @@ +// Core configuration +export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'; +export const WS_BASE_URL = API_BASE_URL.replace(/^https?:/, window.location.protocol === 'https:' ? 'wss:' : 'ws:').replace(/^\/api$/, `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`); diff --git a/web/frontend/src/core/http/ApiClient.js b/web/frontend/src/core/http/ApiClient.js new file mode 100644 index 0000000..18cb0ef --- /dev/null +++ b/web/frontend/src/core/http/ApiClient.js @@ -0,0 +1,75 @@ +import axios from 'axios'; +import { API_BASE_URL } from '../config/index.js'; + +class ApiClient { + constructor(baseURL = API_BASE_URL) { + this.client = axios.create({ + baseURL, + headers: { + 'Content-Type': 'application/json', + }, + }); + + this.setupInterceptors(); + } + + setupInterceptors() { + // Request interceptor - add auth header + this.client.interceptors.request.use( + (config) => { + const authHeader = this.getAuthHeader(); + if (authHeader) { + config.headers.Authorization = authHeader; + } + return config; + }, + (error) => Promise.reject(error) + ); + + // Response interceptor - handle 401 errors + this.client.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + window.dispatchEvent(new CustomEvent('auth-required', { + detail: { + message: 'Se requiere autenticación para esta acción', + config: error.config, + }, + })); + } + return Promise.reject(error); + } + ); + } + + setAuthToken(token) { + this.token = token; + } + + clearAuthToken() { + this.token = null; + } + + getAuthHeader() { + return this.token ? `Bearer ${this.token}` : null; + } + + get(url, config = {}) { + return this.client.get(url, config); + } + + post(url, data, config = {}) { + return this.client.post(url, data, config); + } + + put(url, data, config = {}) { + return this.client.put(url, data, config); + } + + delete(url, config = {}) { + return this.client.delete(url, config); + } +} + +export default new ApiClient(); diff --git a/web/frontend/src/core/storage/LocalStorageAdapter.js b/web/frontend/src/core/storage/LocalStorageAdapter.js new file mode 100644 index 0000000..2fad3ec --- /dev/null +++ b/web/frontend/src/core/storage/LocalStorageAdapter.js @@ -0,0 +1,46 @@ +// LocalStorage adapter for storage abstraction +export class LocalStorageAdapter { + constructor() { + this.storage = localStorage; + } + + get(key) { + try { + const item = this.storage.getItem(key); + return item ? JSON.parse(item) : null; + } catch (error) { + console.error(`Error reading from localStorage key "${key}":`, error); + return null; + } + } + + set(key, value) { + try { + this.storage.setItem(key, JSON.stringify(value)); + return true; + } catch (error) { + console.error(`Error writing to localStorage key "${key}":`, error); + return false; + } + } + + remove(key) { + try { + this.storage.removeItem(key); + return true; + } catch (error) { + console.error(`Error removing from localStorage key "${key}":`, error); + return false; + } + } + + clear() { + try { + this.storage.clear(); + return true; + } catch (error) { + console.error('Error clearing localStorage:', error); + return false; + } + } +} diff --git a/web/frontend/src/core/storage/StorageService.js b/web/frontend/src/core/storage/StorageService.js new file mode 100644 index 0000000..a1cc8c5 --- /dev/null +++ b/web/frontend/src/core/storage/StorageService.js @@ -0,0 +1,25 @@ +import { LocalStorageAdapter } from './LocalStorageAdapter.js'; + +class StorageService { + constructor(adapter) { + this.adapter = adapter || new LocalStorageAdapter(); + } + + get(key) { + return this.adapter.get(key); + } + + set(key, value) { + return this.adapter.set(key, value); + } + + remove(key) { + return this.adapter.remove(key); + } + + clear() { + return this.adapter.clear(); + } +} + +export default new StorageService(); diff --git a/web/frontend/src/core/websocket/WebSocketService.js b/web/frontend/src/core/websocket/WebSocketService.js new file mode 100644 index 0000000..e896c53 --- /dev/null +++ b/web/frontend/src/core/websocket/WebSocketService.js @@ -0,0 +1,146 @@ +import { WS_BASE_URL } from '../config/index.js'; + +class WebSocketService { + constructor() { + this.ws = null; + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 5; + this.reconnectDelay = 3000; + this.listeners = new Set(); + } + + connect(token) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + return; + } + + this.setToken(token); + this.close(); + + try { + const wsUrl = this.buildWebSocketUrl(token); + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + this.reconnectAttempts = 0; + this.onOpen(); + }; + + this.ws.onclose = (event) => { + this.onClose(event); + }; + + this.ws.onerror = (error) => { + this.onError(error); + }; + + this.ws.onmessage = (event) => { + this.onMessage(event); + }; + } catch (error) { + console.error('Error creating WebSocket connection:', error); + } + } + + buildWebSocketUrl(token) { + if (!token) { + throw new Error('Token is required for WebSocket connection'); + } + + const apiBaseUrl = import.meta.env.VITE_API_BASE_URL; + + if (apiBaseUrl && apiBaseUrl !== '/api') { + try { + const url = new URL(apiBaseUrl); + const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${url.host}/ws?token=${encodeURIComponent(token)}`; + } catch (e) { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; + } + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; + } + + onOpen() { + console.log('WebSocket connected'); + window.dispatchEvent(new CustomEvent('ws-connected')); + } + + onClose(event) { + console.log('WebSocket disconnected', event.code); + window.dispatchEvent(new CustomEvent('ws-disconnected', { detail: { code: event.code } })); + + if (event.code === 1008) { + // Authentication failed, don't reconnect + return; + } + + // Attempt to reconnect + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + setTimeout(() => { + if (this.token) { + this.connect(this.token); + } + }, this.reconnectDelay); + } + } + + onError(error) { + console.error('WebSocket error:', error); + window.dispatchEvent(new CustomEvent('ws-error', { detail: error })); + } + + onMessage(event) { + try { + const data = JSON.parse(event.data); + window.dispatchEvent(new CustomEvent('ws-message', { detail: data })); + + // Notify all listeners + this.listeners.forEach(listener => { + try { + listener(data); + } catch (error) { + console.error('Error in WebSocket listener:', error); + } + }); + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + } + + getToken() { + // Token will be passed when connecting + return this.token || null; + } + + setToken(token) { + this.token = token; + } + + close() { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + this.token = null; + } + + isConnected() { + return this.ws && this.ws.readyState === WebSocket.OPEN; + } + + addListener(callback) { + this.listeners.add(callback); + return () => this.listeners.delete(callback); + } + + removeListener(callback) { + this.listeners.delete(callback); + } +} + +export default new WebSocketService(); diff --git a/web/frontend/src/domain/entities/Article.js b/web/frontend/src/domain/entities/Article.js new file mode 100644 index 0000000..2b5f43c --- /dev/null +++ b/web/frontend/src/domain/entities/Article.js @@ -0,0 +1,59 @@ +// Article entity +export class Article { + constructor(data) { + this.id = data.id; + this.platform = data.platform; + this.title = data.title || 'Sin título'; + this.description = data.description || ''; + this.price = data.price; + this.currency = data.currency || '€'; + this.url = data.url; + this.images = data.images || []; + this.location = data.location; + this.allows_shipping = data.allows_shipping; + this.modified_at = data.modified_at; + this.notifiedAt = data.notifiedAt; + this.addedAt = data.addedAt; + this.username = data.username; + this.worker_name = data.worker_name; + this.is_favorite = data.is_favorite || false; + } + + get displayPrice() { + if (this.price === null || this.price === undefined) return 'N/A'; + return `${this.price} ${this.currency}`; + } + + get hasImages() { + return this.images && this.images.length > 0; + } + + get primaryImage() { + return this.hasImages ? this.images[0] : null; + } + + get uniqueId() { + return `${this.platform}-${this.id}`; + } + + toJSON() { + return { + id: this.id, + platform: this.platform, + title: this.title, + description: this.description, + price: this.price, + currency: this.currency, + url: this.url, + images: this.images, + location: this.location, + allows_shipping: this.allows_shipping, + modified_at: this.modified_at, + notifiedAt: this.notifiedAt, + addedAt: this.addedAt, + username: this.username, + worker_name: this.worker_name, + is_favorite: this.is_favorite, + }; + } +} diff --git a/web/frontend/src/domain/entities/Favorite.js b/web/frontend/src/domain/entities/Favorite.js new file mode 100644 index 0000000..edee998 --- /dev/null +++ b/web/frontend/src/domain/entities/Favorite.js @@ -0,0 +1,19 @@ +// Favorite entity +export class Favorite { + constructor({ platform, id, article = null }) { + this.platform = platform; + this.id = String(id); + this.article = article; + } + + get uniqueId() { + return `${this.platform}-${this.id}`; + } + + toJSON() { + return { + platform: this.platform, + id: this.id, + }; + } +} diff --git a/web/frontend/src/domain/entities/User.js b/web/frontend/src/domain/entities/User.js new file mode 100644 index 0000000..ee032fa --- /dev/null +++ b/web/frontend/src/domain/entities/User.js @@ -0,0 +1,24 @@ +// User entity +export class User { + constructor({ username, role = 'user', token = null }) { + this.username = username; + this.role = role; + this.token = token; + } + + get isAdmin() { + return this.role === 'admin'; + } + + get isAuthenticated() { + return !!this.token; + } + + toJSON() { + return { + username: this.username, + role: this.role, + token: this.token, + }; + } +} diff --git a/web/frontend/src/domain/repositories/AdminRepository.js b/web/frontend/src/domain/repositories/AdminRepository.js new file mode 100644 index 0000000..5893970 --- /dev/null +++ b/web/frontend/src/domain/repositories/AdminRepository.js @@ -0,0 +1,19 @@ +import apiClient from '../../core/http/ApiClient.js'; + +class AdminRepository { + async getRateLimiterInfo() { + const response = await apiClient.get('/admin/rate-limiter'); + return response.data; + } + + async getSessions() { + const response = await apiClient.get('/admin/sessions'); + return response.data; + } + + async deleteSession(token) { + await apiClient.delete(`/admin/sessions/${token}`); + } +} + +export default new AdminRepository(); diff --git a/web/frontend/src/domain/repositories/ArticleRepository.js b/web/frontend/src/domain/repositories/ArticleRepository.js new file mode 100644 index 0000000..ce5f423 --- /dev/null +++ b/web/frontend/src/domain/repositories/ArticleRepository.js @@ -0,0 +1,35 @@ +import apiClient from '../../core/http/ApiClient.js'; +import { Article } from '../entities/Article.js'; + +class ArticleRepository { + async getArticles(limit = 100, offset = 0, filters = {}) { + const params = { limit, offset, ...filters }; + const response = await apiClient.get('/articles', { params }); + return { + articles: response.data.articles.map(a => new Article(a)), + total: response.data.total, + }; + } + + async getArticleFacets() { + const response = await apiClient.get('/articles/facets'); + return response.data; + } + + async searchArticles(query, mode = 'AND') { + const response = await apiClient.get('/articles/search', { + params: { q: query, mode }, + }); + return { + articles: response.data.articles.map(a => new Article(a)), + total: response.data.total, + }; + } + + async getArticle(platform, id) { + const response = await apiClient.get(`/articles/${platform}/${id}`); + return new Article(response.data); + } +} + +export default new ArticleRepository(); diff --git a/web/frontend/src/domain/repositories/AuthRepository.js b/web/frontend/src/domain/repositories/AuthRepository.js new file mode 100644 index 0000000..10da624 --- /dev/null +++ b/web/frontend/src/domain/repositories/AuthRepository.js @@ -0,0 +1,76 @@ +import apiClient from '../../core/http/ApiClient.js'; +import { getDeviceFingerprint } from '../../shared/utils/fingerprint.js'; + +class AuthRepository { + async login(username, password) { + let fingerprintData = null; + try { + fingerprintData = await getDeviceFingerprint(); + } catch (error) { + console.warn('Error obteniendo fingerprint, continuando sin él:', error); + } + + const requestBody = { + username, + password, + }; + + if (fingerprintData) { + requestBody.fingerprint = fingerprintData.fingerprint; + requestBody.deviceInfo = fingerprintData.deviceInfo; + } + + const response = await fetch('/api/users/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Error en login'); + } + + return data; + } + + async logout(token) { + try { + await fetch('/api/users/logout', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + console.error('Error al cerrar sesión en el servidor:', error); + } + } + + async validateSession(token) { + try { + const response = await fetch('/api/users/me', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (response.ok) { + const data = await response.json(); + return data.success && data.authenticated ? data : null; + } + + return null; + } catch (error) { + console.error('Error validando sesión:', error); + return null; + } + } +} + +export default new AuthRepository(); diff --git a/web/frontend/src/domain/repositories/FavoriteRepository.js b/web/frontend/src/domain/repositories/FavoriteRepository.js new file mode 100644 index 0000000..7850f1b --- /dev/null +++ b/web/frontend/src/domain/repositories/FavoriteRepository.js @@ -0,0 +1,20 @@ +import apiClient from '../../core/http/ApiClient.js'; +import { Favorite } from '../entities/Favorite.js'; + +class FavoriteRepository { + async getFavorites() { + const response = await apiClient.get('/favorites'); + return response.data.map(f => new Favorite(f)); + } + + async addFavorite(favorite) { + const response = await apiClient.post('/favorites', favorite.toJSON()); + return new Favorite(response.data); + } + + async removeFavorite(platform, id) { + await apiClient.delete(`/favorites/${platform}/${id}`); + } +} + +export default new FavoriteRepository(); diff --git a/web/frontend/src/domain/repositories/LogRepository.js b/web/frontend/src/domain/repositories/LogRepository.js new file mode 100644 index 0000000..64eafb8 --- /dev/null +++ b/web/frontend/src/domain/repositories/LogRepository.js @@ -0,0 +1,14 @@ +import apiClient from '../../core/http/ApiClient.js'; + +class LogRepository { + async getLogs(limit = 500, sinceLine = null) { + const params = { limit }; + if (sinceLine !== null && sinceLine > 0) { + params.since = sinceLine; + } + const response = await apiClient.get('/logs', { params }); + return response.data; + } +} + +export default new LogRepository(); diff --git a/web/frontend/src/domain/repositories/StatsRepository.js b/web/frontend/src/domain/repositories/StatsRepository.js new file mode 100644 index 0000000..89ebd8b --- /dev/null +++ b/web/frontend/src/domain/repositories/StatsRepository.js @@ -0,0 +1,10 @@ +import apiClient from '../../core/http/ApiClient.js'; + +class StatsRepository { + async getStats() { + const response = await apiClient.get('/stats'); + return response.data; + } +} + +export default new StatsRepository(); diff --git a/web/frontend/src/domain/repositories/TelegramRepository.js b/web/frontend/src/domain/repositories/TelegramRepository.js new file mode 100644 index 0000000..14d7618 --- /dev/null +++ b/web/frontend/src/domain/repositories/TelegramRepository.js @@ -0,0 +1,20 @@ +import apiClient from '../../core/http/ApiClient.js'; + +class TelegramRepository { + async getConfig() { + const response = await apiClient.get('/telegram/config'); + return response.data; + } + + async setConfig(config) { + const response = await apiClient.put('/telegram/config', config); + return response.data; + } + + async getThreads() { + const response = await apiClient.get('/telegram/threads'); + return response.data; + } +} + +export default new TelegramRepository(); diff --git a/web/frontend/src/domain/repositories/UserRepository.js b/web/frontend/src/domain/repositories/UserRepository.js new file mode 100644 index 0000000..4b487ec --- /dev/null +++ b/web/frontend/src/domain/repositories/UserRepository.js @@ -0,0 +1,24 @@ +import apiClient from '../../core/http/ApiClient.js'; + +class UserRepository { + async getUsers() { + const response = await apiClient.get('/users'); + return response.data; + } + + async createUser(userData) { + const response = await apiClient.post('/users', userData); + return response.data; + } + + async deleteUser(username) { + await apiClient.delete(`/users/${username}`); + } + + async changePassword(passwordData) { + const response = await apiClient.post('/users/change-password', passwordData); + return response.data; + } +} + +export default new UserRepository(); diff --git a/web/frontend/src/domain/repositories/WorkerRepository.js b/web/frontend/src/domain/repositories/WorkerRepository.js new file mode 100644 index 0000000..dba6d81 --- /dev/null +++ b/web/frontend/src/domain/repositories/WorkerRepository.js @@ -0,0 +1,15 @@ +import apiClient from '../../core/http/ApiClient.js'; + +class WorkerRepository { + async getWorkers() { + const response = await apiClient.get('/workers'); + return response.data; + } + + async updateWorkers(workers) { + const response = await apiClient.put('/workers', workers); + return response.data; + } +} + +export default new WorkerRepository(); diff --git a/web/frontend/src/main.js b/web/frontend/src/main.js index 66afc98..b9c4476 100644 --- a/web/frontend/src/main.js +++ b/web/frontend/src/main.js @@ -1,79 +1,23 @@ import { createApp } from 'vue'; -import { createRouter, createWebHistory } from 'vue-router'; import App from './App.vue'; -import Dashboard from './views/Dashboard.vue'; -import Articles from './views/Articles.vue'; -import ArticleDetail from './views/ArticleDetail.vue'; -import Favorites from './views/Favorites.vue'; -import Workers from './views/Workers.vue'; -import Users from './views/Users.vue'; -import Logs from './views/Logs.vue'; -import RateLimiter from './views/RateLimiter.vue'; -import Sessions from './views/Sessions.vue'; -import Login from './views/Login.vue'; +import router from './presentation/router/index.js'; +import authService from './application/services/AuthService.js'; +import webSocketService from './core/websocket/WebSocketService.js'; import './style.css'; -import authService from './services/auth'; - -const routes = [ - { path: '/login', component: Login, name: 'login' }, - { path: '/', component: Dashboard, meta: { requiresAuth: true } }, - { path: '/articles', component: Articles, meta: { requiresAuth: true } }, - { path: '/articles/:platform/:id', component: ArticleDetail, meta: { requiresAuth: true } }, - { path: '/favorites', component: Favorites, meta: { requiresAuth: true } }, - { path: '/workers', component: Workers, meta: { requiresAuth: true } }, - { path: '/users', component: Users, meta: { requiresAuth: true } }, - { path: '/logs', component: Logs, meta: { requiresAuth: true } }, - { path: '/rate-limiter', component: RateLimiter, meta: { requiresAuth: true } }, - { path: '/sessions', component: Sessions, meta: { requiresAuth: true } }, -]; - -const router = createRouter({ - history: createWebHistory(), - routes, -}); - -// Guard de navegación para verificar autenticación -router.beforeEach(async (to, from, next) => { - // Si la ruta es /login y ya está autenticado, redirigir al dashboard - if (to.path === '/login') { - if (authService.hasCredentials()) { - const isValid = await authService.validateSession(); - if (isValid) { - next('/'); - return; - } - } - next(); - return; - } - - // Para todas las demás rutas, verificar autenticación - if (to.meta.requiresAuth) { - // Verificar si hay token almacenado - if (!authService.hasCredentials()) { - // No hay token, redirigir a login - next('/login'); - return; - } - - // Hay token, validar si sigue siendo válido - const isValid = await authService.validateSession(); - if (!isValid) { - // Token inválido o expirado, redirigir a login - next('/login'); - return; - } - } - - // Continuar la navegación - next(); -}); const app = createApp(App); app.use(router); app.mount('#app'); -// Registrar Service Worker automáticamente al cargar la app +// Initialize WebSocket connection if authenticated +if (authService.hasCredentials()) { + const token = authService.getToken(); + if (token) { + webSocketService.connect(token); + } +} + +// Register Service Worker if ('serviceWorker' in navigator) { window.addEventListener('load', async () => { try { @@ -86,4 +30,3 @@ if ('serviceWorker' in navigator) { } }); } - diff --git a/web/frontend/src/components/ArticleCard.vue b/web/frontend/src/presentation/components/ArticleCard.vue similarity index 95% rename from web/frontend/src/components/ArticleCard.vue rename to web/frontend/src/presentation/components/ArticleCard.vue index 6d5f552..04cce62 100644 --- a/web/frontend/src/components/ArticleCard.vue +++ b/web/frontend/src/presentation/components/ArticleCard.vue @@ -145,8 +145,9 @@ import { defineProps, defineEmits, ref, onMounted, onUnmounted } from 'vue'; import { useRouter } from 'vue-router'; import { HeartIcon } from '@heroicons/vue/24/outline'; import { HeartIcon as HeartIconSolid } from '@heroicons/vue/24/solid'; -import authService from '../services/auth'; -import api from '../services/api'; +import authService from '../../application/services/AuthService.js'; +import favoriteService from '../../application/services/FavoriteService.js'; +import { formatDate } from '../../shared/utils/date.js'; const router = useRouter(); @@ -181,10 +182,6 @@ function checkAuth() { isAuthenticated.value = authService.hasCredentials(); } -function formatDate(timestamp) { - if (!timestamp) return 'N/A'; - return new Date(timestamp).toLocaleString('es-ES'); -} function handleImageError(event) { // Si la imagen falla al cargar, reemplazar con placeholder @@ -205,20 +202,11 @@ async function handleAddFavorite() { isAdding.value = true; try { - // El backend solo necesita platform e id - const favorite = { - platform: props.article.platform, - id: String(props.article.id), // Asegurar que sea string - }; - - await api.addFavorite(favorite); + await favoriteService.addFavorite(props.article.platform, props.article.id); favoriteStatus.value = true; - - // Emitir evento para que el componente padre pueda actualizar si es necesario emit('added', props.article.platform, props.article.id); } catch (error) { console.error('Error añadiendo a favoritos:', error); - // El interceptor de API ya maneja el error 401 mostrando el modal de login if (error.response?.status === 404) { alert('El artículo no se encontró en la base de datos. Asegúrate de que el artículo esté en la lista de notificados.'); } else if (error.response?.status === 400) { diff --git a/web/frontend/src/components/ToastContainer.vue b/web/frontend/src/presentation/components/ToastContainer.vue similarity index 88% rename from web/frontend/src/components/ToastContainer.vue rename to web/frontend/src/presentation/components/ToastContainer.vue index d4c77b7..fdc92c3 100644 --- a/web/frontend/src/components/ToastContainer.vue +++ b/web/frontend/src/presentation/components/ToastContainer.vue @@ -29,6 +29,7 @@ diff --git a/web/frontend/src/views/Favorites.vue b/web/frontend/src/presentation/views/Favorites.vue similarity index 73% rename from web/frontend/src/views/Favorites.vue rename to web/frontend/src/presentation/views/Favorites.vue index 05a487e..fc010a0 100644 --- a/web/frontend/src/views/Favorites.vue +++ b/web/frontend/src/presentation/views/Favorites.vue @@ -48,15 +48,16 @@ diff --git a/web/frontend/src/views/Login.vue b/web/frontend/src/presentation/views/Login.vue similarity index 99% rename from web/frontend/src/views/Login.vue rename to web/frontend/src/presentation/views/Login.vue index 8bfd3c2..880f8db 100644 --- a/web/frontend/src/views/Login.vue +++ b/web/frontend/src/presentation/views/Login.vue @@ -230,7 +230,7 @@ diff --git a/web/frontend/src/services/api.js b/web/frontend/src/services/api.js deleted file mode 100644 index 233ed81..0000000 --- a/web/frontend/src/services/api.js +++ /dev/null @@ -1,175 +0,0 @@ -import axios from 'axios'; -import authService from './auth'; - -// Usar variable de entorno si está disponible, sino usar '/api' (proxy en desarrollo) -const baseURL = import.meta.env.VITE_API_BASE_URL || '/api'; - -console.log('baseURL', baseURL); - -const api = axios.create({ - baseURL, - headers: { - 'Content-Type': 'application/json', - }, -}); - -// Interceptor para añadir autenticación a las peticiones -api.interceptors.request.use( - (config) => { - const authHeader = authService.getAuthHeader(); - if (authHeader) { - config.headers.Authorization = authHeader; - } - return config; - }, - (error) => { - return Promise.reject(error); - } -); - -// Interceptor para manejar errores 401 (no autenticado) -api.interceptors.response.use( - (response) => response, - (error) => { - if (error.response?.status === 401) { - // Disparar evento personalizado para mostrar diálogo de login - window.dispatchEvent(new CustomEvent('auth-required', { - detail: { - message: 'Se requiere autenticación para esta acción', - config: error.config - } - })); - } - return Promise.reject(error); - } -); - -export default { - // Estadísticas - async getStats() { - const response = await api.get('/stats'); - return response.data; - }, - - // Workers - async getWorkers() { - const response = await api.get('/workers'); - return response.data; - }, - - async updateWorkers(workers) { - const response = await api.put('/workers', workers); - return response.data; - }, - - // Favoritos - async getFavorites() { - const response = await api.get('/favorites'); - return response.data; - }, - - async addFavorite(favorite) { - const response = await api.post('/favorites', favorite); - return response.data; - }, - - async removeFavorite(platform, id) { - const response = await api.delete(`/favorites/${platform}/${id}`); - return response.data; - }, - - // Artículos - async getArticles(limit = 100, offset = 0, additionalParams = {}) { - const params = { limit, offset, ...additionalParams }; - const response = await api.get('/articles', { params }); - return response.data; - }, - - async getArticleFacets() { - const response = await api.get('/articles/facets'); - return response.data; - }, - - async searchArticles(query, mode = 'AND') { - const response = await api.get('/articles/search', { - params: { q: query, mode: mode }, - }); - return response.data; - }, - - async getArticle(platform, id) { - const response = await api.get(`/articles/${platform}/${id}`); - return response.data; - }, - - // Logs - async getLogs(limit = 500, sinceLine = null) { - const params = { limit }; - if (sinceLine !== null && sinceLine > 0) { - params.since = sinceLine; - } - const response = await api.get('/logs', { params }); - return response.data; - }, - - // Configuración - async getConfig() { - const response = await api.get('/config'); - return response.data; - }, - - // Telegram - async getTelegramConfig() { - const response = await api.get('/telegram/config'); - return response.data; - }, - - async setTelegramConfig(config) { - const response = await api.put('/telegram/config', config); - return response.data; - }, - - async getTelegramThreads() { - const response = await api.get('/telegram/threads'); - return response.data; - }, - - // Usuarios - async getUsers() { - const response = await api.get('/users'); - return response.data; - }, - - async createUser(userData) { - const response = await api.post('/users', userData); - return response.data; - }, - - async deleteUser(username) { - const response = await api.delete(`/users/${username}`); - return response.data; - }, - - async changePassword(passwordData) { - const response = await api.post('/users/change-password', passwordData); - return response.data; - }, - - // Admin - Rate Limiter - async getRateLimiterInfo() { - const response = await api.get('/admin/rate-limiter'); - return response.data; - }, - - // Admin - Sessions - async getSessions() { - const response = await api.get('/admin/sessions'); - return response.data; - }, - - async deleteSession(token) { - const response = await api.delete(`/admin/sessions/${token}`); - return response.data; - }, -}; - diff --git a/web/frontend/src/services/auth.js b/web/frontend/src/services/auth.js deleted file mode 100644 index f916ba1..0000000 --- a/web/frontend/src/services/auth.js +++ /dev/null @@ -1,230 +0,0 @@ -// Servicio de autenticación para gestionar tokens -import { getDeviceFingerprint } from './fingerprint.js'; - -const AUTH_STORAGE_KEY = 'wallabicher_token'; -const USERNAME_STORAGE_KEY = 'wallabicher_username'; -const ROLE_STORAGE_KEY = 'wallabicher_role'; - -class AuthService { - constructor() { - this.token = this.loadToken(); - this.username = this.loadUsername(); - this.role = this.loadRole(); - } - - // Cargar token desde localStorage - loadToken() { - try { - return localStorage.getItem(AUTH_STORAGE_KEY) || ''; - } catch (error) { - console.error('Error cargando token:', error); - return ''; - } - } - - // Cargar username desde localStorage - loadUsername() { - try { - return localStorage.getItem(USERNAME_STORAGE_KEY) || ''; - } catch (error) { - console.error('Error cargando username:', error); - return ''; - } - } - - // Guardar token, username y role en localStorage - saveSession(token, username, role = 'user') { - try { - this.token = token; - this.username = username; - this.role = role; - localStorage.setItem(AUTH_STORAGE_KEY, token); - localStorage.setItem(USERNAME_STORAGE_KEY, username); - localStorage.setItem(ROLE_STORAGE_KEY, role); - return true; - } catch (error) { - console.error('Error guardando sesión:', error); - return false; - } - } - - // Cargar role desde localStorage - loadRole() { - try { - return localStorage.getItem(ROLE_STORAGE_KEY) || 'user'; - } catch (error) { - console.error('Error cargando role:', error); - return 'user'; - } - } - - // Eliminar token, username y role - clearSession() { - try { - this.token = ''; - this.username = ''; - this.role = 'user'; - localStorage.removeItem(AUTH_STORAGE_KEY); - localStorage.removeItem(USERNAME_STORAGE_KEY); - localStorage.removeItem(ROLE_STORAGE_KEY); - return true; - } catch (error) { - console.error('Error eliminando sesión:', error); - return false; - } - } - - // Obtener token actual - getToken() { - return this.token; - } - - // Obtener username actual - getUsername() { - return this.username; - } - - // Obtener role actual - getRole() { - return this.role; - } - - // Verificar si es admin - isAdmin() { - return this.role === 'admin'; - } - - // Verificar si hay sesión activa (token guardado) - hasCredentials() { - return !!this.token; - } - - // Generar header de autenticación Bearer - getAuthHeader() { - if (!this.token) { - return null; - } - return `Bearer ${this.token}`; - } - - // Hacer login (llamar al endpoint de login) - async login(username, password) { - try { - // Obtener fingerprint del dispositivo - let fingerprintData = null; - try { - fingerprintData = await getDeviceFingerprint(); - } catch (error) { - console.warn('Error obteniendo fingerprint, continuando sin él:', error); - } - - const requestBody = { - username, - password, - }; - - // Agregar fingerprint si está disponible - if (fingerprintData) { - requestBody.fingerprint = fingerprintData.fingerprint; - requestBody.deviceInfo = fingerprintData.deviceInfo; - } - - const response = await fetch('/api/users/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.error || 'Error en login'); - } - - if (data.success && data.token) { - const role = data.role || 'user'; - this.saveSession(data.token, data.username, role); - return { success: true, token: data.token, username: data.username, role }; - } - - throw new Error('Respuesta inválida del servidor'); - } catch (error) { - console.error('Error en login:', error); - throw error; - } - } - - // Hacer logout (llamar al endpoint de logout) - async logout() { - try { - const token = this.token; - - if (token) { - try { - await fetch('/api/users/logout', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }); - } catch (error) { - // Si falla el logout en el servidor, aún así limpiar localmente - console.error('Error al cerrar sesión en el servidor:', error); - } - } - - this.clearSession(); - return true; - } catch (error) { - console.error('Error en logout:', error); - this.clearSession(); // Limpiar localmente de todas formas - return false; - } - } - - // Verificar si el token sigue siendo válido - async validateSession() { - if (!this.token) { - return false; - } - - try { - const response = await fetch('/api/users/me', { - method: 'GET', - headers: { - 'Authorization': `Bearer ${this.token}`, - }, - }); - - if (response.ok) { - const data = await response.json(); - if (data.success && data.authenticated) { - // Actualizar role si está disponible - if (data.role) { - this.role = data.role; - localStorage.setItem(ROLE_STORAGE_KEY, data.role); - } - return true; - } - } - - // Si el token es inválido, limpiar sesión - if (response.status === 401) { - this.clearSession(); - } - - return false; - } catch (error) { - console.error('Error validando sesión:', error); - return false; - } - } -} - -// Exportar instancia singleton -const authService = new AuthService(); -export default authService; - diff --git a/web/frontend/src/shared/utils/date.js b/web/frontend/src/shared/utils/date.js new file mode 100644 index 0000000..76b5869 --- /dev/null +++ b/web/frontend/src/shared/utils/date.js @@ -0,0 +1,15 @@ +// Date utility functions +export function formatDate(timestamp) { + if (!timestamp) return 'N/A'; + return new Date(timestamp).toLocaleString('es-ES'); +} + +export function formatDateShort(timestamp) { + if (!timestamp) return 'N/A'; + return new Date(timestamp).toLocaleDateString('es-ES'); +} + +export function formatTime(timestamp) { + if (!timestamp) return 'N/A'; + return new Date(timestamp).toLocaleTimeString('es-ES'); +} diff --git a/web/frontend/src/services/fingerprint.js b/web/frontend/src/shared/utils/fingerprint.js similarity index 76% rename from web/frontend/src/services/fingerprint.js rename to web/frontend/src/shared/utils/fingerprint.js index b4da9e8..43942f1 100644 --- a/web/frontend/src/services/fingerprint.js +++ b/web/frontend/src/shared/utils/fingerprint.js @@ -4,9 +4,6 @@ let fpPromise = null; let cachedFingerprint = null; let cachedDeviceInfo = null; -/** - * Inicializa FingerprintJS (solo una vez) - */ function initFingerprintJS() { if (!fpPromise) { fpPromise = FingerprintJS.load(); @@ -14,12 +11,7 @@ function initFingerprintJS() { return fpPromise; } -/** - * Obtiene el fingerprint del dispositivo - * @returns {Promise<{fingerprint: string, deviceInfo: Object}>} - */ export async function getDeviceFingerprint() { - // Si ya tenemos el fingerprint en caché, devolverlo if (cachedFingerprint && cachedDeviceInfo) { return { fingerprint: cachedFingerprint, @@ -31,7 +23,6 @@ export async function getDeviceFingerprint() { const fp = await initFingerprintJS(); const result = await fp.get(); - // Extraer información del dispositivo desde los componentes const deviceInfo = extractDeviceInfo(result.components); cachedFingerprint = result.visitorId; @@ -43,7 +34,6 @@ export async function getDeviceFingerprint() { }; } catch (error) { console.error('Error obteniendo fingerprint:', error); - // Fallback: generar un fingerprint básico return { fingerprint: generateFallbackFingerprint(), deviceInfo: { @@ -57,11 +47,6 @@ export async function getDeviceFingerprint() { } } -/** - * Extrae información legible del dispositivo desde los componentes de FingerprintJS - * @param {Object} components - Componentes de FingerprintJS - * @returns {Object} Información del dispositivo - */ function extractDeviceInfo(components) { const info = { browser: 'Unknown', @@ -74,7 +59,6 @@ function extractDeviceInfo(components) { language: navigator.language || '', }; - // Información del navegador if (components.browserName) { info.browser = components.browserName.value || 'Unknown'; } @@ -82,7 +66,6 @@ function extractDeviceInfo(components) { info.browserVersion = components.browserVersion.value || ''; } - // Información del sistema operativo if (components.os) { info.os = components.os.value || 'Unknown'; } @@ -90,7 +73,6 @@ function extractDeviceInfo(components) { info.osVersion = components.osVersion.value || ''; } - // Información del dispositivo if (components.deviceMemory) { info.device = components.deviceMemory.value ? 'Desktop' : 'Mobile'; } @@ -105,7 +87,6 @@ function extractDeviceInfo(components) { } } - // Resolución de pantalla if (components.screenResolution) { const res = components.screenResolution.value; if (res && res.length >= 2) { @@ -113,7 +94,6 @@ function extractDeviceInfo(components) { } } - // Zona horaria if (components.timezone) { info.timezone = components.timezone.value || ''; } @@ -121,10 +101,6 @@ function extractDeviceInfo(components) { return info; } -/** - * Genera un fingerprint básico como fallback - * @returns {string} Hash del fingerprint - */ function generateFallbackFingerprint() { const data = [ navigator.userAgent, @@ -134,21 +110,16 @@ function generateFallbackFingerprint() { new Date().getTimezoneOffset(), ].join('|'); - // Simple hash (no usar en producción, solo como fallback) let hash = 0; for (let i = 0; i < data.length; i++) { const char = data.charCodeAt(i); hash = ((hash << 5) - hash) + char; - hash = hash & hash; // Convert to 32bit integer + hash = hash & hash; } return Math.abs(hash).toString(36); } -/** - * Limpia el caché del fingerprint (útil para testing) - */ export function clearFingerprintCache() { cachedFingerprint = null; cachedDeviceInfo = null; } -