refactor on components and delete clear, profesional login

This commit is contained in:
Omar Sánchez Pizarro
2026-01-20 18:15:49 +01:00
parent d1a8055727
commit 804efe7663
17 changed files with 831 additions and 516 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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