1 Commits

Author SHA1 Message Date
Omar Sánchez Pizarro
03bc550dab refactor frontend
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
2026-01-20 23:02:09 +01:00
50 changed files with 1274 additions and 850 deletions

View File

@@ -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>

View 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();

View 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();

View 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();

View 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();

View 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();

View File

@@ -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();

View File

@@ -0,0 +1,9 @@
import statsRepository from '../../domain/repositories/StatsRepository.js';
class StatsService {
async getStats() {
return await statsRepository.getStats();
}
}
export default new StatsService();

View 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();

View 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();

View 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();

View 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`);

View 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();

View 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;
}
}
}

View 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();

View 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();

View 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,
};
}
}

View 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,
};
}
}

View 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,
};
}
}

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View File

@@ -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) {
}
});
}

View File

@@ -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) {

View File

@@ -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 = [];
}
// Escuchar eventos de WebSocket para nuevos artículos
function handleWebSocketMessage(event) {
const data = event.detail;
const { handleMessage } = useWebSocket();
// Escuchar eventos de WebSocket para nuevos artículos
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();

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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;

View File

@@ -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';

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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('');

View File

@@ -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;

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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>

View File

@@ -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;
},
};

View File

@@ -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;

View 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');
}

View File

@@ -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;
}