refactor on components and delete clear, profesional login
This commit is contained in:
@@ -50,7 +50,7 @@ backend/
|
|||||||
|
|
||||||
### Routes (`routes/`)
|
### Routes (`routes/`)
|
||||||
Cada archivo maneja un conjunto relacionado de endpoints:
|
Cada archivo maneja un conjunto relacionado de endpoints:
|
||||||
- **index.js**: `/api/stats`, `/api/cache`
|
- **index.js**: `/api/stats`
|
||||||
- **workers.js**: `/api/workers` (GET, PUT)
|
- **workers.js**: `/api/workers` (GET, PUT)
|
||||||
- **articles.js**: `/api/articles` (GET, search)
|
- **articles.js**: `/api/articles` (GET, search)
|
||||||
- **favorites.js**: `/api/favorites` (GET, POST, DELETE)
|
- **favorites.js**: `/api/favorites` (GET, POST, DELETE)
|
||||||
|
|||||||
@@ -4,15 +4,6 @@ import { basicAuthMiddleware } from '../middlewares/auth.js';
|
|||||||
|
|
||||||
const router = express.Router();
|
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)
|
// Obtener artículos notificados (requiere autenticación obligatoria)
|
||||||
router.get('/', basicAuthMiddleware, async (req, res) => {
|
router.get('/', basicAuthMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import express from 'express';
|
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 { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||||
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
|
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
|
||||||
import { broadcast } from '../services/websocket.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;
|
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
|
// Cerrar conexión
|
||||||
export async function closeMongoDB() {
|
export async function closeMongoDB() {
|
||||||
if (mongoClient) {
|
if (mongoClient) {
|
||||||
|
|||||||
@@ -1,16 +1,93 @@
|
|||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
|
import { getDB, getSession, getUser, deleteSession as deleteSessionFromDB } from './mongodb.js';
|
||||||
|
|
||||||
let wss = null;
|
let wss = null;
|
||||||
|
|
||||||
|
// Duración de la sesión en milisegundos (24 horas)
|
||||||
|
const SESSION_DURATION = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
// Inicializar WebSocket Server
|
// Inicializar WebSocket Server
|
||||||
export function initWebSocket(server) {
|
export function initWebSocket(server) {
|
||||||
wss = new WebSocketServer({ server, path: '/ws' });
|
wss = new WebSocketServer({ server, path: '/ws' });
|
||||||
|
|
||||||
wss.on('connection', (ws) => {
|
wss.on('connection', async (ws, req) => {
|
||||||
console.log('Cliente WebSocket conectado');
|
// 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', () => {
|
ws.on('close', () => {
|
||||||
console.log('Cliente WebSocket desconectado');
|
if (ws.user) {
|
||||||
|
console.log(`Cliente WebSocket desconectado: ${ws.user.username}`);
|
||||||
|
} else {
|
||||||
|
console.log('Cliente WebSocket desconectado');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('error', (error) => {
|
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>
|
<template>
|
||||||
<div class="min-h-screen bg-gray-100 dark:bg-gray-900" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
|
<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 -->
|
<!-- Sidebar -->
|
||||||
<aside
|
<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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="transition-all duration-300" :class="sidebarCollapsed ? 'ml-20' : 'ml-64'">
|
<div class="transition-all duration-300" :class="$route.path === '/login' ? '' : (sidebarCollapsed ? 'ml-20' : 'ml-64')">
|
||||||
<!-- Header -->
|
<!-- Header - Solo mostrar si no estamos en login -->
|
||||||
<header class="sticky top-0 z-30 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
|
<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">
|
<div class="flex items-center justify-between h-16 px-6">
|
||||||
<!-- Breadcrumbs -->
|
<!-- Breadcrumbs -->
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
@@ -122,196 +125,35 @@
|
|||||||
<MoonIcon v-else class="w-5 h-5" />
|
<MoonIcon v-else class="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Login/Logout -->
|
<!-- Logout -->
|
||||||
<button
|
<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"
|
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" />
|
<ArrowRightOnRectangleIcon class="w-5 h-5" />
|
||||||
<ArrowLeftOnRectangleIcon v-else class="w-5 h-5" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Page Content -->
|
<!-- Page Content -->
|
||||||
<main class="p-6">
|
<main :class="$route.path === '/login' ? '' : 'p-6 pb-20'">
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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 -->
|
<!-- Toast notifications container -->
|
||||||
<div class="fixed top-20 right-6 z-50 space-y-3">
|
<ToastContainer />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -331,12 +173,12 @@ import {
|
|||||||
BellIcon,
|
BellIcon,
|
||||||
BellSlashIcon,
|
BellSlashIcon,
|
||||||
ArrowRightOnRectangleIcon,
|
ArrowRightOnRectangleIcon,
|
||||||
ArrowLeftOnRectangleIcon,
|
|
||||||
} from '@heroicons/vue/24/outline';
|
} from '@heroicons/vue/24/outline';
|
||||||
import pushNotificationService from './services/pushNotifications';
|
import pushNotificationService from './services/pushNotifications';
|
||||||
import authService from './services/auth';
|
import authService from './services/auth';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import api from './services/api';
|
import api from './services/api';
|
||||||
|
import ToastContainer from './components/ToastContainer.vue';
|
||||||
|
|
||||||
const allNavItems = [
|
const allNavItems = [
|
||||||
{ path: '/', name: 'Dashboard', icon: HomeIcon, adminOnly: false },
|
{ path: '/', name: 'Dashboard', icon: HomeIcon, adminOnly: false },
|
||||||
@@ -351,20 +193,10 @@ const router = useRouter();
|
|||||||
const wsConnected = ref(false);
|
const wsConnected = ref(false);
|
||||||
const sidebarCollapsed = ref(false);
|
const sidebarCollapsed = ref(false);
|
||||||
const darkMode = ref(false);
|
const darkMode = ref(false);
|
||||||
const toasts = ref([]);
|
|
||||||
const pushEnabled = ref(false);
|
const pushEnabled = ref(false);
|
||||||
const showLoginModal = ref(false);
|
|
||||||
const globalLoginError = ref('');
|
|
||||||
const globalLoginLoading = ref(false);
|
|
||||||
const currentUser = ref(authService.getUsername() || null);
|
const currentUser = ref(authService.getUsername() || null);
|
||||||
const isAdmin = ref(false);
|
const isAdmin = ref(false);
|
||||||
const globalLoginForm = ref({
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
remember: true,
|
|
||||||
});
|
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let toastIdCounter = 0;
|
|
||||||
|
|
||||||
const isDark = computed(() => darkMode.value);
|
const isDark = computed(() => darkMode.value);
|
||||||
const isAuthenticated = computed(() => authService.hasCredentials());
|
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() {
|
function toggleDarkMode() {
|
||||||
darkMode.value = !darkMode.value;
|
darkMode.value = !darkMode.value;
|
||||||
@@ -469,69 +277,33 @@ async function checkPushStatus() {
|
|||||||
pushEnabled.value = hasSubscription;
|
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() {
|
function getCurrentPageTitle() {
|
||||||
const currentItem = navItems.value.find(item => item.path === router.currentRoute.value.path);
|
const currentItem = navItems.value.find(item => item.path === router.currentRoute.value.path);
|
||||||
return currentItem ? currentItem.name : 'Dashboard';
|
return currentItem ? currentItem.name : 'Dashboard';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAuthChange() {
|
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() {
|
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
|
// Llamar al endpoint de logout e invalidar token
|
||||||
await authService.logout();
|
await authService.logout();
|
||||||
|
|
||||||
// Redirigir al dashboard después del logout
|
// Redirigir a login después del logout
|
||||||
router.push('/');
|
router.push('/login');
|
||||||
|
|
||||||
// Disparar evento para que los componentes se actualicen
|
// Disparar evento para que los componentes se actualicen
|
||||||
window.dispatchEvent(new CustomEvent('auth-logout'));
|
window.dispatchEvent(new CustomEvent('auth-logout'));
|
||||||
@@ -542,45 +314,77 @@ async function handleLogout() {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
initDarkMode();
|
initDarkMode();
|
||||||
checkUserRole();
|
currentUser.value = authService.getUsername() || null;
|
||||||
connectWebSocket();
|
isAdmin.value = authService.isAdmin();
|
||||||
await checkPushStatus();
|
await checkPushStatus();
|
||||||
|
|
||||||
// Cargar username guardado si existe (pero no la contraseña)
|
// Escuchar eventos de autenticación
|
||||||
if (authService.hasCredentials()) {
|
|
||||||
const username = authService.getUsername();
|
|
||||||
if (username) {
|
|
||||||
globalLoginForm.value.username = username;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validar si el token sigue siendo válido
|
|
||||||
const isValid = await authService.validateSession();
|
|
||||||
if (!isValid) {
|
|
||||||
// Si el token expiró, limpiar sesión
|
|
||||||
authService.clearSession();
|
|
||||||
checkUserRole();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escuchar eventos de autenticación requerida
|
|
||||||
window.addEventListener('auth-required', handleAuthRequired);
|
|
||||||
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
|
||||||
|
if (authService.hasCredentials()) {
|
||||||
|
// Validar si el token sigue siendo válido
|
||||||
|
const isValid = await authService.validateSession();
|
||||||
|
if (!isValid) {
|
||||||
|
// Si el token expiró, limpiar sesión y redirigir a login
|
||||||
|
authService.clearSession();
|
||||||
|
currentUser.value = authService.getUsername() || null;
|
||||||
|
isAdmin.value = authService.isAdmin();
|
||||||
|
if (router.currentRoute.value.path !== '/login') {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Solo conectar websocket si el token es válido
|
||||||
|
connectWebSocket();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('auth-required', handleAuthRequired);
|
|
||||||
window.removeEventListener('auth-login', handleAuthChange);
|
window.removeEventListener('auth-login', handleAuthChange);
|
||||||
window.removeEventListener('auth-logout', handleAuthChange);
|
window.removeEventListener('auth-logout', handleAuthChange);
|
||||||
|
|
||||||
if (ws) {
|
if (ws) {
|
||||||
ws.close();
|
ws.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function connectWebSocket() {
|
function connectWebSocket() {
|
||||||
// Use relative path so Vite proxy can handle it
|
// Cerrar conexión existente si hay una
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
if (ws) {
|
||||||
const wsUrl = `${protocol}//${window.location.host}/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 = new WebSocket(wsUrl);
|
||||||
|
|
||||||
@@ -589,10 +393,24 @@ function connectWebSocket() {
|
|||||||
console.log('WebSocket conectado');
|
console.log('WebSocket conectado');
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = (event) => {
|
||||||
wsConnected.value = false;
|
wsConnected.value = false;
|
||||||
console.log('WebSocket desconectado, reintentando...');
|
|
||||||
setTimeout(connectWebSocket, 3000);
|
// 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) => {
|
ws.onerror = (error) => {
|
||||||
@@ -603,15 +421,7 @@ function connectWebSocket() {
|
|||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
// Manejar notificaciones de artículos nuevos
|
// Los componentes individuales manejarán los mensajes (incluyendo ToastContainer)
|
||||||
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
|
|
||||||
window.dispatchEvent(new CustomEvent('ws-message', { detail: data }));
|
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 Workers from './views/Workers.vue';
|
||||||
import Users from './views/Users.vue';
|
import Users from './views/Users.vue';
|
||||||
import Logs from './views/Logs.vue';
|
import Logs from './views/Logs.vue';
|
||||||
|
import Login from './views/Login.vue';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
import authService from './services/auth';
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/', component: Dashboard },
|
{ path: '/login', component: Login, name: 'login' },
|
||||||
{ path: '/articles', component: Articles },
|
{ path: '/', component: Dashboard, meta: { requiresAuth: true } },
|
||||||
{ path: '/favorites', component: Favorites },
|
{ path: '/articles', component: Articles, meta: { requiresAuth: true } },
|
||||||
{ path: '/workers', component: Workers },
|
{ path: '/favorites', component: Favorites, meta: { requiresAuth: true } },
|
||||||
{ path: '/users', component: Users },
|
{ path: '/workers', component: Workers, meta: { requiresAuth: true } },
|
||||||
{ path: '/logs', component: Logs },
|
{ path: '/users', component: Users, meta: { requiresAuth: true } },
|
||||||
|
{ path: '/logs', component: Logs, meta: { requiresAuth: true } },
|
||||||
];
|
];
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@@ -23,6 +26,43 @@ const router = createRouter({
|
|||||||
routes,
|
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');
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import authService from './auth';
|
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({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
@@ -119,18 +124,6 @@ export default {
|
|||||||
return response.data;
|
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
|
// Usuarios
|
||||||
async getUsers() {
|
async getUsers() {
|
||||||
const response = await api.get('/users');
|
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 {
|
@keyframes fade-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -202,3 +213,16 @@
|
|||||||
animation: fade-in 0.2s ease-out;
|
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">
|
<button @click="loadArticles" class="btn btn-primary whitespace-nowrap">
|
||||||
Actualizar
|
Actualizar
|
||||||
</button>
|
</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>
|
||||||
</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) {
|
async function loadArticles(reset = true, silent = false) {
|
||||||
if (reset) {
|
if (reset) {
|
||||||
@@ -248,7 +232,12 @@ async function loadArticles(reset = true, silent = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleAuthChange() {
|
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) {
|
if (currentUser.value) {
|
||||||
loadArticles();
|
loadArticles();
|
||||||
}
|
}
|
||||||
@@ -260,69 +249,11 @@ function loadMore() {
|
|||||||
|
|
||||||
function handleWSMessage(event) {
|
function handleWSMessage(event) {
|
||||||
const data = event.detail;
|
const data = event.detail;
|
||||||
if (data.type === 'articles_updated' || data.type === 'articles_cleared' || data.type === 'cache_cleared') {
|
if (data.type === 'articles_updated') {
|
||||||
loadArticles();
|
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) {
|
async function searchArticles(query) {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
@@ -378,7 +309,12 @@ watch(searchQuery, (newQuery) => {
|
|||||||
|
|
||||||
|
|
||||||
onMounted(() => {
|
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();
|
loadArticles();
|
||||||
window.addEventListener('ws-message', handleWSMessage);
|
window.addEventListener('ws-message', handleWSMessage);
|
||||||
window.addEventListener('auth-logout', handleAuthChange);
|
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() {
|
function handleAuthChange() {
|
||||||
checkUserRole();
|
currentUser.value = authService.getUsername() || null;
|
||||||
|
isAdmin.value = authService.isAdmin();
|
||||||
if (currentUser.value) {
|
if (currentUser.value) {
|
||||||
loadStats();
|
loadStats();
|
||||||
}
|
}
|
||||||
@@ -288,7 +282,8 @@ function handleWSMessage(event) {
|
|||||||
let interval = null;
|
let interval = null;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
checkUserRole();
|
currentUser.value = authService.getUsername() || null;
|
||||||
|
isAdmin.value = authService.isAdmin();
|
||||||
loadStats();
|
loadStats();
|
||||||
window.addEventListener('ws-message', handleWSMessage);
|
window.addEventListener('ws-message', handleWSMessage);
|
||||||
window.addEventListener('auth-logout', handleAuthChange);
|
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) {
|
function handleWSMessage(event) {
|
||||||
const data = event.detail;
|
const data = event.detail;
|
||||||
@@ -112,14 +108,16 @@ function handleWSMessage(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleAuthChange() {
|
function handleAuthChange() {
|
||||||
checkUserRole();
|
currentUser.value = authService.getUsername() || null;
|
||||||
|
isAdmin.value = authService.isAdmin();
|
||||||
if (currentUser.value) {
|
if (currentUser.value) {
|
||||||
loadFavorites();
|
loadFavorites();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
checkUserRole();
|
currentUser.value = authService.getUsername() || null;
|
||||||
|
isAdmin.value = authService.isAdmin();
|
||||||
loadFavorites();
|
loadFavorites();
|
||||||
window.addEventListener('ws-message', handleWSMessage);
|
window.addEventListener('ws-message', handleWSMessage);
|
||||||
window.addEventListener('auth-logout', handleAuthChange);
|
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';
|
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) {
|
async function loadLogs(forceReload = false, shouldScroll = null) {
|
||||||
// Verificar que el usuario es admin antes de cargar logs
|
// Verificar que el usuario es admin antes de cargar logs
|
||||||
@@ -245,7 +241,8 @@ function handleWSMessage(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
checkUserRole();
|
currentUser.value = authService.getUsername() || null;
|
||||||
|
isAdmin.value = authService.isAdmin();
|
||||||
loadLogs(true, true); // Primera carga forzada siempre hace scroll
|
loadLogs(true, true); // Primera carga forzada siempre hace scroll
|
||||||
window.addEventListener('ws-message', handleWSMessage);
|
window.addEventListener('ws-message', handleWSMessage);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user