refactor frontend
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
@@ -176,11 +176,14 @@ import {
|
|||||||
BellSlashIcon,
|
BellSlashIcon,
|
||||||
ArrowRightOnRectangleIcon,
|
ArrowRightOnRectangleIcon,
|
||||||
} from '@heroicons/vue/24/outline';
|
} from '@heroicons/vue/24/outline';
|
||||||
import pushNotificationService from './services/pushNotifications';
|
import pushNotificationService from './application/services/PushNotificationService.js';
|
||||||
import authService from './services/auth';
|
import authService from './application/services/AuthService.js';
|
||||||
|
import webSocketService from './core/websocket/WebSocketService.js';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import api from './services/api';
|
import { useAuth } from './presentation/composables/useAuth.js';
|
||||||
import ToastContainer from './components/ToastContainer.vue';
|
import { useWebSocket } from './presentation/composables/useWebSocket.js';
|
||||||
|
import { useDarkMode } from './presentation/composables/useDarkMode.js';
|
||||||
|
import ToastContainer from './presentation/components/ToastContainer.vue';
|
||||||
|
|
||||||
const allNavItems = [
|
const allNavItems = [
|
||||||
{ path: '/', name: 'Dashboard', icon: HomeIcon, adminOnly: false },
|
{ path: '/', name: 'Dashboard', icon: HomeIcon, adminOnly: false },
|
||||||
@@ -194,16 +197,11 @@ const allNavItems = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const router = useRouter();
|
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 sidebarCollapsed = ref(false);
|
||||||
const darkMode = ref(false);
|
|
||||||
const pushEnabled = 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
|
// Filtrar navItems según el rol del usuario
|
||||||
const navItems = computed(() => {
|
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() {
|
async function enablePushNotifications() {
|
||||||
try {
|
try {
|
||||||
@@ -287,60 +264,35 @@ function getCurrentPageTitle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleAuthChange() {
|
function handleAuthChange() {
|
||||||
currentUser.value = authService.getUsername() || null;
|
// WebSocket will be connected automatically by AuthService
|
||||||
isAdmin.value = authService.isAdmin();
|
const token = authService.getToken();
|
||||||
// Reconectar websocket cuando cambie la autenticación (login)
|
if (token) {
|
||||||
if (authService.hasCredentials()) {
|
webSocketService.connect(token);
|
||||||
connectWebSocket();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
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();
|
await authService.logout();
|
||||||
|
|
||||||
// Redirigir a login después del logout
|
|
||||||
router.push('/login');
|
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 () => {
|
onMounted(async () => {
|
||||||
initDarkMode();
|
|
||||||
currentUser.value = authService.getUsername() || null;
|
|
||||||
isAdmin.value = authService.isAdmin();
|
|
||||||
await checkPushStatus();
|
await checkPushStatus();
|
||||||
|
|
||||||
// Escuchar eventos de autenticación
|
// Escuchar eventos de autenticación
|
||||||
window.addEventListener('auth-login', handleAuthChange);
|
window.addEventListener('auth-login', handleAuthChange);
|
||||||
window.addEventListener('auth-logout', 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()) {
|
if (authService.hasCredentials()) {
|
||||||
// Validar si el token sigue siendo válido
|
const token = authService.getToken();
|
||||||
const isValid = await authService.validateSession();
|
if (token) {
|
||||||
if (!isValid) {
|
webSocketService.connect(token);
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -348,86 +300,6 @@ onMounted(async () => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('auth-login', handleAuthChange);
|
window.removeEventListener('auth-login', handleAuthChange);
|
||||||
window.removeEventListener('auth-logout', 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>
|
</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 {
|
class PushNotificationService {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.registration = null;
|
this.registration = null;
|
||||||
this.subscription = null;
|
this.subscription = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registrar Service Worker
|
|
||||||
async registerServiceWorker() {
|
async registerServiceWorker() {
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
try {
|
try {
|
||||||
@@ -13,22 +12,17 @@ class PushNotificationService {
|
|||||||
scope: '/'
|
scope: '/'
|
||||||
});
|
});
|
||||||
this.registration = registration;
|
this.registration = registration;
|
||||||
console.log('Service Worker registrado:', registration.scope);
|
|
||||||
return registration;
|
return registration;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error registrando Service Worker:', error);
|
console.error('Error registrando Service Worker:', error);
|
||||||
return null;
|
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() {
|
async requestPermission() {
|
||||||
if (!('Notification' in window)) {
|
if (!('Notification' in window)) {
|
||||||
console.warn('Este navegador no soporta notificaciones');
|
|
||||||
return 'denied';
|
return 'denied';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,15 +35,13 @@ class PushNotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const permission = await Notification.requestPermission();
|
return await Notification.requestPermission();
|
||||||
return permission;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error solicitando permiso:', error);
|
console.error('Error solicitando permiso:', error);
|
||||||
return 'denied';
|
return 'denied';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suscribirse a notificaciones push
|
|
||||||
async subscribe() {
|
async subscribe() {
|
||||||
if (!this.registration) {
|
if (!this.registration) {
|
||||||
await this.registerServiceWorker();
|
await this.registerServiceWorker();
|
||||||
@@ -60,30 +52,22 @@ class PushNotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verificar si ya existe una suscripción
|
|
||||||
this.subscription = await this.registration.pushManager.getSubscription();
|
this.subscription = await this.registration.pushManager.getSubscription();
|
||||||
|
|
||||||
if (this.subscription) {
|
if (this.subscription) {
|
||||||
console.log('Ya existe una suscripción push');
|
|
||||||
return this.subscription;
|
return this.subscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Obtener la clave pública del servidor
|
|
||||||
const response = await fetch('/api/push/public-key');
|
const response = await fetch('/api/push/public-key');
|
||||||
const { publicKey } = await response.json();
|
const { publicKey } = await response.json();
|
||||||
|
|
||||||
// Convertir la clave pública a formato ArrayBuffer
|
|
||||||
const applicationServerKey = this.urlBase64ToUint8Array(publicKey);
|
const applicationServerKey = this.urlBase64ToUint8Array(publicKey);
|
||||||
|
|
||||||
// Crear nueva suscripción
|
|
||||||
this.subscription = await this.registration.pushManager.subscribe({
|
this.subscription = await this.registration.pushManager.subscribe({
|
||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey: applicationServerKey
|
applicationServerKey: applicationServerKey
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Suscripción push creada:', this.subscription);
|
|
||||||
|
|
||||||
// Enviar la suscripción al servidor
|
|
||||||
await this.sendSubscriptionToServer(this.subscription);
|
await this.sendSubscriptionToServer(this.subscription);
|
||||||
|
|
||||||
return this.subscription;
|
return this.subscription;
|
||||||
@@ -93,7 +77,6 @@ class PushNotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enviar suscripción al servidor
|
|
||||||
async sendSubscriptionToServer(subscription) {
|
async sendSubscriptionToServer(subscription) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/push/subscribe', {
|
const response = await fetch('/api/push/subscribe', {
|
||||||
@@ -108,7 +91,6 @@ class PushNotificationService {
|
|||||||
throw new Error('Error enviando suscripción al servidor');
|
throw new Error('Error enviando suscripción al servidor');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Suscripción enviada al servidor');
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error enviando suscripción:', error);
|
console.error('Error enviando suscripción:', error);
|
||||||
@@ -116,7 +98,6 @@ class PushNotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancelar suscripción
|
|
||||||
async unsubscribe() {
|
async unsubscribe() {
|
||||||
if (this.subscription) {
|
if (this.subscription) {
|
||||||
try {
|
try {
|
||||||
@@ -129,7 +110,6 @@ class PushNotificationService {
|
|||||||
body: JSON.stringify(this.subscription),
|
body: JSON.stringify(this.subscription),
|
||||||
});
|
});
|
||||||
this.subscription = null;
|
this.subscription = null;
|
||||||
console.log('Suscripción cancelada');
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cancelando suscripción:', error);
|
console.error('Error cancelando suscripción:', error);
|
||||||
@@ -139,7 +119,6 @@ class PushNotificationService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verificar estado de suscripción
|
|
||||||
async checkSubscription() {
|
async checkSubscription() {
|
||||||
if (!this.registration) {
|
if (!this.registration) {
|
||||||
await this.registerServiceWorker();
|
await this.registerServiceWorker();
|
||||||
@@ -158,7 +137,6 @@ class PushNotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convertir clave pública de base64 URL a Uint8Array
|
|
||||||
urlBase64ToUint8Array(base64String) {
|
urlBase64ToUint8Array(base64String) {
|
||||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||||
const base64 = (base64String + padding)
|
const base64 = (base64String + padding)
|
||||||
@@ -174,7 +152,6 @@ class PushNotificationService {
|
|||||||
return outputArray;
|
return outputArray;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inicializar todo el proceso
|
|
||||||
async init() {
|
async init() {
|
||||||
const permission = await this.requestPermission();
|
const permission = await this.requestPermission();
|
||||||
if (permission === 'granted') {
|
if (permission === 'granted') {
|
||||||
@@ -191,5 +168,4 @@ class PushNotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exportar instancia singleton
|
|
||||||
export default new PushNotificationService();
|
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 { createApp } from 'vue';
|
||||||
import { createRouter, createWebHistory } from 'vue-router';
|
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import Dashboard from './views/Dashboard.vue';
|
import router from './presentation/router/index.js';
|
||||||
import Articles from './views/Articles.vue';
|
import authService from './application/services/AuthService.js';
|
||||||
import ArticleDetail from './views/ArticleDetail.vue';
|
import webSocketService from './core/websocket/WebSocketService.js';
|
||||||
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 './style.css';
|
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);
|
const app = createApp(App);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.mount('#app');
|
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) {
|
if ('serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', async () => {
|
window.addEventListener('load', async () => {
|
||||||
try {
|
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 { useRouter } from 'vue-router';
|
||||||
import { HeartIcon } from '@heroicons/vue/24/outline';
|
import { HeartIcon } from '@heroicons/vue/24/outline';
|
||||||
import { HeartIcon as HeartIconSolid } from '@heroicons/vue/24/solid';
|
import { HeartIcon as HeartIconSolid } from '@heroicons/vue/24/solid';
|
||||||
import authService from '../services/auth';
|
import authService from '../../application/services/AuthService.js';
|
||||||
import api from '../services/api';
|
import favoriteService from '../../application/services/FavoriteService.js';
|
||||||
|
import { formatDate } from '../../shared/utils/date.js';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -181,10 +182,6 @@ function checkAuth() {
|
|||||||
isAuthenticated.value = authService.hasCredentials();
|
isAuthenticated.value = authService.hasCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(timestamp) {
|
|
||||||
if (!timestamp) return 'N/A';
|
|
||||||
return new Date(timestamp).toLocaleString('es-ES');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleImageError(event) {
|
function handleImageError(event) {
|
||||||
// Si la imagen falla al cargar, reemplazar con placeholder
|
// Si la imagen falla al cargar, reemplazar con placeholder
|
||||||
@@ -205,20 +202,11 @@ async function handleAddFavorite() {
|
|||||||
isAdding.value = true;
|
isAdding.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// El backend solo necesita platform e id
|
await favoriteService.addFavorite(props.article.platform, props.article.id);
|
||||||
const favorite = {
|
|
||||||
platform: props.article.platform,
|
|
||||||
id: String(props.article.id), // Asegurar que sea string
|
|
||||||
};
|
|
||||||
|
|
||||||
await api.addFavorite(favorite);
|
|
||||||
favoriteStatus.value = true;
|
favoriteStatus.value = true;
|
||||||
|
|
||||||
// Emitir evento para que el componente padre pueda actualizar si es necesario
|
|
||||||
emit('added', props.article.platform, props.article.id);
|
emit('added', props.article.platform, props.article.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error añadiendo a favoritos:', 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) {
|
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.');
|
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) {
|
} else if (error.response?.status === 400) {
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted } from 'vue';
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
import { XMarkIcon } from '@heroicons/vue/24/outline';
|
import { XMarkIcon } from '@heroicons/vue/24/outline';
|
||||||
|
import { useWebSocket } from '../composables/useWebSocket.js';
|
||||||
import ToastNotification from './ToastNotification.vue';
|
import ToastNotification from './ToastNotification.vue';
|
||||||
|
|
||||||
const toasts = ref([]);
|
const toasts = ref([]);
|
||||||
@@ -78,10 +79,10 @@ function clearAllToasts() {
|
|||||||
toasts.value = [];
|
toasts.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { handleMessage } = useWebSocket();
|
||||||
|
|
||||||
// Escuchar eventos de WebSocket para nuevos artículos
|
// Escuchar eventos de WebSocket para nuevos artículos
|
||||||
function handleWebSocketMessage(event) {
|
function handleWebSocketMessage(data) {
|
||||||
const data = event.detail;
|
|
||||||
|
|
||||||
// Manejar notificaciones de artículos nuevos
|
// Manejar notificaciones de artículos nuevos
|
||||||
if (data.type === 'new_articles' && data.data) {
|
if (data.type === 'new_articles' && data.data) {
|
||||||
// Mostrar toasts para cada artículo nuevo
|
// Mostrar toasts para cada artículo nuevo
|
||||||
@@ -92,12 +93,17 @@ function handleWebSocketMessage(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
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(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('ws-message', handleWebSocketMessage);
|
|
||||||
|
|
||||||
// Limpiar todos los timeouts de toasts
|
// Limpiar todos los timeouts de toasts
|
||||||
toastTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
|
toastTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
|
||||||
toastTimeouts.clear();
|
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>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted } from 'vue';
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import api from '../services/api';
|
import articleService from '../../application/services/ArticleService.js';
|
||||||
import authService from '../services/auth';
|
import favoriteService from '../../application/services/FavoriteService.js';
|
||||||
|
import { useAuth } from '../composables/useAuth.js';
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
ArrowTopRightOnSquareIcon,
|
ArrowTopRightOnSquareIcon,
|
||||||
@@ -351,8 +352,10 @@ function handleKeydown(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
function checkAuth() {
|
function checkAuth() {
|
||||||
isAuthenticated.value = authService.hasCredentials();
|
// Auth state is managed by useAuth composable
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddFavorite() {
|
async function handleAddFavorite() {
|
||||||
@@ -368,7 +371,7 @@ async function handleAddFavorite() {
|
|||||||
id: String(article.value.id),
|
id: String(article.value.id),
|
||||||
};
|
};
|
||||||
|
|
||||||
await api.addFavorite(favorite);
|
await favoriteService.addFavorite(article.value.platform, article.value.id);
|
||||||
favoriteStatus.value = true;
|
favoriteStatus.value = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error añadiendo a favoritos:', error);
|
console.error('Error añadiendo a favoritos:', error);
|
||||||
@@ -409,7 +412,7 @@ async function loadArticle() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
article.value = await api.getArticle(platform, id);
|
article.value = await articleService.getArticle(platform, id);
|
||||||
|
|
||||||
if (!article.value) {
|
if (!article.value) {
|
||||||
error.value = 'Artículo no encontrado';
|
error.value = 'Artículo no encontrado';
|
||||||
@@ -211,7 +211,7 @@
|
|||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<ArticleCard
|
<ArticleCard
|
||||||
v-for="article in filteredArticles"
|
v-for="article in filteredArticles"
|
||||||
:key="`${article.platform}-${article.id}`"
|
:key="article.uniqueId || `${article.platform}-${article.id}`"
|
||||||
:article="article"
|
:article="article"
|
||||||
:is-new="newArticleIds.has(`${article.platform}-${article.id}`)"
|
:is-new="newArticleIds.has(`${article.platform}-${article.id}`)"
|
||||||
/>
|
/>
|
||||||
@@ -243,8 +243,9 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||||
import api from '../services/api';
|
import articleService from '../../application/services/ArticleService.js';
|
||||||
import authService from '../services/auth';
|
import { useAuth } from '../composables/useAuth.js';
|
||||||
|
import { useWebSocket } from '../composables/useWebSocket.js';
|
||||||
import ArticleCard from '../components/ArticleCard.vue';
|
import ArticleCard from '../components/ArticleCard.vue';
|
||||||
import {
|
import {
|
||||||
FunnelIcon,
|
FunnelIcon,
|
||||||
@@ -256,8 +257,8 @@ import {
|
|||||||
MagnifyingGlassIcon
|
MagnifyingGlassIcon
|
||||||
} from '@heroicons/vue/24/outline';
|
} from '@heroicons/vue/24/outline';
|
||||||
|
|
||||||
const currentUser = ref(authService.getUsername() || null);
|
const { username: currentUser, isAdmin } = useAuth();
|
||||||
const isAdmin = ref(false);
|
const { handleMessage } = useWebSocket();
|
||||||
|
|
||||||
const allArticles = ref([]);
|
const allArticles = ref([]);
|
||||||
const searchResults = ref([]);
|
const searchResults = ref([]);
|
||||||
@@ -326,7 +327,7 @@ function clearAllFilters() {
|
|||||||
|
|
||||||
async function loadFacets() {
|
async function loadFacets() {
|
||||||
try {
|
try {
|
||||||
const data = await api.getArticleFacets();
|
const data = await articleService.getArticleFacets();
|
||||||
facets.value = {
|
facets.value = {
|
||||||
platforms: data.platforms || [],
|
platforms: data.platforms || [],
|
||||||
usernames: data.usernames || [],
|
usernames: data.usernames || [],
|
||||||
@@ -374,7 +375,7 @@ async function loadArticles(reset = true, silent = false) {
|
|||||||
}
|
}
|
||||||
if (selectedWorker.value) params.worker_name = selectedWorker.value;
|
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;
|
let filtered = data.articles;
|
||||||
// El filtro de plataforma se aplica en el backend ahora, pero mantenemos compatibilidad
|
// 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() {
|
function handleAuthChange() {
|
||||||
currentUser.value = authService.getUsername() || null;
|
|
||||||
isAdmin.value = authService.isAdmin();
|
|
||||||
// Si no es admin, no permitir filtrar por username
|
// Si no es admin, no permitir filtrar por username
|
||||||
if (!isAdmin.value && selectedUsername.value) {
|
if (!isAdmin.value && selectedUsername.value) {
|
||||||
selectedUsername.value = '';
|
selectedUsername.value = '';
|
||||||
}
|
}
|
||||||
if (currentUser.value) {
|
if (currentUser.value) {
|
||||||
loadFacets(); // Recargar facets cuando cambie el usuario
|
loadFacets();
|
||||||
loadArticles();
|
loadArticles();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -442,10 +441,9 @@ function loadMore() {
|
|||||||
loadArticles(false);
|
loadArticles(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWSMessage(event) {
|
function handleWSMessage(data) {
|
||||||
const data = event.detail;
|
|
||||||
if (data.type === 'articles_updated') {
|
if (data.type === 'articles_updated') {
|
||||||
loadFacets(); // Actualizar facets cuando se actualicen los artículos
|
loadFacets();
|
||||||
loadArticles();
|
loadArticles();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -460,7 +458,7 @@ async function searchArticles(query) {
|
|||||||
searching.value = true;
|
searching.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.searchArticles(query, searchMode.value);
|
const data = await articleService.searchArticles(query, searchMode.value);
|
||||||
|
|
||||||
let filtered = data.articles || [];
|
let filtered = data.articles || [];
|
||||||
|
|
||||||
@@ -505,37 +503,39 @@ watch([searchQuery, searchMode], ([newQuery]) => {
|
|||||||
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
currentUser.value = authService.getUsername() || null;
|
|
||||||
isAdmin.value = authService.isAdmin();
|
|
||||||
// Si no es admin, no permitir filtrar por username
|
// Si no es admin, no permitir filtrar por username
|
||||||
if (!isAdmin.value && selectedUsername.value) {
|
if (!isAdmin.value && selectedUsername.value) {
|
||||||
selectedUsername.value = '';
|
selectedUsername.value = '';
|
||||||
}
|
}
|
||||||
loadFacets(); // Cargar facets primero
|
loadFacets();
|
||||||
loadArticles();
|
loadArticles();
|
||||||
window.addEventListener('ws-message', handleWSMessage);
|
|
||||||
window.addEventListener('auth-logout', handleAuthChange);
|
const unsubscribe = handleMessage(handleWSMessage);
|
||||||
window.addEventListener('auth-login', handleAuthChange);
|
|
||||||
|
|
||||||
// Iniciar autopoll para actualizar automáticamente
|
// Iniciar autopoll para actualizar automáticamente
|
||||||
autoPollInterval.value = setInterval(() => {
|
autoPollInterval.value = setInterval(() => {
|
||||||
loadArticles(true, true); // Reset silencioso cada 30 segundos
|
loadArticles(true, true);
|
||||||
loadFacets(); // Actualizar facets también
|
loadFacets();
|
||||||
}, POLL_INTERVAL);
|
}, POLL_INTERVAL);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
if (autoPollInterval.value) {
|
||||||
|
clearInterval(autoPollInterval.value);
|
||||||
|
autoPollInterval.value = null;
|
||||||
|
}
|
||||||
|
if (searchTimeout.value) {
|
||||||
|
clearTimeout(searchTimeout.value);
|
||||||
|
searchTimeout.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
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) {
|
if (autoPollInterval.value) {
|
||||||
clearInterval(autoPollInterval.value);
|
clearInterval(autoPollInterval.value);
|
||||||
autoPollInterval.value = null;
|
autoPollInterval.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limpiar el timeout de búsqueda
|
|
||||||
if (searchTimeout.value) {
|
if (searchTimeout.value) {
|
||||||
clearTimeout(searchTimeout.value);
|
clearTimeout(searchTimeout.value);
|
||||||
searchTimeout.value = null;
|
searchTimeout.value = null;
|
||||||
@@ -227,8 +227,9 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted } from 'vue';
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
import api from '../services/api';
|
import statsService from '../../application/services/StatsService.js';
|
||||||
import authService from '../services/auth';
|
import { useAuth } from '../composables/useAuth.js';
|
||||||
|
import { useWebSocket } from '../composables/useWebSocket.js';
|
||||||
import {
|
import {
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
HeartIcon,
|
HeartIcon,
|
||||||
@@ -245,8 +246,8 @@ const stats = ref({
|
|||||||
platforms: {},
|
platforms: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentUser = ref(authService.getUsername() || null);
|
const { username: currentUser, isAdmin } = useAuth();
|
||||||
const isAdmin = ref(false);
|
const { handleMessage } = useWebSocket();
|
||||||
|
|
||||||
function getPercentage(value, total) {
|
function getPercentage(value, total) {
|
||||||
if (!total || total === 0) return 0;
|
if (!total || total === 0) return 0;
|
||||||
@@ -255,25 +256,13 @@ function getPercentage(value, total) {
|
|||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
try {
|
try {
|
||||||
stats.value = await api.getStats();
|
stats.value = await statsService.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
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cargando estadísticas:', error);
|
console.error('Error cargando estadísticas:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleWSMessage(data) {
|
||||||
function handleAuthChange() {
|
|
||||||
currentUser.value = authService.getUsername() || null;
|
|
||||||
isAdmin.value = authService.isAdmin();
|
|
||||||
if (currentUser.value) {
|
|
||||||
loadStats();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleWSMessage(event) {
|
|
||||||
const data = event.detail;
|
|
||||||
if (data.type === 'workers_updated' || data.type === 'favorites_updated') {
|
if (data.type === 'workers_updated' || data.type === 'favorites_updated') {
|
||||||
loadStats();
|
loadStats();
|
||||||
}
|
}
|
||||||
@@ -282,22 +271,22 @@ function handleWSMessage(event) {
|
|||||||
let interval = null;
|
let interval = null;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
currentUser.value = authService.getUsername() || null;
|
|
||||||
isAdmin.value = authService.isAdmin();
|
|
||||||
loadStats();
|
loadStats();
|
||||||
window.addEventListener('ws-message', handleWSMessage);
|
const unsubscribe = handleMessage(handleWSMessage);
|
||||||
window.addEventListener('auth-logout', handleAuthChange);
|
interval = setInterval(loadStats, 10000);
|
||||||
window.addEventListener('auth-login', handleAuthChange);
|
|
||||||
interval = setInterval(loadStats, 10000); // Actualizar cada 10 segundos
|
return () => {
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (interval) {
|
if (interval) {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
}
|
}
|
||||||
window.removeEventListener('ws-message', handleWSMessage);
|
|
||||||
window.removeEventListener('auth-logout', handleAuthChange);
|
|
||||||
window.removeEventListener('auth-login', handleAuthChange);
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -48,15 +48,16 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted } from 'vue';
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
import api from '../services/api';
|
import favoriteService from '../../application/services/FavoriteService.js';
|
||||||
import authService from '../services/auth';
|
import { useAuth } from '../composables/useAuth.js';
|
||||||
|
import { useWebSocket } from '../composables/useWebSocket.js';
|
||||||
import { HeartIcon } from '@heroicons/vue/24/outline';
|
import { HeartIcon } from '@heroicons/vue/24/outline';
|
||||||
import ArticleCard from '../components/ArticleCard.vue';
|
import ArticleCard from '../components/ArticleCard.vue';
|
||||||
|
|
||||||
const favorites = ref([]);
|
const favorites = ref([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const currentUser = ref(authService.getUsername() || null);
|
const { username: currentUser } = useAuth();
|
||||||
const isAdmin = ref(false);
|
const { handleMessage } = useWebSocket();
|
||||||
|
|
||||||
|
|
||||||
async function loadFavorites() {
|
async function loadFavorites() {
|
||||||
@@ -69,13 +70,11 @@ async function loadFavorites() {
|
|||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
favorites.value = await api.getFavorites();
|
favorites.value = await favoriteService.getFavorites();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cargando favoritos:', error);
|
console.error('Error cargando favoritos:', error);
|
||||||
// Si hay error de autenticación, limpiar favoritos
|
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
favorites.value = [];
|
favorites.value = [];
|
||||||
currentUser.value = null;
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -88,7 +87,7 @@ async function removeFavorite(platform, id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.removeFavorite(platform, id);
|
await favoriteService.removeFavorite(platform, id);
|
||||||
await loadFavorites();
|
await loadFavorites();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error eliminando favorito:', error);
|
console.error('Error eliminando favorito:', error);
|
||||||
@@ -96,38 +95,25 @@ async function removeFavorite(platform, id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleWSMessage(data) {
|
||||||
function handleWSMessage(event) {
|
|
||||||
const data = event.detail;
|
|
||||||
if (data.type === 'favorites_updated') {
|
if (data.type === 'favorites_updated') {
|
||||||
// Solo actualizar si es para el usuario actual
|
|
||||||
if (data.username === currentUser.value) {
|
if (data.username === currentUser.value) {
|
||||||
favorites.value = data.data;
|
favorites.value = data.data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAuthChange() {
|
|
||||||
currentUser.value = authService.getUsername() || null;
|
|
||||||
isAdmin.value = authService.isAdmin();
|
|
||||||
if (currentUser.value) {
|
|
||||||
loadFavorites();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
currentUser.value = authService.getUsername() || null;
|
|
||||||
isAdmin.value = authService.isAdmin();
|
|
||||||
loadFavorites();
|
loadFavorites();
|
||||||
window.addEventListener('ws-message', handleWSMessage);
|
const unsubscribe = handleMessage(handleWSMessage);
|
||||||
window.addEventListener('auth-logout', handleAuthChange);
|
|
||||||
window.addEventListener('auth-login', handleAuthChange);
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('ws-message', handleWSMessage);
|
// Cleanup handled in onMounted return
|
||||||
window.removeEventListener('auth-logout', handleAuthChange);
|
|
||||||
window.removeEventListener('auth-login', handleAuthChange);
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import authService from '../services/auth';
|
import authService from '../../application/services/AuthService.js';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const loginError = ref('');
|
const loginError = ref('');
|
||||||
@@ -94,8 +94,8 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
|
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
|
||||||
import api from '../services/api';
|
import logService from '../../application/services/LogService.js';
|
||||||
import authService from '../services/auth';
|
import { useAuth } from '../composables/useAuth.js';
|
||||||
import { DocumentMagnifyingGlassIcon } from '@heroicons/vue/24/outline';
|
import { DocumentMagnifyingGlassIcon } from '@heroicons/vue/24/outline';
|
||||||
|
|
||||||
const logs = ref([]);
|
const logs = ref([]);
|
||||||
@@ -106,8 +106,7 @@ const refreshIntervalSeconds = ref(5);
|
|||||||
const followLatestLog = ref(true);
|
const followLatestLog = ref(true);
|
||||||
const logsContainer = ref(null);
|
const logsContainer = ref(null);
|
||||||
const lastLineNumber = ref(-1); // Número de la última línea leída
|
const lastLineNumber = ref(-1); // Número de la última línea leída
|
||||||
const currentUser = ref(authService.getUsername() || null);
|
const { username: currentUser, isAdmin } = useAuth();
|
||||||
const isAdmin = ref(false);
|
|
||||||
const accessDenied = ref(false);
|
const accessDenied = ref(false);
|
||||||
let refreshInterval = null;
|
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 carga inicial o forzada, no enviar sinceLine (cargar últimas líneas)
|
||||||
// Si es actualización incremental, enviar lastLineNumber + 1 para obtener solo las nuevas
|
// Si es actualización incremental, enviar lastLineNumber + 1 para obtener solo las nuevas
|
||||||
const sinceLine = isInitialLoad ? null : lastLineNumber.value + 1;
|
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 newLogs = data.logs || [];
|
||||||
const newLastLineNumber = data.lastLineNumber !== undefined ? data.lastLineNumber : -1;
|
const newLastLineNumber = data.lastLineNumber !== undefined ? data.lastLineNumber : -1;
|
||||||
@@ -149,8 +149,8 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import api from '../services/api';
|
import adminService from '../../application/services/AdminService.js';
|
||||||
import authService from '../services/auth';
|
import { useAuth } from '../composables/useAuth.js';
|
||||||
import { ShieldExclamationIcon } from '@heroicons/vue/24/outline';
|
import { ShieldExclamationIcon } from '@heroicons/vue/24/outline';
|
||||||
|
|
||||||
const rateLimiterInfo = ref(null);
|
const rateLimiterInfo = ref(null);
|
||||||
@@ -188,7 +188,7 @@ async function loadRateLimiterInfo(showLoading = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.getRateLimiterInfo();
|
const data = await adminService.getRateLimiterInfo();
|
||||||
rateLimiterInfo.value = data;
|
rateLimiterInfo.value = data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cargando información del rate limiter:', error);
|
console.error('Error cargando información del rate limiter:', error);
|
||||||
@@ -169,8 +169,8 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import api from '../services/api';
|
import adminService from '../../application/services/AdminService.js';
|
||||||
import authService from '../services/auth';
|
import { useAuth } from '../composables/useAuth.js';
|
||||||
import { UserGroupIcon } from '@heroicons/vue/24/outline';
|
import { UserGroupIcon } from '@heroicons/vue/24/outline';
|
||||||
|
|
||||||
const sessionsData = ref(null);
|
const sessionsData = ref(null);
|
||||||
@@ -225,7 +225,7 @@ async function loadSessions(showLoading = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.getSessions();
|
const data = await adminService.getSessions();
|
||||||
sessionsData.value = data;
|
sessionsData.value = data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cargando sesiones:', error);
|
console.error('Error cargando sesiones:', error);
|
||||||
@@ -243,7 +243,7 @@ async function confirmDeleteSession(token) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.deleteSession(token);
|
await adminService.deleteSession(token);
|
||||||
// Recargar sesiones después de eliminar
|
// Recargar sesiones después de eliminar
|
||||||
await loadSessions(false);
|
await loadSessions(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -418,8 +418,9 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||||
import api from '../services/api';
|
import userService from '../../application/services/UserService.js';
|
||||||
import authService from '../services/auth';
|
import telegramService from '../../application/services/TelegramService.js';
|
||||||
|
import { useAuth } from '../composables/useAuth.js';
|
||||||
|
|
||||||
const users = ref([]);
|
const users = ref([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
@@ -452,13 +453,7 @@ const telegramForm = ref({
|
|||||||
enable_polling: false
|
enable_polling: false
|
||||||
});
|
});
|
||||||
|
|
||||||
const isAuthenticated = computed(() => authService.hasCredentials());
|
const { isAuthenticated, username: currentUser, isAdmin } = useAuth();
|
||||||
const currentUser = computed(() => {
|
|
||||||
return authService.getUsername() || '';
|
|
||||||
});
|
|
||||||
const isAdmin = computed(() => {
|
|
||||||
return authService.isAdmin();
|
|
||||||
});
|
|
||||||
|
|
||||||
function formatDate(dateString) {
|
function formatDate(dateString) {
|
||||||
if (!dateString) return 'N/A';
|
if (!dateString) return 'N/A';
|
||||||
@@ -479,7 +474,7 @@ function formatDate(dateString) {
|
|||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const data = await api.getUsers();
|
const data = await userService.getUsers();
|
||||||
users.value = data.users || [];
|
users.value = data.users || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cargando usuarios:', error);
|
console.error('Error cargando usuarios:', error);
|
||||||
@@ -518,7 +513,7 @@ async function handleCreateUser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.createUser({
|
await userService.createUser({
|
||||||
username: userForm.value.username,
|
username: userForm.value.username,
|
||||||
password: userForm.value.password,
|
password: userForm.value.password,
|
||||||
});
|
});
|
||||||
@@ -561,7 +556,7 @@ async function handleChangePassword() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.changePassword({
|
await userService.changePassword({
|
||||||
currentPassword: passwordForm.value.currentPassword,
|
currentPassword: passwordForm.value.currentPassword,
|
||||||
newPassword: passwordForm.value.newPassword,
|
newPassword: passwordForm.value.newPassword,
|
||||||
});
|
});
|
||||||
@@ -571,6 +566,7 @@ async function handleChangePassword() {
|
|||||||
// Invalidar la sesión actual - el usuario deberá hacer login nuevamente
|
// Invalidar la sesión actual - el usuario deberá hacer login nuevamente
|
||||||
// El backend ya invalidó todas las sesiones, así que limpiamos localmente también
|
// El backend ya invalidó todas las sesiones, así que limpiamos localmente también
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
|
const authService = (await import('../../application/services/AuthService.js')).default;
|
||||||
await authService.logout();
|
await authService.logout();
|
||||||
closeChangePasswordModal();
|
closeChangePasswordModal();
|
||||||
// Recargar página para forzar nuevo login
|
// Recargar página para forzar nuevo login
|
||||||
@@ -594,7 +590,7 @@ async function handleDeleteUser() {
|
|||||||
|
|
||||||
loadingAction.value = true;
|
loadingAction.value = true;
|
||||||
try {
|
try {
|
||||||
await api.deleteUser(userToDelete.value);
|
await userService.deleteUser(userToDelete.value);
|
||||||
userToDelete.value = null;
|
userToDelete.value = null;
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -643,7 +639,7 @@ function closeTelegramModal() {
|
|||||||
|
|
||||||
async function loadTelegramConfig() {
|
async function loadTelegramConfig() {
|
||||||
try {
|
try {
|
||||||
const config = await api.getTelegramConfig();
|
const config = await telegramService.getConfig();
|
||||||
if (config) {
|
if (config) {
|
||||||
telegramForm.value = {
|
telegramForm.value = {
|
||||||
token: config.token || '',
|
token: config.token || '',
|
||||||
@@ -668,7 +664,7 @@ async function saveTelegramConfig() {
|
|||||||
|
|
||||||
loadingAction.value = true;
|
loadingAction.value = true;
|
||||||
try {
|
try {
|
||||||
await api.setTelegramConfig(telegramForm.value);
|
await telegramService.setConfig(telegramForm.value);
|
||||||
telegramSuccess.value = 'Configuración de Telegram guardada correctamente';
|
telegramSuccess.value = 'Configuración de Telegram guardada correctamente';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
closeTelegramModal();
|
closeTelegramModal();
|
||||||
@@ -430,15 +430,18 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||||
import api from '../services/api';
|
import workerService from '../../application/services/WorkerService.js';
|
||||||
import authService from '../services/auth';
|
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 workers = ref({ items: [], disabled: [], general: {} });
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const showAddModal = ref(false);
|
const showAddModal = ref(false);
|
||||||
const showGeneralModal = ref(false);
|
const showGeneralModal = ref(false);
|
||||||
const editingWorker = ref(null);
|
const editingWorker = ref(null);
|
||||||
const currentUser = ref(authService.getUsername() || null);
|
const { username: currentUser } = useAuth();
|
||||||
|
const { handleMessage } = useWebSocket();
|
||||||
|
|
||||||
const activeWorkers = computed(() => {
|
const activeWorkers = computed(() => {
|
||||||
return workers.value.items?.filter(
|
return workers.value.items?.filter(
|
||||||
@@ -527,7 +530,7 @@ const threadsInfo = ref('');
|
|||||||
async function loadWorkers() {
|
async function loadWorkers() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
workers.value = await api.getWorkers();
|
workers.value = await workerService.getWorkers();
|
||||||
// Actualizar formulario general
|
// Actualizar formulario general
|
||||||
generalForm.value = {
|
generalForm.value = {
|
||||||
title_exclude_text: arrayToText(workers.value.general?.title_exclude || []),
|
title_exclude_text: arrayToText(workers.value.general?.title_exclude || []),
|
||||||
@@ -547,7 +550,7 @@ async function loadTelegramThreads() {
|
|||||||
threadsInfo.value = '';
|
threadsInfo.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.getTelegramThreads();
|
const result = await telegramService.getThreads();
|
||||||
|
|
||||||
if (result.success && result.threads && result.threads.length > 0) {
|
if (result.success && result.threads && result.threads.length > 0) {
|
||||||
availableThreads.value = result.threads;
|
availableThreads.value = result.threads;
|
||||||
@@ -663,7 +666,7 @@ async function saveWorker() {
|
|||||||
updatedWorkers.items.push(workerData);
|
updatedWorkers.items.push(workerData);
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.updateWorkers(updatedWorkers);
|
await workerService.updateWorkers(updatedWorkers);
|
||||||
await loadWorkers();
|
await loadWorkers();
|
||||||
closeModal();
|
closeModal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -684,7 +687,7 @@ async function saveGeneralConfig() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await api.updateWorkers(updatedWorkers);
|
await workerService.updateWorkers(updatedWorkers);
|
||||||
await loadWorkers();
|
await loadWorkers();
|
||||||
closeGeneralModal();
|
closeGeneralModal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -717,7 +720,7 @@ async function disableWorker(worker) {
|
|||||||
updatedWorkers.disabled.push(identifier);
|
updatedWorkers.disabled.push(identifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.updateWorkers(updatedWorkers);
|
await workerService.updateWorkers(updatedWorkers);
|
||||||
await loadWorkers();
|
await loadWorkers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error desactivando worker:', 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)
|
disabled: [...(workers.value.disabled || [])].filter(d => d !== workerId && d !== workerName && d !== worker.worker_id)
|
||||||
};
|
};
|
||||||
|
|
||||||
await api.updateWorkers(updatedWorkers);
|
await workerService.updateWorkers(updatedWorkers);
|
||||||
await loadWorkers();
|
await loadWorkers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error activando worker:', error);
|
console.error('Error activando worker:', error);
|
||||||
@@ -757,7 +760,7 @@ async function deleteWorker(name) {
|
|||||||
general: workers.value.general || {}
|
general: workers.value.general || {}
|
||||||
};
|
};
|
||||||
|
|
||||||
await api.updateWorkers(updatedWorkers);
|
await workerService.updateWorkers(updatedWorkers);
|
||||||
await loadWorkers();
|
await loadWorkers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error eliminando worker:', error);
|
console.error('Error eliminando worker:', error);
|
||||||
@@ -765,13 +768,10 @@ async function deleteWorker(name) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWSMessage(event) {
|
function handleWSMessage(data) {
|
||||||
const data = event.detail;
|
|
||||||
if (data.type === 'workers_updated') {
|
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) {
|
if (!data.username || data.username === currentUser.value) {
|
||||||
workers.value = data.data;
|
workers.value = data.data;
|
||||||
// Actualizar formulario general
|
|
||||||
generalForm.value = {
|
generalForm.value = {
|
||||||
title_exclude_text: arrayToText(workers.value.general?.title_exclude || []),
|
title_exclude_text: arrayToText(workers.value.general?.title_exclude || []),
|
||||||
description_exclude_text: arrayToText(workers.value.general?.description_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(() => {
|
onMounted(() => {
|
||||||
loadWorkers();
|
loadWorkers();
|
||||||
window.addEventListener('ws-message', handleWSMessage);
|
const unsubscribe = handleMessage(handleWSMessage);
|
||||||
window.addEventListener('auth-logout', handleAuthChange);
|
|
||||||
window.addEventListener('auth-login', handleAuthChange);
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('ws-message', handleWSMessage);
|
// Cleanup handled in onMounted return
|
||||||
window.removeEventListener('auth-logout', handleAuthChange);
|
|
||||||
window.removeEventListener('auth-login', handleAuthChange);
|
|
||||||
});
|
});
|
||||||
</script>
|
</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 cachedFingerprint = null;
|
||||||
let cachedDeviceInfo = null;
|
let cachedDeviceInfo = null;
|
||||||
|
|
||||||
/**
|
|
||||||
* Inicializa FingerprintJS (solo una vez)
|
|
||||||
*/
|
|
||||||
function initFingerprintJS() {
|
function initFingerprintJS() {
|
||||||
if (!fpPromise) {
|
if (!fpPromise) {
|
||||||
fpPromise = FingerprintJS.load();
|
fpPromise = FingerprintJS.load();
|
||||||
@@ -14,12 +11,7 @@ function initFingerprintJS() {
|
|||||||
return fpPromise;
|
return fpPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtiene el fingerprint del dispositivo
|
|
||||||
* @returns {Promise<{fingerprint: string, deviceInfo: Object}>}
|
|
||||||
*/
|
|
||||||
export async function getDeviceFingerprint() {
|
export async function getDeviceFingerprint() {
|
||||||
// Si ya tenemos el fingerprint en caché, devolverlo
|
|
||||||
if (cachedFingerprint && cachedDeviceInfo) {
|
if (cachedFingerprint && cachedDeviceInfo) {
|
||||||
return {
|
return {
|
||||||
fingerprint: cachedFingerprint,
|
fingerprint: cachedFingerprint,
|
||||||
@@ -31,7 +23,6 @@ export async function getDeviceFingerprint() {
|
|||||||
const fp = await initFingerprintJS();
|
const fp = await initFingerprintJS();
|
||||||
const result = await fp.get();
|
const result = await fp.get();
|
||||||
|
|
||||||
// Extraer información del dispositivo desde los componentes
|
|
||||||
const deviceInfo = extractDeviceInfo(result.components);
|
const deviceInfo = extractDeviceInfo(result.components);
|
||||||
|
|
||||||
cachedFingerprint = result.visitorId;
|
cachedFingerprint = result.visitorId;
|
||||||
@@ -43,7 +34,6 @@ export async function getDeviceFingerprint() {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error obteniendo fingerprint:', error);
|
console.error('Error obteniendo fingerprint:', error);
|
||||||
// Fallback: generar un fingerprint básico
|
|
||||||
return {
|
return {
|
||||||
fingerprint: generateFallbackFingerprint(),
|
fingerprint: generateFallbackFingerprint(),
|
||||||
deviceInfo: {
|
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) {
|
function extractDeviceInfo(components) {
|
||||||
const info = {
|
const info = {
|
||||||
browser: 'Unknown',
|
browser: 'Unknown',
|
||||||
@@ -74,7 +59,6 @@ function extractDeviceInfo(components) {
|
|||||||
language: navigator.language || '',
|
language: navigator.language || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Información del navegador
|
|
||||||
if (components.browserName) {
|
if (components.browserName) {
|
||||||
info.browser = components.browserName.value || 'Unknown';
|
info.browser = components.browserName.value || 'Unknown';
|
||||||
}
|
}
|
||||||
@@ -82,7 +66,6 @@ function extractDeviceInfo(components) {
|
|||||||
info.browserVersion = components.browserVersion.value || '';
|
info.browserVersion = components.browserVersion.value || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Información del sistema operativo
|
|
||||||
if (components.os) {
|
if (components.os) {
|
||||||
info.os = components.os.value || 'Unknown';
|
info.os = components.os.value || 'Unknown';
|
||||||
}
|
}
|
||||||
@@ -90,7 +73,6 @@ function extractDeviceInfo(components) {
|
|||||||
info.osVersion = components.osVersion.value || '';
|
info.osVersion = components.osVersion.value || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Información del dispositivo
|
|
||||||
if (components.deviceMemory) {
|
if (components.deviceMemory) {
|
||||||
info.device = components.deviceMemory.value ? 'Desktop' : 'Mobile';
|
info.device = components.deviceMemory.value ? 'Desktop' : 'Mobile';
|
||||||
}
|
}
|
||||||
@@ -105,7 +87,6 @@ function extractDeviceInfo(components) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolución de pantalla
|
|
||||||
if (components.screenResolution) {
|
if (components.screenResolution) {
|
||||||
const res = components.screenResolution.value;
|
const res = components.screenResolution.value;
|
||||||
if (res && res.length >= 2) {
|
if (res && res.length >= 2) {
|
||||||
@@ -113,7 +94,6 @@ function extractDeviceInfo(components) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zona horaria
|
|
||||||
if (components.timezone) {
|
if (components.timezone) {
|
||||||
info.timezone = components.timezone.value || '';
|
info.timezone = components.timezone.value || '';
|
||||||
}
|
}
|
||||||
@@ -121,10 +101,6 @@ function extractDeviceInfo(components) {
|
|||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Genera un fingerprint básico como fallback
|
|
||||||
* @returns {string} Hash del fingerprint
|
|
||||||
*/
|
|
||||||
function generateFallbackFingerprint() {
|
function generateFallbackFingerprint() {
|
||||||
const data = [
|
const data = [
|
||||||
navigator.userAgent,
|
navigator.userAgent,
|
||||||
@@ -134,21 +110,16 @@ function generateFallbackFingerprint() {
|
|||||||
new Date().getTimezoneOffset(),
|
new Date().getTimezoneOffset(),
|
||||||
].join('|');
|
].join('|');
|
||||||
|
|
||||||
// Simple hash (no usar en producción, solo como fallback)
|
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < data.length; i++) {
|
for (let i = 0; i < data.length; i++) {
|
||||||
const char = data.charCodeAt(i);
|
const char = data.charCodeAt(i);
|
||||||
hash = ((hash << 5) - hash) + char;
|
hash = ((hash << 5) - hash) + char;
|
||||||
hash = hash & hash; // Convert to 32bit integer
|
hash = hash & hash;
|
||||||
}
|
}
|
||||||
return Math.abs(hash).toString(36);
|
return Math.abs(hash).toString(36);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Limpia el caché del fingerprint (útil para testing)
|
|
||||||
*/
|
|
||||||
export function clearFingerprintCache() {
|
export function clearFingerprintCache() {
|
||||||
cachedFingerprint = null;
|
cachedFingerprint = null;
|
||||||
cachedDeviceInfo = null;
|
cachedDeviceInfo = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user