refactor on components and delete clear, profesional login
This commit is contained in:
@@ -50,7 +50,7 @@ backend/
|
||||
|
||||
### Routes (`routes/`)
|
||||
Cada archivo maneja un conjunto relacionado de endpoints:
|
||||
- **index.js**: `/api/stats`, `/api/cache`
|
||||
- **index.js**: `/api/stats`
|
||||
- **workers.js**: `/api/workers` (GET, PUT)
|
||||
- **articles.js**: `/api/articles` (GET, search)
|
||||
- **favorites.js**: `/api/favorites` (GET, POST, DELETE)
|
||||
|
||||
@@ -4,15 +4,6 @@ import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.delete('/', basicAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const count = await clearAllArticles();
|
||||
res.json({ success: true, message: `Todos los artículos eliminados: ${count} artículos borrados`, count });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener artículos notificados (requiere autenticación obligatoria)
|
||||
router.get('/', basicAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import express from 'express';
|
||||
import { getFavorites, getNotifiedArticles, getDB, getWorkers, clearAllArticles } from '../services/mongodb.js';
|
||||
import { getFavorites, getNotifiedArticles, getDB, getWorkers } from '../services/mongodb.js';
|
||||
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
|
||||
import { broadcast } from '../services/websocket.js';
|
||||
@@ -92,75 +92,5 @@ router.get('/stats', basicAuthMiddleware, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Limpiar toda la caché de MongoDB (requiere autenticación de administrador)
|
||||
router.delete('/cache', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const db = getDB();
|
||||
if (!db) {
|
||||
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
||||
}
|
||||
|
||||
// Eliminar todos los artículos
|
||||
const count = await clearAllArticles();
|
||||
|
||||
// Notificar a los clientes WebSocket
|
||||
broadcast({
|
||||
type: 'cache_cleared',
|
||||
data: { count, timestamp: Date.now() }
|
||||
});
|
||||
|
||||
// También notificar actualización de artículos (ahora está vacío)
|
||||
broadcast({ type: 'articles_updated', data: [] });
|
||||
|
||||
// También actualizar favoritos (debería estar vacío ahora)
|
||||
const favorites = await getFavorites(null);
|
||||
broadcast({ type: 'favorites_updated', data: favorites, username: null });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Todos los artículos eliminados: ${count} artículos borrados`,
|
||||
count
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error limpiando cache de MongoDB:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Endpoint específico para borrar artículos (alias de /cache para claridad)
|
||||
router.delete('/articles', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const db = getDB();
|
||||
if (!db) {
|
||||
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
||||
}
|
||||
|
||||
// Eliminar todos los artículos
|
||||
const count = await clearAllArticles();
|
||||
|
||||
// Notificar a los clientes WebSocket
|
||||
broadcast({
|
||||
type: 'articles_cleared',
|
||||
data: { count, timestamp: Date.now() }
|
||||
});
|
||||
|
||||
// También notificar actualización de artículos (ahora está vacío)
|
||||
broadcast({ type: 'articles_updated', data: [] });
|
||||
|
||||
// También actualizar favoritos (debería estar vacío ahora)
|
||||
const favorites = await getFavorites(null);
|
||||
broadcast({ type: 'favorites_updated', data: favorites, username: null });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Todos los artículos eliminados: ${count} artículos borrados`,
|
||||
count
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error borrando artículos:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -799,21 +799,6 @@ export async function updateArticleFavorite(platform, id, is_favorite, username)
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearAllArticles() {
|
||||
if (!db) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const articlesCollection = db.collection('articles');
|
||||
const result = await articlesCollection.deleteMany({});
|
||||
return result.deletedCount;
|
||||
} catch (error) {
|
||||
console.error('Error limpiando artículos:', error.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Cerrar conexión
|
||||
export async function closeMongoDB() {
|
||||
if (mongoClient) {
|
||||
|
||||
@@ -1,16 +1,93 @@
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { getDB, getSession, getUser, deleteSession as deleteSessionFromDB } from './mongodb.js';
|
||||
|
||||
let wss = null;
|
||||
|
||||
// Duración de la sesión en milisegundos (24 horas)
|
||||
const SESSION_DURATION = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Inicializar WebSocket Server
|
||||
export function initWebSocket(server) {
|
||||
wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('Cliente WebSocket conectado');
|
||||
wss.on('connection', async (ws, req) => {
|
||||
// Extraer token de los query parameters
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
console.log('Intento de conexión WebSocket sin token');
|
||||
ws.close(1008, 'Token requerido');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar token
|
||||
try {
|
||||
const db = getDB();
|
||||
|
||||
if (!db) {
|
||||
console.error('MongoDB no disponible para validar WebSocket');
|
||||
ws.close(1011, 'Servicio no disponible');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar token en MongoDB
|
||||
const session = await getSession(token);
|
||||
|
||||
if (!session) {
|
||||
console.log('Intento de conexión WebSocket con token inválido');
|
||||
ws.close(1008, 'Token inválido');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar que la sesión no haya expirado
|
||||
if (session.expiresAt && new Date(session.expiresAt) < new Date()) {
|
||||
await deleteSessionFromDB(token);
|
||||
console.log('Intento de conexión WebSocket con sesión expirada');
|
||||
ws.close(1008, 'Sesión expirada');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar que el usuario aún existe
|
||||
const user = await getUser(session.username);
|
||||
|
||||
if (!user) {
|
||||
// Eliminar sesión si el usuario ya no existe
|
||||
await deleteSessionFromDB(token);
|
||||
console.log('Intento de conexión WebSocket con usuario inexistente');
|
||||
ws.close(1008, 'Usuario no encontrado');
|
||||
return;
|
||||
}
|
||||
|
||||
// Actualizar expiración de la sesión (refresh)
|
||||
const sessionsCollection = db.collection('sessions');
|
||||
const newExpiresAt = new Date(Date.now() + SESSION_DURATION);
|
||||
await sessionsCollection.updateOne(
|
||||
{ token },
|
||||
{ $set: { expiresAt: newExpiresAt } }
|
||||
);
|
||||
|
||||
// Autenticación exitosa - almacenar información del usuario en el websocket
|
||||
ws.user = {
|
||||
username: session.username,
|
||||
role: user.role || 'user',
|
||||
token: token
|
||||
};
|
||||
|
||||
console.log(`Cliente WebSocket conectado: ${session.username} (${user.role || 'user'})`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error validando token WebSocket:', error);
|
||||
ws.close(1011, 'Error de autenticación');
|
||||
return;
|
||||
}
|
||||
|
||||
ws.on('close', () => {
|
||||
if (ws.user) {
|
||||
console.log(`Cliente WebSocket desconectado: ${ws.user.username}`);
|
||||
} else {
|
||||
console.log('Cliente WebSocket desconectado');
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
|
||||
44
web/frontend/CONFIGURACION_BACKEND.md
Normal file
44
web/frontend/CONFIGURACION_BACKEND.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Configuración del Backend
|
||||
|
||||
Este documento explica cómo configurar el frontend para conectarse a diferentes backends.
|
||||
|
||||
## Configuración mediante Variables de Entorno
|
||||
|
||||
El frontend usa la variable de entorno `VITE_API_BASE_URL` para determinar la URL del backend.
|
||||
|
||||
### Para desarrollo local (por defecto)
|
||||
|
||||
Si no defines `VITE_API_BASE_URL`, el frontend usará `/api` que será manejado por el proxy de Vite configurado en `vite.config.js` (redirige a `http://localhost:3001`).
|
||||
|
||||
### Para backend en producción
|
||||
|
||||
Crea un archivo `.env.local` en la raíz del directorio `frontend` con el siguiente contenido:
|
||||
|
||||
```bash
|
||||
VITE_API_BASE_URL=https://wb.pribyte.cloud/api
|
||||
```
|
||||
|
||||
**Nota:** Los archivos `.env.local` están en `.gitignore` y no se subirán al repositorio.
|
||||
|
||||
## Cómo usar
|
||||
|
||||
1. **Desarrollo local:**
|
||||
- No necesitas hacer nada, funciona por defecto con el proxy de Vite
|
||||
|
||||
2. **Backend en producción:**
|
||||
- Crea el archivo `.env.local` con la URL del backend
|
||||
- Reinicia el servidor de desarrollo (`npm run dev`) para que cargue las nuevas variables
|
||||
|
||||
## Ejemplo de archivo `.env.local`
|
||||
|
||||
```bash
|
||||
# Backend en producción
|
||||
VITE_API_BASE_URL=https://wb.pribyte.cloud/api
|
||||
```
|
||||
|
||||
## Notas importantes
|
||||
|
||||
- Las variables de entorno que empiezan con `VITE_` son expuestas al código del cliente
|
||||
- Después de modificar `.env.local`, necesitas reiniciar el servidor de desarrollo
|
||||
- El WebSocket también se configurará automáticamente basándose en la URL del backend
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100 dark:bg-gray-900" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
|
||||
<!-- Sidebar - Solo mostrar si no estamos en login -->
|
||||
<template v-if="$route.path !== '/login'">
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="fixed top-0 left-0 z-40 h-screen transition-all duration-300 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 shadow-lg"
|
||||
@@ -78,11 +80,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="transition-all duration-300" :class="sidebarCollapsed ? 'ml-20' : 'ml-64'">
|
||||
<!-- Header -->
|
||||
<header class="sticky top-0 z-30 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div class="transition-all duration-300" :class="$route.path === '/login' ? '' : (sidebarCollapsed ? 'ml-20' : 'ml-64')">
|
||||
<!-- Header - Solo mostrar si no estamos en login -->
|
||||
<header v-if="$route.path !== '/login'" class="sticky top-0 z-30 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div class="flex items-center justify-between h-16 px-6">
|
||||
<!-- Breadcrumbs -->
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -122,196 +125,35 @@
|
||||
<MoonIcon v-else class="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<!-- Login/Logout -->
|
||||
<!-- Logout -->
|
||||
<button
|
||||
@click="isAuthenticated ? handleLogout() : showLoginModal = true"
|
||||
@click="handleLogout()"
|
||||
class="p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
:title="isAuthenticated ? 'Desconectar' : 'Iniciar sesión'"
|
||||
title="Desconectar"
|
||||
>
|
||||
<ArrowRightOnRectangleIcon v-if="isAuthenticated" class="w-5 h-5" />
|
||||
<ArrowLeftOnRectangleIcon v-else class="w-5 h-5" />
|
||||
<ArrowRightOnRectangleIcon class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Page Content -->
|
||||
<main class="p-6">
|
||||
<main :class="$route.path === '/login' ? '' : 'p-6 pb-20'">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Footer Fixed - Solo mostrar si no estamos en login -->
|
||||
<footer v-if="$route.path !== '/login'" class="fixed bottom-0 left-0 right-0 z-30 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 py-3 shadow-sm" :class="sidebarCollapsed ? 'ml-20' : 'ml-64'">
|
||||
<div class="px-6">
|
||||
<p class="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
© {{ new Date().getFullYear() }} Wallabicher. Todos los derechos reservados.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Toast notifications container -->
|
||||
<div class="fixed top-20 right-6 z-50 space-y-3">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl p-3 max-w-sm min-w-[320px] animate-slide-in backdrop-blur-sm"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
v-if="toast.images"
|
||||
:src="toast.images[0]"
|
||||
:alt="toast.title"
|
||||
class="w-12 h-12 object-cover rounded-lg"
|
||||
@error="($event) => $event.target.style.display = 'none'"
|
||||
/>
|
||||
<div v-else class="w-12 h-12 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-600 rounded-lg flex items-center justify-center">
|
||||
<span class="text-gray-400 text-lg">📦</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
class="px-2 py-0.5 text-[10px] font-bold rounded-md uppercase tracking-wide"
|
||||
:class="toast.platform === 'wallapop' ? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300' : 'bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300'"
|
||||
>
|
||||
{{ toast.platform?.toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm mb-1 line-clamp-1 leading-tight">
|
||||
{{ toast.title || 'Nuevo artículo' }}
|
||||
</h4>
|
||||
<p v-if="toast.price" class="text-base font-bold text-primary-600 dark:text-primary-400 mb-2">
|
||||
{{ toast.price }} {{ toast.currency || '€' }}
|
||||
</p>
|
||||
<a
|
||||
v-if="toast.url"
|
||||
:href="toast.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:underline inline-flex items-center gap-1 font-medium"
|
||||
>
|
||||
Ver artículo →
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
@click="removeToast(toast.id)"
|
||||
class="flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-sm leading-none p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="Cerrar"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Login Global -->
|
||||
<div
|
||||
v-if="showLoginModal"
|
||||
class="fixed inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||
@click.self="closeLoginModal"
|
||||
>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-md w-full border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<!-- Modal Header -->
|
||||
<div class="bg-gradient-to-r from-primary-600 to-primary-700 px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-white rounded-lg overflow-hidden ring-2 ring-white/30">
|
||||
<img
|
||||
src="/logo.jpg"
|
||||
alt="Wallabicher Logo"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-white">Iniciar Sesión</h2>
|
||||
<p class="text-xs text-white/80">Wallabicher Admin Panel</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="closeLoginModal"
|
||||
class="text-white/80 hover:text-white p-1 rounded-lg hover:bg-white/20 transition-colors"
|
||||
title="Cerrar"
|
||||
>
|
||||
<XMarkIcon class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="px-6 py-6">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Ingresa tus credenciales para acceder al panel de administración.
|
||||
</p>
|
||||
|
||||
<form @submit.prevent="handleGlobalLogin" class="space-y-5">
|
||||
<div v-if="globalLoginError" class="bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500 text-red-700 dark:text-red-300 px-4 py-3 rounded-r-lg">
|
||||
<div class="flex items-center">
|
||||
<span class="text-red-500 mr-2">⚠️</span>
|
||||
<span class="text-sm font-medium">{{ globalLoginError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Usuario
|
||||
</label>
|
||||
<input
|
||||
v-model="globalLoginForm.username"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="admin"
|
||||
required
|
||||
autocomplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Contraseña
|
||||
</label>
|
||||
<input
|
||||
v-model="globalLoginForm.password"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
v-model="globalLoginForm.remember"
|
||||
type="checkbox"
|
||||
id="remember-global"
|
||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label for="remember-global" class="ml-2 block text-sm text-gray-700 dark:text-gray-300">
|
||||
Recordar credenciales
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col-reverse sm:flex-row justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeLoginModal"
|
||||
class="btn btn-secondary text-sm sm:text-base"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary text-sm sm:text-base"
|
||||
:disabled="globalLoginLoading"
|
||||
>
|
||||
<span v-if="!globalLoginLoading">Iniciar Sesión</span>
|
||||
<span v-else class="flex items-center">
|
||||
<span class="animate-spin mr-2">⏳</span>
|
||||
Iniciando...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -331,12 +173,12 @@ import {
|
||||
BellIcon,
|
||||
BellSlashIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
ArrowLeftOnRectangleIcon,
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import pushNotificationService from './services/pushNotifications';
|
||||
import authService from './services/auth';
|
||||
import { useRouter } from 'vue-router';
|
||||
import api from './services/api';
|
||||
import ToastContainer from './components/ToastContainer.vue';
|
||||
|
||||
const allNavItems = [
|
||||
{ path: '/', name: 'Dashboard', icon: HomeIcon, adminOnly: false },
|
||||
@@ -351,20 +193,10 @@ const router = useRouter();
|
||||
const wsConnected = ref(false);
|
||||
const sidebarCollapsed = ref(false);
|
||||
const darkMode = ref(false);
|
||||
const toasts = ref([]);
|
||||
const pushEnabled = ref(false);
|
||||
const showLoginModal = ref(false);
|
||||
const globalLoginError = ref('');
|
||||
const globalLoginLoading = ref(false);
|
||||
const currentUser = ref(authService.getUsername() || null);
|
||||
const isAdmin = ref(false);
|
||||
const globalLoginForm = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
remember: true,
|
||||
});
|
||||
let ws = null;
|
||||
let toastIdCounter = 0;
|
||||
|
||||
const isDark = computed(() => darkMode.value);
|
||||
const isAuthenticated = computed(() => authService.hasCredentials());
|
||||
@@ -380,30 +212,6 @@ const navItems = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
function checkUserRole() {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = currentUser.value === 'admin'; // Simplificación temporal
|
||||
}
|
||||
|
||||
function addToast(article) {
|
||||
const id = ++toastIdCounter;
|
||||
toasts.value.push({
|
||||
id,
|
||||
...article,
|
||||
});
|
||||
|
||||
// Auto-remover después de 5 segundos (más corto para toasts discretos)
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function removeToast(id) {
|
||||
const index = toasts.value.findIndex(t => t.id === id);
|
||||
if (index > -1) {
|
||||
toasts.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDarkMode() {
|
||||
darkMode.value = !darkMode.value;
|
||||
@@ -469,69 +277,33 @@ async function checkPushStatus() {
|
||||
pushEnabled.value = hasSubscription;
|
||||
}
|
||||
|
||||
async function handleGlobalLogin() {
|
||||
globalLoginError.value = '';
|
||||
globalLoginLoading.value = true;
|
||||
|
||||
if (!globalLoginForm.value.username || !globalLoginForm.value.password) {
|
||||
globalLoginError.value = 'Usuario y contraseña son requeridos';
|
||||
globalLoginLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Usar el nuevo método login que genera un token
|
||||
await authService.login(
|
||||
globalLoginForm.value.username,
|
||||
globalLoginForm.value.password
|
||||
);
|
||||
|
||||
// Si llegamos aquí, el login fue exitoso y el token está guardado
|
||||
closeLoginModal();
|
||||
|
||||
// Recargar página para actualizar datos después del login
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Error en login:', error);
|
||||
globalLoginError.value = error.message || 'Usuario o contraseña incorrectos';
|
||||
authService.clearSession();
|
||||
} finally {
|
||||
globalLoginLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeLoginModal() {
|
||||
showLoginModal.value = false;
|
||||
globalLoginError.value = '';
|
||||
globalLoginForm.value = {
|
||||
username: '',
|
||||
password: '',
|
||||
remember: true,
|
||||
};
|
||||
}
|
||||
|
||||
function handleAuthRequired(event) {
|
||||
showLoginModal.value = true;
|
||||
if (event.detail?.message) {
|
||||
globalLoginError.value = event.detail.message;
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentPageTitle() {
|
||||
const currentItem = navItems.value.find(item => item.path === router.currentRoute.value.path);
|
||||
return currentItem ? currentItem.name : 'Dashboard';
|
||||
}
|
||||
|
||||
function handleAuthChange() {
|
||||
checkUserRole();
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
// Reconectar websocket cuando cambie la autenticación (login)
|
||||
if (authService.hasCredentials()) {
|
||||
connectWebSocket();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
// Cerrar conexión WebSocket antes de hacer logout
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
wsConnected.value = false;
|
||||
}
|
||||
|
||||
// Llamar al endpoint de logout e invalidar token
|
||||
await authService.logout();
|
||||
|
||||
// Redirigir al dashboard después del logout
|
||||
router.push('/');
|
||||
// Redirigir a login después del logout
|
||||
router.push('/login');
|
||||
|
||||
// Disparar evento para que los componentes se actualicen
|
||||
window.dispatchEvent(new CustomEvent('auth-logout'));
|
||||
@@ -542,45 +314,77 @@ async function handleLogout() {
|
||||
|
||||
onMounted(async () => {
|
||||
initDarkMode();
|
||||
checkUserRole();
|
||||
connectWebSocket();
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
await checkPushStatus();
|
||||
|
||||
// Cargar username guardado si existe (pero no la contraseña)
|
||||
if (authService.hasCredentials()) {
|
||||
const username = authService.getUsername();
|
||||
if (username) {
|
||||
globalLoginForm.value.username = username;
|
||||
}
|
||||
// Escuchar eventos de autenticación
|
||||
window.addEventListener('auth-login', handleAuthChange);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
|
||||
// Si hay credenciales, validar y conectar websocket
|
||||
if (authService.hasCredentials()) {
|
||||
// Validar si el token sigue siendo válido
|
||||
const isValid = await authService.validateSession();
|
||||
if (!isValid) {
|
||||
// Si el token expiró, limpiar sesión
|
||||
// Si el token expiró, limpiar sesión y redirigir a login
|
||||
authService.clearSession();
|
||||
checkUserRole();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// Escuchar eventos de autenticación requerida
|
||||
window.addEventListener('auth-required', handleAuthRequired);
|
||||
window.addEventListener('auth-login', handleAuthChange);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('auth-required', handleAuthRequired);
|
||||
window.removeEventListener('auth-login', handleAuthChange);
|
||||
window.removeEventListener('auth-logout', handleAuthChange);
|
||||
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
function connectWebSocket() {
|
||||
// Cerrar conexión existente si hay una
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
|
||||
// Verificar si hay token de autenticación
|
||||
const token = authService.getToken();
|
||||
if (!token) {
|
||||
console.log('No hay token de autenticación, no se conectará WebSocket');
|
||||
wsConnected.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let wsUrl;
|
||||
|
||||
// Si hay una URL de API configurada, usarla para WebSocket también
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
|
||||
if (apiBaseUrl && apiBaseUrl !== '/api') {
|
||||
// Extraer el host de la URL de la API y construir la URL del WebSocket
|
||||
try {
|
||||
const url = new URL(apiBaseUrl);
|
||||
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${url.host}/ws?token=${encodeURIComponent(token)}`;
|
||||
} catch (e) {
|
||||
// Si falla el parsing, usar la configuración por defecto
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
} else {
|
||||
// Use relative path so Vite proxy can handle it
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
@@ -589,10 +393,24 @@ function connectWebSocket() {
|
||||
console.log('WebSocket conectado');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
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) => {
|
||||
@@ -603,15 +421,7 @@ function connectWebSocket() {
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Manejar notificaciones de artículos nuevos
|
||||
if (data.type === 'new_articles' && data.data) {
|
||||
// Mostrar toasts para cada artículo nuevo
|
||||
for (const article of data.data) {
|
||||
addToast(article);
|
||||
}
|
||||
}
|
||||
|
||||
// Los componentes individuales manejarán otros mensajes
|
||||
// Los componentes individuales manejarán los mensajes (incluyendo ToastContainer)
|
||||
window.dispatchEvent(new CustomEvent('ws-message', { detail: data }));
|
||||
};
|
||||
}
|
||||
|
||||
127
web/frontend/src/components/ToastContainer.vue
Normal file
127
web/frontend/src/components/ToastContainer.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="fixed top-20 right-6 z-50">
|
||||
<!-- Botón para cerrar todos los toasts -->
|
||||
<button
|
||||
v-if="toasts.length > 0"
|
||||
@click="clearAllToasts"
|
||||
class="mb-3 ml-auto flex items-center gap-2 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Cerrar todas las notificaciones"
|
||||
>
|
||||
<XMarkIcon class="w-4 h-4" />
|
||||
Cerrar todas ({{ toasts.length }})
|
||||
</button>
|
||||
|
||||
<transition-group
|
||||
name="toast"
|
||||
tag="div"
|
||||
class="space-y-3"
|
||||
>
|
||||
<ToastNotification
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
:toast="toast"
|
||||
@close="removeToast(toast.id)"
|
||||
/>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { XMarkIcon } from '@heroicons/vue/24/outline';
|
||||
import ToastNotification from './ToastNotification.vue';
|
||||
|
||||
const toasts = ref([]);
|
||||
let toastIdCounter = 0;
|
||||
const toastTimeouts = new Map();
|
||||
|
||||
function addToast(article) {
|
||||
const id = ++toastIdCounter;
|
||||
toasts.value.push({
|
||||
id,
|
||||
...article,
|
||||
});
|
||||
|
||||
// Limpiar timeout anterior si existe (por si acaso)
|
||||
if (toastTimeouts.has(id)) {
|
||||
clearTimeout(toastTimeouts.get(id));
|
||||
}
|
||||
|
||||
// Auto-remover después de 5 segundos
|
||||
const timeoutId = setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, 5000);
|
||||
|
||||
toastTimeouts.set(id, timeoutId);
|
||||
}
|
||||
|
||||
function removeToast(id) {
|
||||
// Limpiar timeout si existe
|
||||
if (toastTimeouts.has(id)) {
|
||||
clearTimeout(toastTimeouts.get(id));
|
||||
toastTimeouts.delete(id);
|
||||
}
|
||||
|
||||
// Eliminar directamente - Vue transition-group manejará la animación
|
||||
const index = toasts.value.findIndex(t => t.id === id);
|
||||
if (index > -1) {
|
||||
toasts.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllToasts() {
|
||||
// Limpiar todos los timeouts
|
||||
toastTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
|
||||
toastTimeouts.clear();
|
||||
|
||||
// Eliminar todos los toasts
|
||||
toasts.value = [];
|
||||
}
|
||||
|
||||
// Escuchar eventos de WebSocket para nuevos artículos
|
||||
function handleWebSocketMessage(event) {
|
||||
const data = event.detail;
|
||||
|
||||
// Manejar notificaciones de artículos nuevos
|
||||
if (data.type === 'new_articles' && data.data) {
|
||||
// Mostrar toasts para cada artículo nuevo
|
||||
for (const article of data.data) {
|
||||
addToast(article);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('ws-message', handleWebSocketMessage);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('ws-message', handleWebSocketMessage);
|
||||
|
||||
// Limpiar todos los timeouts de toasts
|
||||
toastTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
|
||||
toastTimeouts.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.toast-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
68
web/frontend/src/components/ToastNotification.vue
Normal file
68
web/frontend/src/components/ToastNotification.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl p-3 max-w-sm min-w-[320px] backdrop-blur-sm"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
v-if="toast.images"
|
||||
:src="toast.images[0]"
|
||||
:alt="toast.title"
|
||||
class="w-12 h-12 object-cover rounded-lg"
|
||||
@error="($event) => $event.target.style.display = 'none'"
|
||||
/>
|
||||
<div v-else class="w-12 h-12 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-600 rounded-lg flex items-center justify-center">
|
||||
<span class="text-gray-400 text-lg">📦</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
class="px-2 py-0.5 text-[10px] font-bold rounded-md uppercase tracking-wide"
|
||||
:class="toast.platform === 'wallapop' ? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300' : 'bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300'"
|
||||
>
|
||||
{{ toast.platform?.toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm mb-1 line-clamp-1 leading-tight">
|
||||
{{ toast.title || 'Nuevo artículo' }}
|
||||
</h4>
|
||||
<p v-if="toast.price" class="text-base font-bold text-primary-600 dark:text-primary-400 mb-2">
|
||||
{{ toast.price }} {{ toast.currency || '€' }}
|
||||
</p>
|
||||
<a
|
||||
v-if="toast.url"
|
||||
:href="toast.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:underline inline-flex items-center gap-1 font-medium"
|
||||
>
|
||||
Ver artículo →
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-sm leading-none p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="Cerrar"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
toast: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['close']);
|
||||
</script>
|
||||
|
||||
@@ -7,15 +7,18 @@ 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 Login from './views/Login.vue';
|
||||
import './style.css';
|
||||
import authService from './services/auth';
|
||||
|
||||
const routes = [
|
||||
{ path: '/', component: Dashboard },
|
||||
{ path: '/articles', component: Articles },
|
||||
{ path: '/favorites', component: Favorites },
|
||||
{ path: '/workers', component: Workers },
|
||||
{ path: '/users', component: Users },
|
||||
{ path: '/logs', component: Logs },
|
||||
{ path: '/login', component: Login, name: 'login' },
|
||||
{ path: '/', component: Dashboard, meta: { requiresAuth: true } },
|
||||
{ path: '/articles', component: Articles, 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 } },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
@@ -23,6 +26,43 @@ const router = createRouter({
|
||||
routes,
|
||||
});
|
||||
|
||||
// Guard de navegación para verificar autenticación
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// Si la ruta es /login y ya está autenticado, redirigir al dashboard
|
||||
if (to.path === '/login') {
|
||||
if (authService.hasCredentials()) {
|
||||
const isValid = await authService.validateSession();
|
||||
if (isValid) {
|
||||
next('/');
|
||||
return;
|
||||
}
|
||||
}
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Para todas las demás rutas, verificar autenticación
|
||||
if (to.meta.requiresAuth) {
|
||||
// Verificar si hay token almacenado
|
||||
if (!authService.hasCredentials()) {
|
||||
// No hay token, redirigir a login
|
||||
next('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Hay token, validar si sigue siendo válido
|
||||
const isValid = await authService.validateSession();
|
||||
if (!isValid) {
|
||||
// Token inválido o expirado, redirigir a login
|
||||
next('/login');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Continuar la navegación
|
||||
next();
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
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: '/api',
|
||||
baseURL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -119,18 +124,6 @@ export default {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Cache
|
||||
async clearCache() {
|
||||
const response = await api.delete('/cache');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Artículos - Borrar todos (solo admin)
|
||||
async clearAllArticles() {
|
||||
const response = await api.delete('/articles');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Usuarios
|
||||
async getUsers() {
|
||||
const response = await api.get('/users');
|
||||
|
||||
@@ -185,6 +185,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-out {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -202,3 +213,16 @@
|
||||
animation: fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Toast transitions */
|
||||
.toast-enter-active {
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast-leave-active {
|
||||
animation: slide-out 0.3s ease-in;
|
||||
}
|
||||
|
||||
.toast-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,14 +47,6 @@
|
||||
<button @click="loadArticles" class="btn btn-primary whitespace-nowrap">
|
||||
Actualizar
|
||||
</button>
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
@click="handleClearAllArticles"
|
||||
class="btn btn-danger whitespace-nowrap"
|
||||
title="Borrar todos los artículos (solo admin)"
|
||||
>
|
||||
🗑️ Borrar Todos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -192,14 +184,6 @@ const filteredArticles = computed(() => {
|
||||
});
|
||||
|
||||
|
||||
function checkUserRole() {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = currentUser.value === 'admin'; // Simplificación temporal
|
||||
// Si no es admin, no permitir filtrar por username
|
||||
if (!isAdmin.value && selectedUsername.value) {
|
||||
selectedUsername.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadArticles(reset = true, silent = false) {
|
||||
if (reset) {
|
||||
@@ -248,7 +232,12 @@ async function loadArticles(reset = true, silent = false) {
|
||||
}
|
||||
|
||||
function handleAuthChange() {
|
||||
checkUserRole();
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
// Si no es admin, no permitir filtrar por username
|
||||
if (!isAdmin.value && selectedUsername.value) {
|
||||
selectedUsername.value = '';
|
||||
}
|
||||
if (currentUser.value) {
|
||||
loadArticles();
|
||||
}
|
||||
@@ -260,69 +249,11 @@ function loadMore() {
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
if (data.type === 'articles_updated' || data.type === 'articles_cleared' || data.type === 'cache_cleared') {
|
||||
if (data.type === 'articles_updated') {
|
||||
loadArticles();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearAllArticles() {
|
||||
if (!isAdmin.value) {
|
||||
alert('Solo los administradores pueden borrar todos los artículos');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = confirm(
|
||||
'⚠️ ¿Estás seguro de que quieres borrar TODOS los artículos?\n\n' +
|
||||
'Esta acción eliminará permanentemente todos los artículos de la base de datos.\n' +
|
||||
'Esta acción NO se puede deshacer.\n\n' +
|
||||
'¿Continuar?'
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirmación adicional
|
||||
const doubleConfirmed = confirm(
|
||||
'⚠️ ÚLTIMA CONFIRMACIÓN ⚠️\n\n' +
|
||||
'Estás a punto de borrar TODOS los artículos de la base de datos.\n' +
|
||||
'Esta acción es IRREVERSIBLE.\n\n' +
|
||||
'¿Estás absolutamente seguro?'
|
||||
);
|
||||
|
||||
if (!doubleConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const result = await api.clearAllArticles();
|
||||
alert(`✅ ${result.message || `Se borraron ${result.count || 0} artículos`}`);
|
||||
|
||||
// Limpiar la vista
|
||||
allArticles.value = [];
|
||||
searchResults.value = [];
|
||||
total.value = 0;
|
||||
offset.value = 0;
|
||||
|
||||
// Recargar artículos (ahora estará vacío)
|
||||
await loadArticles();
|
||||
} catch (error) {
|
||||
console.error('Error borrando artículos:', error);
|
||||
|
||||
if (error.response?.status === 403) {
|
||||
alert('❌ Error: No tienes permisos de administrador para realizar esta acción');
|
||||
} else if (error.response?.status === 401) {
|
||||
alert('❌ Error: Debes estar autenticado para realizar esta acción');
|
||||
} else {
|
||||
alert('❌ Error al borrar artículos: ' + (error.response?.data?.error || error.message || 'Error desconocido'));
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchArticles(query) {
|
||||
if (!query.trim()) {
|
||||
searchResults.value = [];
|
||||
@@ -378,7 +309,12 @@ watch(searchQuery, (newQuery) => {
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
checkUserRole();
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
// Si no es admin, no permitir filtrar por username
|
||||
if (!isAdmin.value && selectedUsername.value) {
|
||||
selectedUsername.value = '';
|
||||
}
|
||||
loadArticles();
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
|
||||
@@ -263,16 +263,10 @@ async function loadStats() {
|
||||
}
|
||||
}
|
||||
|
||||
function checkUserRole() {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
// Por ahora, asumimos que si no hay usuario o el usuario no es admin, no es admin
|
||||
// En el futuro, se podría añadir un endpoint para verificar el rol
|
||||
// Por defecto, asumimos que el usuario normal no es admin
|
||||
isAdmin.value = currentUser.value === 'admin'; // Simplificación temporal
|
||||
}
|
||||
|
||||
function handleAuthChange() {
|
||||
checkUserRole();
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
if (currentUser.value) {
|
||||
loadStats();
|
||||
}
|
||||
@@ -288,7 +282,8 @@ function handleWSMessage(event) {
|
||||
let interval = null;
|
||||
|
||||
onMounted(() => {
|
||||
checkUserRole();
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
loadStats();
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
|
||||
@@ -96,10 +96,6 @@ async function removeFavorite(platform, id) {
|
||||
}
|
||||
}
|
||||
|
||||
function checkUserRole() {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = currentUser.value === 'admin'; // Simplificación temporal
|
||||
}
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
@@ -112,14 +108,16 @@ function handleWSMessage(event) {
|
||||
}
|
||||
|
||||
function handleAuthChange() {
|
||||
checkUserRole();
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
if (currentUser.value) {
|
||||
loadFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkUserRole();
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
loadFavorites();
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
|
||||
300
web/frontend/src/views/Login.vue
Normal file
300
web/frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-white dark:bg-gray-900 flex">
|
||||
<!-- Left Panel - Branding -->
|
||||
<div class="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-primary-600 via-primary-700 to-primary-800 relative overflow-hidden">
|
||||
<!-- Decorative Elements -->
|
||||
<div class="absolute inset-0 opacity-10">
|
||||
<div class="absolute top-0 left-0 w-96 h-96 bg-white rounded-full -translate-x-1/2 -translate-y-1/2 blur-3xl"></div>
|
||||
<div class="absolute bottom-0 right-0 w-96 h-96 bg-primary-300 rounded-full translate-x-1/2 translate-y-1/2 blur-3xl"></div>
|
||||
<div class="absolute top-1/2 left-1/2 w-64 h-64 bg-primary-400 rounded-full -translate-x-1/2 -translate-y-1/2 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="relative z-10 flex flex-col justify-between p-12 text-white">
|
||||
<div>
|
||||
<div class="flex items-center space-x-3 mb-8">
|
||||
<div class="w-14 h-14 bg-white/20 backdrop-blur-sm rounded-xl overflow-hidden ring-2 ring-white/30 shadow-xl">
|
||||
<img
|
||||
src="/logo.jpg"
|
||||
alt="Wallabicher Logo"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Wallabicher</h1>
|
||||
<p class="text-sm text-white/80">Admin Panel</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-4xl font-bold mb-4 leading-tight">
|
||||
Bienvenido de vuelta
|
||||
</h2>
|
||||
<p class="text-lg text-white/90 leading-relaxed">
|
||||
Gestiona y monitoriza tus búsquedas de productos con nuestro panel de administración profesional.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="space-y-4 pt-4 border-t border-white/20">
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center mt-0.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-white">Monitoreo en tiempo real</p>
|
||||
<p class="text-sm text-white/80">Recibe notificaciones instantáneas de nuevos artículos</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center mt-0.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-white">Gestión avanzada</p>
|
||||
<p class="text-sm text-white/80">Control total sobre workers, usuarios y configuraciones</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center mt-0.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-white">Seguridad empresarial</p>
|
||||
<p class="text-sm text-white/80">Autenticación robusta y gestión de permisos</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-white/70">
|
||||
© {{ new Date().getFullYear() }} Wallabicher. Todos los derechos reservados.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel - Login Form -->
|
||||
<div class="flex-1 flex items-center justify-center p-8 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="w-full max-w-md">
|
||||
<!-- Mobile Logo -->
|
||||
<div class="lg:hidden flex items-center justify-center space-x-3 mb-8">
|
||||
<div class="w-12 h-12 bg-primary-600 rounded-xl overflow-hidden ring-2 ring-primary-500/50 shadow-lg">
|
||||
<img
|
||||
src="/logo.jpg"
|
||||
alt="Wallabicher Logo"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">Wallabicher</h1>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Admin Panel</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Card -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-8">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Iniciar Sesión
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Ingresa tus credenciales para acceder al panel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="loginError"
|
||||
class="mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4 animate-shake"
|
||||
>
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-red-600 dark:text-red-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-red-800 dark:text-red-300">
|
||||
{{ loginError }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form @submit.prevent="handleLogin" class="space-y-6">
|
||||
<!-- Username Field -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Usuario
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
v-model="loginForm.username"
|
||||
type="text"
|
||||
class="input w-full pl-12 pr-4 py-3 text-base"
|
||||
placeholder="Ingresa tu usuario"
|
||||
required
|
||||
autocomplete="username"
|
||||
:disabled="loginLoading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Field -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Contraseña
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
class="input w-full pl-12 pr-4 py-3 text-base"
|
||||
placeholder="Ingresa tu contraseña"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
:disabled="loginLoading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remember Me & Forgot Password -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
v-model="loginForm.remember"
|
||||
type="checkbox"
|
||||
id="remember"
|
||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 transition-colors cursor-pointer"
|
||||
:disabled="loginLoading"
|
||||
/>
|
||||
<label for="remember" class="ml-2 block text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none">
|
||||
Recordar sesión
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full btn btn-primary py-3.5 text-base font-semibold shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-[1.02] active:scale-[0.98]"
|
||||
:disabled="loginLoading"
|
||||
>
|
||||
<span v-if="!loginLoading" class="flex items-center justify-center">
|
||||
<span>Iniciar Sesión</span>
|
||||
<svg class="ml-2 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span v-else class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Iniciando sesión...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<p class="text-xs text-center text-gray-500 dark:text-gray-400">
|
||||
¿Necesitas ayuda? Contacta con el administrador del sistema
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import authService from '../services/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const loginError = ref('');
|
||||
const loginLoading = ref(false);
|
||||
const loginForm = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
remember: true,
|
||||
});
|
||||
|
||||
async function handleLogin() {
|
||||
loginError.value = '';
|
||||
loginLoading.value = true;
|
||||
|
||||
if (!loginForm.value.username || !loginForm.value.password) {
|
||||
loginError.value = 'Usuario y contraseña son requeridos';
|
||||
loginLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authService.login(
|
||||
loginForm.value.username,
|
||||
loginForm.value.password
|
||||
);
|
||||
|
||||
// Si llegamos aquí, el login fue exitoso
|
||||
// Redirigir al dashboard
|
||||
router.push('/');
|
||||
|
||||
// Disparar evento para que App.vue se actualice
|
||||
window.dispatchEvent(new CustomEvent('auth-login'));
|
||||
} catch (error) {
|
||||
console.error('Error en login:', error);
|
||||
loginError.value = error.message || 'Usuario o contraseña incorrectos';
|
||||
authService.clearSession();
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Si ya está autenticado, redirigir al dashboard
|
||||
if (authService.hasCredentials()) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
// Cargar username guardado si existe
|
||||
const username = authService.getUsername();
|
||||
if (username) {
|
||||
loginForm.value.username = username;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(5px); }
|
||||
}
|
||||
|
||||
.animate-shake {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
@@ -126,10 +126,6 @@ function getLogColor(log) {
|
||||
return 'text-green-400';
|
||||
}
|
||||
|
||||
function checkUserRole() {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = currentUser.value === 'admin'; // Simplificación temporal
|
||||
}
|
||||
|
||||
async function loadLogs(forceReload = false, shouldScroll = null) {
|
||||
// Verificar que el usuario es admin antes de cargar logs
|
||||
@@ -245,7 +241,8 @@ function handleWSMessage(event) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkUserRole();
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
loadLogs(true, true); // Primera carga forzada siempre hace scroll
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user