refactor frontend
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
@@ -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 }));
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
17
web/frontend/src/application/services/AdminService.js
Normal file
17
web/frontend/src/application/services/AdminService.js
Normal file
@@ -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();
|
||||
21
web/frontend/src/application/services/ArticleService.js
Normal file
21
web/frontend/src/application/services/ArticleService.js
Normal file
@@ -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();
|
||||
124
web/frontend/src/application/services/AuthService.js
Normal file
124
web/frontend/src/application/services/AuthService.js
Normal file
@@ -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();
|
||||
19
web/frontend/src/application/services/FavoriteService.js
Normal file
19
web/frontend/src/application/services/FavoriteService.js
Normal file
@@ -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();
|
||||
9
web/frontend/src/application/services/LogService.js
Normal file
9
web/frontend/src/application/services/LogService.js
Normal file
@@ -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();
|
||||
@@ -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();
|
||||
9
web/frontend/src/application/services/StatsService.js
Normal file
9
web/frontend/src/application/services/StatsService.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import statsRepository from '../../domain/repositories/StatsRepository.js';
|
||||
|
||||
class StatsService {
|
||||
async getStats() {
|
||||
return await statsRepository.getStats();
|
||||
}
|
||||
}
|
||||
|
||||
export default new StatsService();
|
||||
17
web/frontend/src/application/services/TelegramService.js
Normal file
17
web/frontend/src/application/services/TelegramService.js
Normal file
@@ -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();
|
||||
21
web/frontend/src/application/services/UserService.js
Normal file
21
web/frontend/src/application/services/UserService.js
Normal file
@@ -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();
|
||||
13
web/frontend/src/application/services/WorkerService.js
Normal file
13
web/frontend/src/application/services/WorkerService.js
Normal file
@@ -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();
|
||||
3
web/frontend/src/core/config/index.js
Normal file
3
web/frontend/src/core/config/index.js
Normal file
@@ -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`);
|
||||
75
web/frontend/src/core/http/ApiClient.js
Normal file
75
web/frontend/src/core/http/ApiClient.js
Normal file
@@ -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();
|
||||
46
web/frontend/src/core/storage/LocalStorageAdapter.js
Normal file
46
web/frontend/src/core/storage/LocalStorageAdapter.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
25
web/frontend/src/core/storage/StorageService.js
Normal file
25
web/frontend/src/core/storage/StorageService.js
Normal file
@@ -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();
|
||||
146
web/frontend/src/core/websocket/WebSocketService.js
Normal file
146
web/frontend/src/core/websocket/WebSocketService.js
Normal file
@@ -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();
|
||||
59
web/frontend/src/domain/entities/Article.js
Normal file
59
web/frontend/src/domain/entities/Article.js
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
19
web/frontend/src/domain/entities/Favorite.js
Normal file
19
web/frontend/src/domain/entities/Favorite.js
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
24
web/frontend/src/domain/entities/User.js
Normal file
24
web/frontend/src/domain/entities/User.js
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
19
web/frontend/src/domain/repositories/AdminRepository.js
Normal file
19
web/frontend/src/domain/repositories/AdminRepository.js
Normal file
@@ -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();
|
||||
35
web/frontend/src/domain/repositories/ArticleRepository.js
Normal file
35
web/frontend/src/domain/repositories/ArticleRepository.js
Normal file
@@ -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();
|
||||
76
web/frontend/src/domain/repositories/AuthRepository.js
Normal file
76
web/frontend/src/domain/repositories/AuthRepository.js
Normal file
@@ -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();
|
||||
20
web/frontend/src/domain/repositories/FavoriteRepository.js
Normal file
20
web/frontend/src/domain/repositories/FavoriteRepository.js
Normal file
@@ -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();
|
||||
14
web/frontend/src/domain/repositories/LogRepository.js
Normal file
14
web/frontend/src/domain/repositories/LogRepository.js
Normal file
@@ -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();
|
||||
10
web/frontend/src/domain/repositories/StatsRepository.js
Normal file
10
web/frontend/src/domain/repositories/StatsRepository.js
Normal file
@@ -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();
|
||||
20
web/frontend/src/domain/repositories/TelegramRepository.js
Normal file
20
web/frontend/src/domain/repositories/TelegramRepository.js
Normal file
@@ -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();
|
||||
24
web/frontend/src/domain/repositories/UserRepository.js
Normal file
24
web/frontend/src/domain/repositories/UserRepository.js
Normal file
@@ -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();
|
||||
15
web/frontend/src/domain/repositories/WorkerRepository.js
Normal file
15
web/frontend/src/domain/repositories/WorkerRepository.js
Normal file
@@ -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();
|
||||
@@ -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) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
@@ -29,6 +29,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { XMarkIcon } from '@heroicons/vue/24/outline';
|
||||
import { useWebSocket } from '../composables/useWebSocket.js';
|
||||
import ToastNotification from './ToastNotification.vue';
|
||||
|
||||
const toasts = ref([]);
|
||||
@@ -78,10 +79,10 @@ function clearAllToasts() {
|
||||
toasts.value = [];
|
||||
}
|
||||
|
||||
const { handleMessage } = useWebSocket();
|
||||
|
||||
// Escuchar eventos de WebSocket para nuevos artículos
|
||||
function handleWebSocketMessage(event) {
|
||||
const data = event.detail;
|
||||
|
||||
function handleWebSocketMessage(data) {
|
||||
// Manejar notificaciones de artículos nuevos
|
||||
if (data.type === 'new_articles' && data.data) {
|
||||
// Mostrar toasts para cada artículo nuevo
|
||||
@@ -92,12 +93,17 @@ function handleWebSocketMessage(event) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('ws-message', handleWebSocketMessage);
|
||||
const unsubscribe = handleMessage(handleWebSocketMessage);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
// Limpiar todos los timeouts de toasts
|
||||
toastTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
|
||||
toastTimeouts.clear();
|
||||
};
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('ws-message', handleWebSocketMessage);
|
||||
|
||||
// Limpiar todos los timeouts de toasts
|
||||
toastTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
|
||||
toastTimeouts.clear();
|
||||
28
web/frontend/src/presentation/composables/useAuth.js
Normal file
28
web/frontend/src/presentation/composables/useAuth.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import authService from '../../application/services/AuthService.js';
|
||||
|
||||
export function useAuth() {
|
||||
const currentUser = ref(authService.getCurrentUser());
|
||||
|
||||
const isAuthenticated = computed(() => authService.hasCredentials());
|
||||
const isAdmin = computed(() => authService.isAdmin());
|
||||
const username = computed(() => authService.getUsername());
|
||||
|
||||
function updateUser() {
|
||||
currentUser.value = authService.getCurrentUser();
|
||||
}
|
||||
|
||||
// Listen to auth events
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('auth-login', updateUser);
|
||||
window.addEventListener('auth-logout', updateUser);
|
||||
}
|
||||
|
||||
return {
|
||||
currentUser,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
username,
|
||||
updateUser,
|
||||
};
|
||||
}
|
||||
37
web/frontend/src/presentation/composables/useDarkMode.js
Normal file
37
web/frontend/src/presentation/composables/useDarkMode.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
export function useDarkMode() {
|
||||
const isDark = ref(false);
|
||||
|
||||
function toggle() {
|
||||
isDark.value = !isDark.value;
|
||||
if (isDark.value) {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('darkMode', 'true');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('darkMode', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
const saved = localStorage.getItem('darkMode');
|
||||
if (saved === 'true' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
isDark.value = true;
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
isDark.value = false;
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init();
|
||||
});
|
||||
|
||||
return {
|
||||
isDark,
|
||||
toggle,
|
||||
init,
|
||||
};
|
||||
}
|
||||
43
web/frontend/src/presentation/composables/useWebSocket.js
Normal file
43
web/frontend/src/presentation/composables/useWebSocket.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
export function useWebSocket() {
|
||||
const isConnected = ref(false);
|
||||
|
||||
function handleConnected() {
|
||||
isConnected.value = true;
|
||||
}
|
||||
|
||||
function handleDisconnected() {
|
||||
isConnected.value = false;
|
||||
}
|
||||
|
||||
function handleMessage(callback) {
|
||||
const handler = (event) => {
|
||||
callback(event.detail);
|
||||
};
|
||||
|
||||
window.addEventListener('ws-message', handler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('ws-message', handler);
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('ws-connected', handleConnected);
|
||||
window.addEventListener('ws-disconnected', handleDisconnected);
|
||||
|
||||
// Check initial state
|
||||
isConnected.value = false; // Will be updated by events
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('ws-connected', handleConnected);
|
||||
window.removeEventListener('ws-disconnected', handleDisconnected);
|
||||
});
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
handleMessage,
|
||||
};
|
||||
}
|
||||
106
web/frontend/src/presentation/router/index.js
Normal file
106
web/frontend/src/presentation/router/index.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import authService from '../../application/services/AuthService.js';
|
||||
import webSocketService from '../../core/websocket/WebSocketService.js';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('../views/Login.vue'),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard',
|
||||
component: () => import('../views/Dashboard.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/articles',
|
||||
name: 'articles',
|
||||
component: () => import('../views/Articles.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/articles/:platform/:id',
|
||||
name: 'article-detail',
|
||||
component: () => import('../views/ArticleDetail.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/favorites',
|
||||
name: 'favorites',
|
||||
component: () => import('../views/Favorites.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/workers',
|
||||
name: 'workers',
|
||||
component: () => import('../views/Workers.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
name: 'users',
|
||||
component: () => import('../views/Users.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/logs',
|
||||
name: 'logs',
|
||||
component: () => import('../views/Logs.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/rate-limiter',
|
||||
name: 'rate-limiter',
|
||||
component: () => import('../views/RateLimiter.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/sessions',
|
||||
name: 'sessions',
|
||||
component: () => import('../views/Sessions.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
if (to.path === '/login') {
|
||||
if (authService.hasCredentials()) {
|
||||
const isValid = await authService.validateSession();
|
||||
if (isValid) {
|
||||
next('/');
|
||||
return;
|
||||
}
|
||||
}
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (to.meta.requiresAuth) {
|
||||
if (!authService.hasCredentials()) {
|
||||
next('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await authService.validateSession();
|
||||
if (!isValid) {
|
||||
next('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (to.meta.requiresAdmin && !authService.isAdmin()) {
|
||||
next('/');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -260,8 +260,9 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import articleService from '../../application/services/ArticleService.js';
|
||||
import favoriteService from '../../application/services/FavoriteService.js';
|
||||
import { useAuth } from '../composables/useAuth.js';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
@@ -351,8 +352,10 @@ function handleKeydown(event) {
|
||||
}
|
||||
}
|
||||
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
function checkAuth() {
|
||||
isAuthenticated.value = authService.hasCredentials();
|
||||
// Auth state is managed by useAuth composable
|
||||
}
|
||||
|
||||
async function handleAddFavorite() {
|
||||
@@ -368,7 +371,7 @@ async function handleAddFavorite() {
|
||||
id: String(article.value.id),
|
||||
};
|
||||
|
||||
await api.addFavorite(favorite);
|
||||
await favoriteService.addFavorite(article.value.platform, article.value.id);
|
||||
favoriteStatus.value = true;
|
||||
} catch (error) {
|
||||
console.error('Error añadiendo a favoritos:', error);
|
||||
@@ -409,7 +412,7 @@ async function loadArticle() {
|
||||
return;
|
||||
}
|
||||
|
||||
article.value = await api.getArticle(platform, id);
|
||||
article.value = await articleService.getArticle(platform, id);
|
||||
|
||||
if (!article.value) {
|
||||
error.value = 'Artículo no encontrado';
|
||||
@@ -211,7 +211,7 @@
|
||||
<div v-else class="space-y-4">
|
||||
<ArticleCard
|
||||
v-for="article in filteredArticles"
|
||||
:key="`${article.platform}-${article.id}`"
|
||||
:key="article.uniqueId || `${article.platform}-${article.id}`"
|
||||
:article="article"
|
||||
:is-new="newArticleIds.has(`${article.platform}-${article.id}`)"
|
||||
/>
|
||||
@@ -243,8 +243,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import articleService from '../../application/services/ArticleService.js';
|
||||
import { useAuth } from '../composables/useAuth.js';
|
||||
import { useWebSocket } from '../composables/useWebSocket.js';
|
||||
import ArticleCard from '../components/ArticleCard.vue';
|
||||
import {
|
||||
FunnelIcon,
|
||||
@@ -256,8 +257,8 @@ import {
|
||||
MagnifyingGlassIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
|
||||
const currentUser = ref(authService.getUsername() || null);
|
||||
const isAdmin = ref(false);
|
||||
const { username: currentUser, isAdmin } = useAuth();
|
||||
const { handleMessage } = useWebSocket();
|
||||
|
||||
const allArticles = ref([]);
|
||||
const searchResults = ref([]);
|
||||
@@ -326,7 +327,7 @@ function clearAllFilters() {
|
||||
|
||||
async function loadFacets() {
|
||||
try {
|
||||
const data = await api.getArticleFacets();
|
||||
const data = await articleService.getArticleFacets();
|
||||
facets.value = {
|
||||
platforms: data.platforms || [],
|
||||
usernames: data.usernames || [],
|
||||
@@ -374,7 +375,7 @@ async function loadArticles(reset = true, silent = false) {
|
||||
}
|
||||
if (selectedWorker.value) params.worker_name = selectedWorker.value;
|
||||
|
||||
const data = await api.getArticles(limit, offset.value, params);
|
||||
const data = await articleService.getArticles(limit, offset.value, params);
|
||||
|
||||
let filtered = data.articles;
|
||||
// El filtro de plataforma se aplica en el backend ahora, pero mantenemos compatibilidad
|
||||
@@ -426,14 +427,12 @@ async function loadArticles(reset = true, silent = false) {
|
||||
}
|
||||
|
||||
function handleAuthChange() {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
// Si no es admin, no permitir filtrar por username
|
||||
if (!isAdmin.value && selectedUsername.value) {
|
||||
selectedUsername.value = '';
|
||||
}
|
||||
if (currentUser.value) {
|
||||
loadFacets(); // Recargar facets cuando cambie el usuario
|
||||
loadFacets();
|
||||
loadArticles();
|
||||
}
|
||||
}
|
||||
@@ -442,10 +441,9 @@ function loadMore() {
|
||||
loadArticles(false);
|
||||
}
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
function handleWSMessage(data) {
|
||||
if (data.type === 'articles_updated') {
|
||||
loadFacets(); // Actualizar facets cuando se actualicen los artículos
|
||||
loadFacets();
|
||||
loadArticles();
|
||||
}
|
||||
}
|
||||
@@ -460,7 +458,7 @@ async function searchArticles(query) {
|
||||
searching.value = true;
|
||||
|
||||
try {
|
||||
const data = await api.searchArticles(query, searchMode.value);
|
||||
const data = await articleService.searchArticles(query, searchMode.value);
|
||||
|
||||
let filtered = data.articles || [];
|
||||
|
||||
@@ -505,37 +503,39 @@ watch([searchQuery, searchMode], ([newQuery]) => {
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
// Si no es admin, no permitir filtrar por username
|
||||
if (!isAdmin.value && selectedUsername.value) {
|
||||
selectedUsername.value = '';
|
||||
}
|
||||
loadFacets(); // Cargar facets primero
|
||||
loadFacets();
|
||||
loadArticles();
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
window.addEventListener('auth-login', handleAuthChange);
|
||||
|
||||
const unsubscribe = handleMessage(handleWSMessage);
|
||||
|
||||
// Iniciar autopoll para actualizar automáticamente
|
||||
autoPollInterval.value = setInterval(() => {
|
||||
loadArticles(true, true); // Reset silencioso cada 30 segundos
|
||||
loadFacets(); // Actualizar facets también
|
||||
loadArticles(true, true);
|
||||
loadFacets();
|
||||
}, POLL_INTERVAL);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (autoPollInterval.value) {
|
||||
clearInterval(autoPollInterval.value);
|
||||
autoPollInterval.value = null;
|
||||
}
|
||||
if (searchTimeout.value) {
|
||||
clearTimeout(searchTimeout.value);
|
||||
searchTimeout.value = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('ws-message', handleWSMessage);
|
||||
window.removeEventListener('auth-logout', handleAuthChange);
|
||||
window.removeEventListener('auth-login', handleAuthChange);
|
||||
|
||||
// Limpiar el intervalo cuando el componente se desmonte
|
||||
if (autoPollInterval.value) {
|
||||
clearInterval(autoPollInterval.value);
|
||||
autoPollInterval.value = null;
|
||||
}
|
||||
|
||||
// Limpiar el timeout de búsqueda
|
||||
if (searchTimeout.value) {
|
||||
clearTimeout(searchTimeout.value);
|
||||
searchTimeout.value = null;
|
||||
@@ -227,8 +227,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import statsService from '../../application/services/StatsService.js';
|
||||
import { useAuth } from '../composables/useAuth.js';
|
||||
import { useWebSocket } from '../composables/useWebSocket.js';
|
||||
import {
|
||||
Cog6ToothIcon,
|
||||
HeartIcon,
|
||||
@@ -245,8 +246,8 @@ const stats = ref({
|
||||
platforms: {},
|
||||
});
|
||||
|
||||
const currentUser = ref(authService.getUsername() || null);
|
||||
const isAdmin = ref(false);
|
||||
const { username: currentUser, isAdmin } = useAuth();
|
||||
const { handleMessage } = useWebSocket();
|
||||
|
||||
function getPercentage(value, total) {
|
||||
if (!total || total === 0) return 0;
|
||||
@@ -255,25 +256,13 @@ function getPercentage(value, total) {
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
stats.value = await api.getStats();
|
||||
// Verificar si el usuario es admin (se puede inferir de si ve todas las estadísticas)
|
||||
// O podemos añadir un endpoint para verificar el rol
|
||||
stats.value = await statsService.getStats();
|
||||
} catch (error) {
|
||||
console.error('Error cargando estadísticas:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleAuthChange() {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
if (currentUser.value) {
|
||||
loadStats();
|
||||
}
|
||||
}
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
function handleWSMessage(data) {
|
||||
if (data.type === 'workers_updated' || data.type === 'favorites_updated') {
|
||||
loadStats();
|
||||
}
|
||||
@@ -282,22 +271,22 @@ function handleWSMessage(event) {
|
||||
let interval = null;
|
||||
|
||||
onMounted(() => {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
loadStats();
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
window.addEventListener('auth-login', handleAuthChange);
|
||||
interval = setInterval(loadStats, 10000); // Actualizar cada 10 segundos
|
||||
const unsubscribe = handleMessage(handleWSMessage);
|
||||
interval = setInterval(loadStats, 10000);
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
window.removeEventListener('ws-message', handleWSMessage);
|
||||
window.removeEventListener('auth-logout', handleAuthChange);
|
||||
window.removeEventListener('auth-login', handleAuthChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -48,15 +48,16 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import favoriteService from '../../application/services/FavoriteService.js';
|
||||
import { useAuth } from '../composables/useAuth.js';
|
||||
import { useWebSocket } from '../composables/useWebSocket.js';
|
||||
import { HeartIcon } from '@heroicons/vue/24/outline';
|
||||
import ArticleCard from '../components/ArticleCard.vue';
|
||||
|
||||
const favorites = ref([]);
|
||||
const loading = ref(true);
|
||||
const currentUser = ref(authService.getUsername() || null);
|
||||
const isAdmin = ref(false);
|
||||
const { username: currentUser } = useAuth();
|
||||
const { handleMessage } = useWebSocket();
|
||||
|
||||
|
||||
async function loadFavorites() {
|
||||
@@ -69,13 +70,11 @@ async function loadFavorites() {
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
favorites.value = await api.getFavorites();
|
||||
favorites.value = await favoriteService.getFavorites();
|
||||
} catch (error) {
|
||||
console.error('Error cargando favoritos:', error);
|
||||
// Si hay error de autenticación, limpiar favoritos
|
||||
if (error.response?.status === 401) {
|
||||
favorites.value = [];
|
||||
currentUser.value = null;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -88,7 +87,7 @@ async function removeFavorite(platform, id) {
|
||||
}
|
||||
|
||||
try {
|
||||
await api.removeFavorite(platform, id);
|
||||
await favoriteService.removeFavorite(platform, id);
|
||||
await loadFavorites();
|
||||
} catch (error) {
|
||||
console.error('Error eliminando favorito:', error);
|
||||
@@ -96,38 +95,25 @@ async function removeFavorite(platform, id) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
function handleWSMessage(data) {
|
||||
if (data.type === 'favorites_updated') {
|
||||
// Solo actualizar si es para el usuario actual
|
||||
if (data.username === currentUser.value) {
|
||||
favorites.value = data.data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthChange() {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
if (currentUser.value) {
|
||||
loadFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
loadFavorites();
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
window.addEventListener('auth-login', handleAuthChange);
|
||||
const unsubscribe = handleMessage(handleWSMessage);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('ws-message', handleWSMessage);
|
||||
window.removeEventListener('auth-logout', handleAuthChange);
|
||||
window.removeEventListener('auth-login', handleAuthChange);
|
||||
// Cleanup handled in onMounted return
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -230,7 +230,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import authService from '../services/auth';
|
||||
import authService from '../../application/services/AuthService.js';
|
||||
|
||||
const router = useRouter();
|
||||
const loginError = ref('');
|
||||
@@ -94,8 +94,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import logService from '../../application/services/LogService.js';
|
||||
import { useAuth } from '../composables/useAuth.js';
|
||||
import { DocumentMagnifyingGlassIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
const logs = ref([]);
|
||||
@@ -106,8 +106,7 @@ const refreshIntervalSeconds = ref(5);
|
||||
const followLatestLog = ref(true);
|
||||
const logsContainer = ref(null);
|
||||
const lastLineNumber = ref(-1); // Número de la última línea leída
|
||||
const currentUser = ref(authService.getUsername() || null);
|
||||
const isAdmin = ref(false);
|
||||
const { username: currentUser, isAdmin } = useAuth();
|
||||
const accessDenied = ref(false);
|
||||
let refreshInterval = null;
|
||||
|
||||
@@ -156,7 +155,7 @@ async function loadLogs(forceReload = false, shouldScroll = null) {
|
||||
// Si es carga inicial o forzada, no enviar sinceLine (cargar últimas líneas)
|
||||
// Si es actualización incremental, enviar lastLineNumber + 1 para obtener solo las nuevas
|
||||
const sinceLine = isInitialLoad ? null : lastLineNumber.value + 1;
|
||||
const data = await api.getLogs(500, sinceLine);
|
||||
const data = await logService.getLogs(500, sinceLine);
|
||||
|
||||
const newLogs = data.logs || [];
|
||||
const newLastLineNumber = data.lastLineNumber !== undefined ? data.lastLineNumber : -1;
|
||||
@@ -149,8 +149,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import adminService from '../../application/services/AdminService.js';
|
||||
import { useAuth } from '../composables/useAuth.js';
|
||||
import { ShieldExclamationIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
const rateLimiterInfo = ref(null);
|
||||
@@ -188,7 +188,7 @@ async function loadRateLimiterInfo(showLoading = false) {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.getRateLimiterInfo();
|
||||
const data = await adminService.getRateLimiterInfo();
|
||||
rateLimiterInfo.value = data;
|
||||
} catch (error) {
|
||||
console.error('Error cargando información del rate limiter:', error);
|
||||
@@ -169,8 +169,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import adminService from '../../application/services/AdminService.js';
|
||||
import { useAuth } from '../composables/useAuth.js';
|
||||
import { UserGroupIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
const sessionsData = ref(null);
|
||||
@@ -225,7 +225,7 @@ async function loadSessions(showLoading = false) {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.getSessions();
|
||||
const data = await adminService.getSessions();
|
||||
sessionsData.value = data;
|
||||
} catch (error) {
|
||||
console.error('Error cargando sesiones:', error);
|
||||
@@ -243,7 +243,7 @@ async function confirmDeleteSession(token) {
|
||||
}
|
||||
|
||||
try {
|
||||
await api.deleteSession(token);
|
||||
await adminService.deleteSession(token);
|
||||
// Recargar sesiones después de eliminar
|
||||
await loadSessions(false);
|
||||
} catch (error) {
|
||||
@@ -418,8 +418,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import userService from '../../application/services/UserService.js';
|
||||
import telegramService from '../../application/services/TelegramService.js';
|
||||
import { useAuth } from '../composables/useAuth.js';
|
||||
|
||||
const users = ref([]);
|
||||
const loading = ref(true);
|
||||
@@ -452,13 +453,7 @@ const telegramForm = ref({
|
||||
enable_polling: false
|
||||
});
|
||||
|
||||
const isAuthenticated = computed(() => authService.hasCredentials());
|
||||
const currentUser = computed(() => {
|
||||
return authService.getUsername() || '';
|
||||
});
|
||||
const isAdmin = computed(() => {
|
||||
return authService.isAdmin();
|
||||
});
|
||||
const { isAuthenticated, username: currentUser, isAdmin } = useAuth();
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
@@ -479,7 +474,7 @@ function formatDate(dateString) {
|
||||
async function loadUsers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await api.getUsers();
|
||||
const data = await userService.getUsers();
|
||||
users.value = data.users || [];
|
||||
} catch (error) {
|
||||
console.error('Error cargando usuarios:', error);
|
||||
@@ -518,7 +513,7 @@ async function handleCreateUser() {
|
||||
}
|
||||
|
||||
try {
|
||||
await api.createUser({
|
||||
await userService.createUser({
|
||||
username: userForm.value.username,
|
||||
password: userForm.value.password,
|
||||
});
|
||||
@@ -561,7 +556,7 @@ async function handleChangePassword() {
|
||||
}
|
||||
|
||||
try {
|
||||
await api.changePassword({
|
||||
await userService.changePassword({
|
||||
currentPassword: passwordForm.value.currentPassword,
|
||||
newPassword: passwordForm.value.newPassword,
|
||||
});
|
||||
@@ -571,6 +566,7 @@ async function handleChangePassword() {
|
||||
// Invalidar la sesión actual - el usuario deberá hacer login nuevamente
|
||||
// El backend ya invalidó todas las sesiones, así que limpiamos localmente también
|
||||
setTimeout(async () => {
|
||||
const authService = (await import('../../application/services/AuthService.js')).default;
|
||||
await authService.logout();
|
||||
closeChangePasswordModal();
|
||||
// Recargar página para forzar nuevo login
|
||||
@@ -594,7 +590,7 @@ async function handleDeleteUser() {
|
||||
|
||||
loadingAction.value = true;
|
||||
try {
|
||||
await api.deleteUser(userToDelete.value);
|
||||
await userService.deleteUser(userToDelete.value);
|
||||
userToDelete.value = null;
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
@@ -643,7 +639,7 @@ function closeTelegramModal() {
|
||||
|
||||
async function loadTelegramConfig() {
|
||||
try {
|
||||
const config = await api.getTelegramConfig();
|
||||
const config = await telegramService.getConfig();
|
||||
if (config) {
|
||||
telegramForm.value = {
|
||||
token: config.token || '',
|
||||
@@ -668,7 +664,7 @@ async function saveTelegramConfig() {
|
||||
|
||||
loadingAction.value = true;
|
||||
try {
|
||||
await api.setTelegramConfig(telegramForm.value);
|
||||
await telegramService.setConfig(telegramForm.value);
|
||||
telegramSuccess.value = 'Configuración de Telegram guardada correctamente';
|
||||
setTimeout(() => {
|
||||
closeTelegramModal();
|
||||
@@ -430,15 +430,18 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import workerService from '../../application/services/WorkerService.js';
|
||||
import telegramService from '../../application/services/TelegramService.js';
|
||||
import { useAuth } from '../composables/useAuth.js';
|
||||
import { useWebSocket } from '../composables/useWebSocket.js';
|
||||
|
||||
const workers = ref({ items: [], disabled: [], general: {} });
|
||||
const loading = ref(true);
|
||||
const showAddModal = ref(false);
|
||||
const showGeneralModal = ref(false);
|
||||
const editingWorker = ref(null);
|
||||
const currentUser = ref(authService.getUsername() || null);
|
||||
const { username: currentUser } = useAuth();
|
||||
const { handleMessage } = useWebSocket();
|
||||
|
||||
const activeWorkers = computed(() => {
|
||||
return workers.value.items?.filter(
|
||||
@@ -527,7 +530,7 @@ const threadsInfo = ref('');
|
||||
async function loadWorkers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
workers.value = await api.getWorkers();
|
||||
workers.value = await workerService.getWorkers();
|
||||
// Actualizar formulario general
|
||||
generalForm.value = {
|
||||
title_exclude_text: arrayToText(workers.value.general?.title_exclude || []),
|
||||
@@ -547,7 +550,7 @@ async function loadTelegramThreads() {
|
||||
threadsInfo.value = '';
|
||||
|
||||
try {
|
||||
const result = await api.getTelegramThreads();
|
||||
const result = await telegramService.getThreads();
|
||||
|
||||
if (result.success && result.threads && result.threads.length > 0) {
|
||||
availableThreads.value = result.threads;
|
||||
@@ -663,7 +666,7 @@ async function saveWorker() {
|
||||
updatedWorkers.items.push(workerData);
|
||||
}
|
||||
|
||||
await api.updateWorkers(updatedWorkers);
|
||||
await workerService.updateWorkers(updatedWorkers);
|
||||
await loadWorkers();
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
@@ -684,7 +687,7 @@ async function saveGeneralConfig() {
|
||||
}
|
||||
};
|
||||
|
||||
await api.updateWorkers(updatedWorkers);
|
||||
await workerService.updateWorkers(updatedWorkers);
|
||||
await loadWorkers();
|
||||
closeGeneralModal();
|
||||
} catch (error) {
|
||||
@@ -717,7 +720,7 @@ async function disableWorker(worker) {
|
||||
updatedWorkers.disabled.push(identifier);
|
||||
}
|
||||
|
||||
await api.updateWorkers(updatedWorkers);
|
||||
await workerService.updateWorkers(updatedWorkers);
|
||||
await loadWorkers();
|
||||
} catch (error) {
|
||||
console.error('Error desactivando worker:', error);
|
||||
@@ -736,7 +739,7 @@ async function enableWorker(worker) {
|
||||
disabled: [...(workers.value.disabled || [])].filter(d => d !== workerId && d !== workerName && d !== worker.worker_id)
|
||||
};
|
||||
|
||||
await api.updateWorkers(updatedWorkers);
|
||||
await workerService.updateWorkers(updatedWorkers);
|
||||
await loadWorkers();
|
||||
} catch (error) {
|
||||
console.error('Error activando worker:', error);
|
||||
@@ -757,7 +760,7 @@ async function deleteWorker(name) {
|
||||
general: workers.value.general || {}
|
||||
};
|
||||
|
||||
await api.updateWorkers(updatedWorkers);
|
||||
await workerService.updateWorkers(updatedWorkers);
|
||||
await loadWorkers();
|
||||
} catch (error) {
|
||||
console.error('Error eliminando worker:', error);
|
||||
@@ -765,13 +768,10 @@ async function deleteWorker(name) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
function handleWSMessage(data) {
|
||||
if (data.type === 'workers_updated') {
|
||||
// Solo actualizar si es para el usuario actual (o si no especifica usuario)
|
||||
if (!data.username || data.username === currentUser.value) {
|
||||
workers.value = data.data;
|
||||
// Actualizar formulario general
|
||||
generalForm.value = {
|
||||
title_exclude_text: arrayToText(workers.value.general?.title_exclude || []),
|
||||
description_exclude_text: arrayToText(workers.value.general?.description_exclude || []),
|
||||
@@ -780,25 +780,16 @@ function handleWSMessage(event) {
|
||||
}
|
||||
}
|
||||
|
||||
// Escuchar cambios de autenticación
|
||||
function handleAuthChange() {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
// Recargar workers si cambia el usuario
|
||||
if (currentUser.value) {
|
||||
loadWorkers();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadWorkers();
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
window.addEventListener('auth-login', handleAuthChange);
|
||||
const unsubscribe = handleMessage(handleWSMessage);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('ws-message', handleWSMessage);
|
||||
window.removeEventListener('auth-logout', handleAuthChange);
|
||||
window.removeEventListener('auth-login', handleAuthChange);
|
||||
// Cleanup handled in onMounted return
|
||||
});
|
||||
</script>
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
15
web/frontend/src/shared/utils/date.js
Normal file
15
web/frontend/src/shared/utils/date.js
Normal file
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user