fixes: favoritos y mas cosas
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
7
web/frontend/package-lock.json
generated
7
web/frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "wallabicher-frontend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||
"@heroicons/vue": "^2.1.1",
|
||||
"axios": "^1.6.0",
|
||||
"chart.js": "^4.4.0",
|
||||
@@ -473,6 +474,12 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@fingerprintjs/fingerprintjs": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@fingerprintjs/fingerprintjs/-/fingerprintjs-5.0.1.tgz",
|
||||
"integrity": "sha512-KbaeE/rk2WL8MfpRP6jTI4lSr42SJPjvkyrjP3QU6uUDkOMWWYC2Ts1sNSYcegHC8avzOoYTHBj+2fTqvZWQBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@heroicons/vue": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz",
|
||||
|
||||
@@ -8,19 +8,19 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.5",
|
||||
"axios": "^1.6.0",
|
||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||
"@heroicons/vue": "^2.1.1",
|
||||
"axios": "^1.6.0",
|
||||
"chart.js": "^4.4.0",
|
||||
"vue-chartjs": "^5.2.0"
|
||||
"vue": "^3.3.4",
|
||||
"vue-chartjs": "^5.2.0",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.4.0",
|
||||
"vite": "^5.0.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.5"
|
||||
"tailwindcss": "^3.3.5",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,8 @@ import {
|
||||
Cog6ToothIcon,
|
||||
UserGroupIcon,
|
||||
DocumentMagnifyingGlassIcon,
|
||||
ShieldExclamationIcon,
|
||||
ClockIcon,
|
||||
Bars3Icon,
|
||||
XMarkIcon,
|
||||
SunIcon,
|
||||
@@ -187,6 +189,8 @@ const allNavItems = [
|
||||
{ path: '/workers', name: 'Workers', icon: Cog6ToothIcon, adminOnly: false },
|
||||
{ path: '/users', name: 'Usuarios', icon: UserGroupIcon, adminOnly: false },
|
||||
{ path: '/logs', name: 'Logs', icon: DocumentMagnifyingGlassIcon, adminOnly: true },
|
||||
{ path: '/rate-limiter', name: 'Rate Limiter', icon: ShieldExclamationIcon, adminOnly: true },
|
||||
{ path: '/sessions', name: 'Sesiones', icon: ClockIcon, adminOnly: true },
|
||||
];
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||
<!-- Imagen del artículo -->
|
||||
<div class="flex-shrink-0 self-center sm:self-start">
|
||||
<div v-if="article.images && article.images.length > 0" class="w-24 h-24 sm:w-32 sm:h-32 relative">
|
||||
<div
|
||||
v-if="article.images && article.images.length > 0"
|
||||
class="w-24 h-24 sm:w-32 sm:h-32 relative cursor-pointer hover:opacity-80 transition-opacity"
|
||||
@click="goToDetail"
|
||||
>
|
||||
<img
|
||||
:src="article.images[0]"
|
||||
:alt="article.title || 'Sin título'"
|
||||
@@ -17,7 +21,11 @@
|
||||
@error="($event) => handleImageError($event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="w-24 h-24 sm:w-32 sm:h-32 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<div
|
||||
v-else
|
||||
class="w-24 h-24 sm:w-32 sm:h-32 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity"
|
||||
@click="goToDetail"
|
||||
>
|
||||
<span class="text-gray-400 dark:text-gray-500 text-xs">Sin imagen</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,7 +59,11 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1 line-clamp-2" :title="article.title">
|
||||
<h3
|
||||
class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1 line-clamp-2 cursor-pointer hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
|
||||
:title="article.title"
|
||||
@click="goToDetail"
|
||||
>
|
||||
{{ article.title || 'Sin título' }}
|
||||
</h3>
|
||||
|
||||
@@ -130,11 +142,14 @@
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { HeartIcon } from '@heroicons/vue/24/outline';
|
||||
import { HeartIcon as HeartIconSolid } from '@heroicons/vue/24/solid';
|
||||
import authService from '../services/auth';
|
||||
import api from '../services/api';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const props = defineProps({
|
||||
article: {
|
||||
type: Object,
|
||||
@@ -158,7 +173,8 @@ const emit = defineEmits(['remove', 'added']);
|
||||
|
||||
const isAdding = ref(false);
|
||||
const isAuthenticated = ref(false);
|
||||
const favoriteStatus = ref(props.isFavorite);
|
||||
// Usar is_favorite directamente del artículo, con fallback a props.isFavorite
|
||||
const favoriteStatus = ref(props.article?.is_favorite ?? props.isFavorite);
|
||||
|
||||
// Verificar autenticación al montar y cuando cambie
|
||||
function checkAuth() {
|
||||
@@ -218,10 +234,26 @@ async function handleAddFavorite() {
|
||||
|
||||
function handleAuthChange() {
|
||||
checkAuth();
|
||||
// Actualizar favoriteStatus basado en el artículo (que viene del backend)
|
||||
if (props.article) {
|
||||
favoriteStatus.value = props.article.is_favorite ?? false;
|
||||
} else {
|
||||
favoriteStatus.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goToDetail() {
|
||||
if (props.article.platform && props.article.id) {
|
||||
router.push(`/articles/${props.article.platform}/${props.article.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkAuth();
|
||||
// Inicializar favoriteStatus desde el artículo
|
||||
if (props.article) {
|
||||
favoriteStatus.value = props.article.is_favorite ?? false;
|
||||
}
|
||||
// Escuchar cambios en la autenticación
|
||||
window.addEventListener('auth-login', handleAuthChange);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<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"
|
||||
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 cursor-pointer hover:shadow-2xl hover:scale-[1.02] transition-all duration-200"
|
||||
@click="goToDetail"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
@@ -37,14 +38,15 @@
|
||||
:href="toast.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click.stop
|
||||
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"
|
||||
@click.stop="$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 z-10"
|
||||
title="Cerrar"
|
||||
>
|
||||
✕
|
||||
@@ -56,13 +58,23 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const props = defineProps({
|
||||
toast: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['close']);
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function goToDetail() {
|
||||
if (props.toast.platform && props.toast.id) {
|
||||
router.push(`/articles/${props.toast.platform}/${props.toast.id}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@ import { createRouter, createWebHistory } from 'vue-router';
|
||||
import App from './App.vue';
|
||||
import Dashboard from './views/Dashboard.vue';
|
||||
import Articles from './views/Articles.vue';
|
||||
import ArticleDetail from './views/ArticleDetail.vue';
|
||||
import Favorites from './views/Favorites.vue';
|
||||
import Workers from './views/Workers.vue';
|
||||
import Users from './views/Users.vue';
|
||||
import Logs from './views/Logs.vue';
|
||||
import RateLimiter from './views/RateLimiter.vue';
|
||||
import Sessions from './views/Sessions.vue';
|
||||
import Login from './views/Login.vue';
|
||||
import './style.css';
|
||||
import authService from './services/auth';
|
||||
@@ -15,10 +18,13 @@ const routes = [
|
||||
{ path: '/login', component: Login, name: 'login' },
|
||||
{ path: '/', component: Dashboard, meta: { requiresAuth: true } },
|
||||
{ path: '/articles', component: Articles, meta: { requiresAuth: true } },
|
||||
{ path: '/articles/:platform/:id', component: ArticleDetail, meta: { requiresAuth: true } },
|
||||
{ path: '/favorites', component: Favorites, meta: { requiresAuth: true } },
|
||||
{ path: '/workers', component: Workers, meta: { requiresAuth: true } },
|
||||
{ path: '/users', component: Users, meta: { requiresAuth: true } },
|
||||
{ path: '/logs', component: Logs, meta: { requiresAuth: true } },
|
||||
{ path: '/rate-limiter', component: RateLimiter, meta: { requiresAuth: true } },
|
||||
{ path: '/sessions', component: Sessions, meta: { requiresAuth: true } },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -97,6 +97,11 @@ export default {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getArticle(platform, id) {
|
||||
const response = await api.get(`/articles/${platform}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Logs
|
||||
async getLogs(limit = 500, sinceLine = null) {
|
||||
const params = { limit };
|
||||
@@ -149,5 +154,22 @@ export default {
|
||||
const response = await api.post('/users/change-password', passwordData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Admin - Rate Limiter
|
||||
async getRateLimiterInfo() {
|
||||
const response = await api.get('/admin/rate-limiter');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Admin - Sessions
|
||||
async getSessions() {
|
||||
const response = await api.get('/admin/sessions');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async deleteSession(token) {
|
||||
const response = await api.delete(`/admin/sessions/${token}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Servicio de autenticación para gestionar tokens
|
||||
import { getDeviceFingerprint } from './fingerprint.js';
|
||||
|
||||
const AUTH_STORAGE_KEY = 'wallabicher_token';
|
||||
const USERNAME_STORAGE_KEY = 'wallabicher_username';
|
||||
@@ -109,12 +110,31 @@ class AuthService {
|
||||
// Hacer login (llamar al endpoint de login)
|
||||
async login(username, password) {
|
||||
try {
|
||||
// Obtener fingerprint del dispositivo
|
||||
let fingerprintData = null;
|
||||
try {
|
||||
fingerprintData = await getDeviceFingerprint();
|
||||
} catch (error) {
|
||||
console.warn('Error obteniendo fingerprint, continuando sin él:', error);
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
username,
|
||||
password,
|
||||
};
|
||||
|
||||
// Agregar fingerprint si está disponible
|
||||
if (fingerprintData) {
|
||||
requestBody.fingerprint = fingerprintData.fingerprint;
|
||||
requestBody.deviceInfo = fingerprintData.deviceInfo;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/users/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
154
web/frontend/src/services/fingerprint.js
Normal file
154
web/frontend/src/services/fingerprint.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import FingerprintJS from '@fingerprintjs/fingerprintjs';
|
||||
|
||||
let fpPromise = null;
|
||||
let cachedFingerprint = null;
|
||||
let cachedDeviceInfo = null;
|
||||
|
||||
/**
|
||||
* Inicializa FingerprintJS (solo una vez)
|
||||
*/
|
||||
function initFingerprintJS() {
|
||||
if (!fpPromise) {
|
||||
fpPromise = FingerprintJS.load();
|
||||
}
|
||||
return fpPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el fingerprint del dispositivo
|
||||
* @returns {Promise<{fingerprint: string, deviceInfo: Object}>}
|
||||
*/
|
||||
export async function getDeviceFingerprint() {
|
||||
// Si ya tenemos el fingerprint en caché, devolverlo
|
||||
if (cachedFingerprint && cachedDeviceInfo) {
|
||||
return {
|
||||
fingerprint: cachedFingerprint,
|
||||
deviceInfo: cachedDeviceInfo,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const fp = await initFingerprintJS();
|
||||
const result = await fp.get();
|
||||
|
||||
// Extraer información del dispositivo desde los componentes
|
||||
const deviceInfo = extractDeviceInfo(result.components);
|
||||
|
||||
cachedFingerprint = result.visitorId;
|
||||
cachedDeviceInfo = deviceInfo;
|
||||
|
||||
return {
|
||||
fingerprint: result.visitorId,
|
||||
deviceInfo: deviceInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo fingerprint:', error);
|
||||
// Fallback: generar un fingerprint básico
|
||||
return {
|
||||
fingerprint: generateFallbackFingerprint(),
|
||||
deviceInfo: {
|
||||
browser: navigator.userAgent.includes('Chrome') ? 'Chrome' :
|
||||
navigator.userAgent.includes('Firefox') ? 'Firefox' :
|
||||
navigator.userAgent.includes('Safari') ? 'Safari' : 'Unknown',
|
||||
os: navigator.platform,
|
||||
device: 'Unknown',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae información legible del dispositivo desde los componentes de FingerprintJS
|
||||
* @param {Object} components - Componentes de FingerprintJS
|
||||
* @returns {Object} Información del dispositivo
|
||||
*/
|
||||
function extractDeviceInfo(components) {
|
||||
const info = {
|
||||
browser: 'Unknown',
|
||||
browserVersion: '',
|
||||
os: 'Unknown',
|
||||
osVersion: '',
|
||||
device: 'Unknown',
|
||||
screenResolution: '',
|
||||
timezone: '',
|
||||
language: navigator.language || '',
|
||||
};
|
||||
|
||||
// Información del navegador
|
||||
if (components.browserName) {
|
||||
info.browser = components.browserName.value || 'Unknown';
|
||||
}
|
||||
if (components.browserVersion) {
|
||||
info.browserVersion = components.browserVersion.value || '';
|
||||
}
|
||||
|
||||
// Información del sistema operativo
|
||||
if (components.os) {
|
||||
info.os = components.os.value || 'Unknown';
|
||||
}
|
||||
if (components.osVersion) {
|
||||
info.osVersion = components.osVersion.value || '';
|
||||
}
|
||||
|
||||
// Información del dispositivo
|
||||
if (components.deviceMemory) {
|
||||
info.device = components.deviceMemory.value ? 'Desktop' : 'Mobile';
|
||||
}
|
||||
if (components.platform) {
|
||||
const platform = components.platform.value?.toLowerCase() || '';
|
||||
if (platform.includes('mobile') || platform.includes('android') || platform.includes('iphone')) {
|
||||
info.device = 'Mobile';
|
||||
} else if (platform.includes('tablet') || platform.includes('ipad')) {
|
||||
info.device = 'Tablet';
|
||||
} else {
|
||||
info.device = 'Desktop';
|
||||
}
|
||||
}
|
||||
|
||||
// Resolución de pantalla
|
||||
if (components.screenResolution) {
|
||||
const res = components.screenResolution.value;
|
||||
if (res && res.length >= 2) {
|
||||
info.screenResolution = `${res[0]}x${res[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Zona horaria
|
||||
if (components.timezone) {
|
||||
info.timezone = components.timezone.value || '';
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un fingerprint básico como fallback
|
||||
* @returns {string} Hash del fingerprint
|
||||
*/
|
||||
function generateFallbackFingerprint() {
|
||||
const data = [
|
||||
navigator.userAgent,
|
||||
navigator.language,
|
||||
navigator.platform,
|
||||
screen.width + 'x' + screen.height,
|
||||
new Date().getTimezoneOffset(),
|
||||
].join('|');
|
||||
|
||||
// Simple hash (no usar en producción, solo como fallback)
|
||||
let hash = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const char = data.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia el caché del fingerprint (útil para testing)
|
||||
*/
|
||||
export function clearFingerprintCache() {
|
||||
cachedFingerprint = null;
|
||||
cachedDeviceInfo = null;
|
||||
}
|
||||
|
||||
476
web/frontend/src/views/ArticleDetail.vue
Normal file
476
web/frontend/src/views/ArticleDetail.vue
Normal file
@@ -0,0 +1,476 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- Botón de volver -->
|
||||
<div class="mb-3">
|
||||
<button
|
||||
@click="$router.back()"
|
||||
class="btn btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<ArrowLeftIcon class="w-4 h-4" />
|
||||
Volver
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="card text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando artículo...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="card text-center py-12">
|
||||
<ExclamationTriangleIcon class="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">{{ error }}</p>
|
||||
<button @click="$router.back()" class="btn btn-secondary mt-4">Volver</button>
|
||||
</div>
|
||||
|
||||
<!-- Contenido del artículo -->
|
||||
<div v-else-if="article" class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<!-- Columna izquierda: Carousel de imágenes -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="card p-0 overflow-hidden">
|
||||
<!-- Carousel principal -->
|
||||
<div v-if="article.images && article.images.length > 0" class="relative">
|
||||
<!-- Imagen principal -->
|
||||
<div class="relative aspect-[4/3] bg-gray-100 dark:bg-gray-900 overflow-hidden">
|
||||
<img
|
||||
:src="article.images[currentImageIndex]"
|
||||
:alt="`Imagen ${currentImageIndex + 1} de ${article.title}`"
|
||||
class="w-full h-full object-contain transition-opacity duration-300"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- Botones de navegación -->
|
||||
<button
|
||||
v-if="article.images.length > 1"
|
||||
@click="previousImage"
|
||||
class="absolute left-2 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white p-2 rounded-full transition-all z-10"
|
||||
:disabled="article.images.length <= 1"
|
||||
>
|
||||
<ChevronLeftIcon class="w-6 h-6" />
|
||||
</button>
|
||||
<button
|
||||
v-if="article.images.length > 1"
|
||||
@click="nextImage"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white p-2 rounded-full transition-all z-10"
|
||||
:disabled="article.images.length <= 1"
|
||||
>
|
||||
<ChevronRightIcon class="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<!-- Indicador de posición -->
|
||||
<div
|
||||
v-if="article.images.length > 1"
|
||||
class="absolute bottom-3 left-1/2 -translate-x-1/2 bg-black/50 text-white px-3 py-1 rounded-full text-xs font-medium z-10"
|
||||
>
|
||||
{{ currentImageIndex + 1 }} / {{ article.images.length }}
|
||||
</div>
|
||||
|
||||
<!-- Botón para ampliar -->
|
||||
<button
|
||||
@click="openImageModal(article.images[currentImageIndex])"
|
||||
class="absolute top-3 right-3 bg-black/50 hover:bg-black/70 text-white p-2 rounded-full transition-all z-10"
|
||||
>
|
||||
<MagnifyingGlassPlusIcon class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Miniaturas -->
|
||||
<div
|
||||
v-if="article.images.length > 1"
|
||||
class="p-3 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="flex gap-2 overflow-x-auto pb-2 scrollbar-hide">
|
||||
<button
|
||||
v-for="(image, index) in article.images"
|
||||
:key="index"
|
||||
@click="currentImageIndex = index"
|
||||
class="flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 transition-all"
|
||||
:class="
|
||||
currentImageIndex === index
|
||||
? 'border-primary-600 dark:border-primary-400 ring-2 ring-primary-200 dark:ring-primary-800'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
"
|
||||
>
|
||||
<img
|
||||
:src="image"
|
||||
:alt="`Miniatura ${index + 1}`"
|
||||
class="w-full h-full object-cover"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="aspect-[4/3] bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span class="text-gray-400 dark:text-gray-500">Sin imágenes disponibles</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Columna derecha: Información del artículo -->
|
||||
<div class="lg:col-span-1 space-y-4">
|
||||
<!-- Header compacto -->
|
||||
<div class="card">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-semibold rounded"
|
||||
:class="
|
||||
article.platform === 'wallapop'
|
||||
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-300'
|
||||
: 'bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-300'
|
||||
"
|
||||
>
|
||||
{{ article.platform?.toUpperCase() || 'N/A' }}
|
||||
</span>
|
||||
<span v-if="article.username" class="px-2 py-1 text-xs font-medium rounded bg-purple-100 dark:bg-purple-900/50 text-purple-800 dark:text-purple-300">
|
||||
👤 {{ article.username }}
|
||||
</span>
|
||||
<span v-if="article.worker_name" class="px-2 py-1 text-xs font-medium rounded bg-orange-100 dark:bg-orange-900/50 text-orange-800 dark:text-orange-300">
|
||||
⚙️ {{ article.worker_name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-3 line-clamp-2">
|
||||
{{ article.title || 'Sin título' }}
|
||||
</h1>
|
||||
|
||||
<div v-if="article.price !== null && article.price !== undefined" class="mb-4">
|
||||
<span class="text-3xl font-bold text-primary-600 dark:text-primary-400">
|
||||
{{ article.price }} {{ article.currency || '€' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<a
|
||||
v-if="article.url"
|
||||
:href="article.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-primary flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon class="w-4 h-4" />
|
||||
Ver anuncio original
|
||||
</a>
|
||||
<button
|
||||
v-if="isAuthenticated && !isAdding"
|
||||
@click="handleAddFavorite"
|
||||
class="btn flex items-center justify-center gap-2 text-sm"
|
||||
:class="favoriteStatus ? 'btn-secondary' : 'bg-pink-500 hover:bg-pink-600 text-white border-pink-600'"
|
||||
:disabled="favoriteStatus"
|
||||
>
|
||||
<HeartIconSolid v-if="favoriteStatus" class="w-4 h-4" />
|
||||
<HeartIcon v-else class="w-4 h-4" />
|
||||
{{ favoriteStatus ? 'En favoritos' : 'Añadir a favoritos' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="isAuthenticated && isAdding"
|
||||
disabled
|
||||
class="btn btn-secondary opacity-50 cursor-not-allowed text-sm"
|
||||
>
|
||||
<span class="inline-block animate-spin mr-1">⏳</span>
|
||||
Añadiendo...
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Información compacta -->
|
||||
<div class="card">
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Información</h2>
|
||||
<div class="space-y-3">
|
||||
<div v-if="article.location" class="flex items-start gap-2">
|
||||
<MapPinIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Localidad</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100 truncate">{{ article.location }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="article.allows_shipping !== null && article.allows_shipping !== undefined" class="flex items-start gap-2">
|
||||
<TruckIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Envío</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ article.allows_shipping ? '✅ Acepta envíos' : '❌ No acepta envíos' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="article.modified_at" class="flex items-start gap-2">
|
||||
<ClockIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Modificado</p>
|
||||
<p class="text-xs text-gray-900 dark:text-gray-100">{{ formatDateShort(article.modified_at) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="article.notifiedAt" class="flex items-start gap-2">
|
||||
<BellIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Notificado</p>
|
||||
<p class="text-xs text-gray-900 dark:text-gray-100">{{ formatDateShort(article.notifiedAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-2">
|
||||
<HashtagIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">ID</p>
|
||||
<p class="text-xs text-gray-900 dark:text-gray-100 font-mono truncate">{{ article.id }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Descripción (debajo del grid principal) -->
|
||||
<div v-if="article && article.description" class="mt-4">
|
||||
<div class="card">
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Descripción</h2>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||
{{ article.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de imagen -->
|
||||
<div
|
||||
v-if="selectedImage"
|
||||
@click="selectedImage = null"
|
||||
class="fixed inset-0 z-50 bg-black bg-opacity-90 flex items-center justify-center p-4 cursor-pointer"
|
||||
>
|
||||
<div class="relative max-w-6xl max-h-full w-full" @click.stop>
|
||||
<button
|
||||
@click="selectedImage = null"
|
||||
class="absolute -top-12 right-0 text-white hover:text-gray-300 transition-colors z-10"
|
||||
>
|
||||
<XMarkIcon class="w-8 h-8" />
|
||||
</button>
|
||||
<img
|
||||
:src="selectedImage"
|
||||
alt="Imagen ampliada"
|
||||
class="max-w-full max-h-[90vh] object-contain rounded-lg mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
HeartIcon,
|
||||
MagnifyingGlassPlusIcon,
|
||||
MapPinIcon,
|
||||
TruckIcon,
|
||||
ClockIcon,
|
||||
BellIcon,
|
||||
HashtagIcon,
|
||||
XMarkIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import { HeartIcon as HeartIconSolid } from '@heroicons/vue/24/solid';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const article = ref(null);
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
const selectedImage = ref(null);
|
||||
const currentImageIndex = ref(0);
|
||||
const isAuthenticated = ref(false);
|
||||
const favoriteStatus = ref(false);
|
||||
const isAdding = ref(false);
|
||||
|
||||
function formatDateShort(timestamp) {
|
||||
if (!timestamp) return 'N/A';
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
if (!timestamp) return 'N/A';
|
||||
return new Date(timestamp).toLocaleString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function handleImageError(event) {
|
||||
event.target.onerror = null;
|
||||
event.target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgdmlld0JveD0iMCAwIDEyOCAxMjgiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik00OCA0OEg4ME04MCA4MEg0OE00OCA0OEw2NCA2NEw4MCA0OE00OCA4MEw2NCA2NE04MCA4MEw2NCA2NEw0OCA4MCIgc3Ryb2tlPSIjOUI5Q0E0IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K';
|
||||
}
|
||||
|
||||
function openImageModal(image) {
|
||||
selectedImage.value = image;
|
||||
}
|
||||
|
||||
function nextImage() {
|
||||
if (article.value?.images && article.value.images.length > 0) {
|
||||
currentImageIndex.value = (currentImageIndex.value + 1) % article.value.images.length;
|
||||
}
|
||||
}
|
||||
|
||||
function previousImage() {
|
||||
if (article.value?.images && article.value.images.length > 0) {
|
||||
currentImageIndex.value = currentImageIndex.value === 0
|
||||
? article.value.images.length - 1
|
||||
: currentImageIndex.value - 1;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (!article.value?.images || article.value.images.length <= 1) return;
|
||||
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
previousImage();
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
nextImage();
|
||||
} else if (event.key === 'Escape' && selectedImage.value) {
|
||||
selectedImage.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function checkAuth() {
|
||||
isAuthenticated.value = authService.hasCredentials();
|
||||
}
|
||||
|
||||
async function handleAddFavorite() {
|
||||
if (!isAuthenticated.value || favoriteStatus.value || isAdding.value || !article.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isAdding.value = true;
|
||||
|
||||
try {
|
||||
const favorite = {
|
||||
platform: article.value.platform,
|
||||
id: String(article.value.id),
|
||||
};
|
||||
|
||||
await api.addFavorite(favorite);
|
||||
favoriteStatus.value = true;
|
||||
} catch (error) {
|
||||
console.error('Error añadiendo a favoritos:', error);
|
||||
if (error.response?.status === 404) {
|
||||
alert('El artículo no se encontró en la base de datos.');
|
||||
} else if (error.response?.status === 400) {
|
||||
alert('Error: ' + (error.response?.data?.error || 'Datos inválidos'));
|
||||
} else if (error.response?.status !== 401) {
|
||||
const errorMessage = error.response?.data?.error || error.message || 'Error desconocido';
|
||||
alert('Error al añadir a favoritos: ' + errorMessage);
|
||||
}
|
||||
} finally {
|
||||
isAdding.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthChange() {
|
||||
checkAuth();
|
||||
// Actualizar favoriteStatus basado en el artículo (que viene del backend)
|
||||
if (article.value) {
|
||||
favoriteStatus.value = article.value.is_favorite ?? false;
|
||||
} else {
|
||||
favoriteStatus.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadArticle() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const platform = route.params.platform;
|
||||
const id = route.params.id;
|
||||
|
||||
if (!platform || !id) {
|
||||
error.value = 'Parámetros inválidos';
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
article.value = await api.getArticle(platform, id);
|
||||
|
||||
if (!article.value) {
|
||||
error.value = 'Artículo no encontrado';
|
||||
} else {
|
||||
currentImageIndex.value = 0;
|
||||
checkAuth();
|
||||
// Actualizar favoriteStatus desde el artículo (que viene del backend con is_favorite)
|
||||
favoriteStatus.value = article.value.is_favorite ?? false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error cargando artículo:', err);
|
||||
if (err.response?.status === 404) {
|
||||
error.value = 'Artículo no encontrado';
|
||||
} else if (err.response?.status === 403) {
|
||||
error.value = 'No tienes permiso para ver este artículo';
|
||||
} else {
|
||||
error.value = 'Error al cargar el artículo: ' + (err.message || 'Error desconocido');
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadArticle();
|
||||
checkAuth();
|
||||
window.addEventListener('auth-login', handleAuthChange);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('auth-login', handleAuthChange);
|
||||
window.removeEventListener('auth-logout', handleAuthChange);
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 inline-flex items-center justify-center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 hover:bg-primary-700 text-white border border-primary-600;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
209
web/frontend/src/views/RateLimiter.vue
Normal file
209
web/frontend/src/views/RateLimiter.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Rate Limiter</h1>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@click="loadRateLimiterInfo(true)"
|
||||
class="btn btn-primary text-xs sm:text-sm whitespace-nowrap"
|
||||
>
|
||||
🔄 Actualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="accessDenied || (!isAdmin && currentUser)" class="card text-center py-12">
|
||||
<ShieldExclamationIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||
<p class="text-gray-600 dark:text-gray-400 text-lg font-semibold">Acceso Denegado</p>
|
||||
<p class="text-gray-400 dark:text-gray-500 text-sm mt-2">
|
||||
Solo los administradores pueden ver el rate limiter
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Estadísticas -->
|
||||
<div v-if="rateLimiterInfo && rateLimiterInfo.enabled" class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||
<div class="card p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total de Claves</div>
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ rateLimiterInfo.stats?.totalBlocks || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Bloqueos Activos</div>
|
||||
<div class="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||
{{ rateLimiterInfo.stats?.activeBlocks || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Tipo</div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100 capitalize">
|
||||
{{ rateLimiterInfo.type }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuración -->
|
||||
<div v-if="rateLimiterInfo && rateLimiterInfo.config" class="card p-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Configuración</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Puntos</div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ rateLimiterInfo.config.points }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Duración (seg)</div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ rateLimiterInfo.config.duration }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Duración Bloqueo (seg)</div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ rateLimiterInfo.config.blockDuration }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mensaje si no está habilitado -->
|
||||
<div v-if="rateLimiterInfo && !rateLimiterInfo.enabled" class="card p-6 text-center">
|
||||
<ShieldExclamationIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||
<p class="text-gray-600 dark:text-gray-400 text-lg font-semibold">Rate Limiter Deshabilitado</p>
|
||||
<p class="text-gray-400 dark:text-gray-500 text-sm mt-2">
|
||||
{{ rateLimiterInfo.message || 'El rate limiter no está configurado' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Nota para rate limiter en memoria -->
|
||||
<div v-if="rateLimiterInfo && rateLimiterInfo.note" class="card p-4 mb-6 bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800">
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-200">{{ rateLimiterInfo.note }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Lista de bloqueos -->
|
||||
<div v-if="rateLimiterInfo && rateLimiterInfo.blocks && rateLimiterInfo.blocks.length > 0" class="card p-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Bloqueos</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Clave/IP
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Estado
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Puntos Restantes
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Total Hits
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Tiempo Restante
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="block in rateLimiterInfo.blocks" :key="block.key">
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-900 dark:text-gray-100">
|
||||
{{ block.key }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
:class="block.isBlocked
|
||||
? 'px-2 py-1 text-xs font-semibold rounded bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
|
||||
: 'px-2 py-1 text-xs font-semibold rounded bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'"
|
||||
>
|
||||
{{ block.isBlocked ? 'Bloqueado' : 'Activo' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ block.remainingPoints }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ block.totalHits }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ formatTimeRemaining(block.msBeforeNext) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="rateLimiterInfo && rateLimiterInfo.enabled" class="card p-6 text-center">
|
||||
<p class="text-gray-600 dark:text-gray-400">No hay bloqueos registrados</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="card p-6 text-center">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando información del rate limiter...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import { ShieldExclamationIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
const rateLimiterInfo = ref(null);
|
||||
const loading = ref(true);
|
||||
const currentUser = ref(authService.getUsername() || null);
|
||||
const isAdmin = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
|
||||
function formatTimeRemaining(ms) {
|
||||
if (!ms || ms <= 0) return 'N/A';
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRateLimiterInfo(showLoading = false) {
|
||||
if (!isAdmin.value) {
|
||||
accessDenied.value = true;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
accessDenied.value = false;
|
||||
|
||||
if (showLoading) {
|
||||
loading.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.getRateLimiterInfo();
|
||||
rateLimiterInfo.value = data;
|
||||
} catch (error) {
|
||||
console.error('Error cargando información del rate limiter:', error);
|
||||
if (error.response?.status === 403) {
|
||||
accessDenied.value = true;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
loadRateLimiterInfo(true);
|
||||
});
|
||||
</script>
|
||||
|
||||
261
web/frontend/src/views/Sessions.vue
Normal file
261
web/frontend/src/views/Sessions.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Sesiones</h1>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@click="loadSessions(true)"
|
||||
class="btn btn-primary text-xs sm:text-sm whitespace-nowrap"
|
||||
>
|
||||
🔄 Actualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="accessDenied || (!isAdmin && currentUser)" class="card text-center py-12">
|
||||
<UserGroupIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||
<p class="text-gray-600 dark:text-gray-400 text-lg font-semibold">Acceso Denegado</p>
|
||||
<p class="text-gray-400 dark:text-gray-500 text-sm mt-2">
|
||||
Solo los administradores pueden ver las sesiones
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Estadísticas -->
|
||||
<div v-if="sessionsData && sessionsData.stats" class="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
|
||||
<div class="card p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Sesiones</div>
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ sessionsData.stats.total || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Sesiones Activas</div>
|
||||
<div class="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{{ sessionsData.stats.active || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Sesiones Expiradas</div>
|
||||
<div class="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||
{{ sessionsData.stats.expired || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Usuarios Únicos</div>
|
||||
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{{ Object.keys(sessionsData.stats.byUser || {}).length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sesiones por usuario -->
|
||||
<div v-if="sessionsData && sessionsData.stats && sessionsData.stats.byUser && Object.keys(sessionsData.stats.byUser).length > 0" class="card p-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Sesiones por Usuario</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div
|
||||
v-for="(count, username) in sessionsData.stats.byUser"
|
||||
:key="username"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
||||
>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ username }}</span>
|
||||
<span class="px-2 py-1 text-sm font-semibold rounded bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
|
||||
{{ count }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de sesiones -->
|
||||
<div v-if="sessionsData && sessionsData.sessions && sessionsData.sessions.length > 0" class="card p-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Todas las Sesiones</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Usuario
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Dispositivo
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Token
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Creada
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Expira
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Estado
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="session in sessionsData.sessions" :key="session.token">
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ session.username }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
<div v-if="session.deviceInfo" class="space-y-1">
|
||||
<div class="font-medium">
|
||||
{{ formatDeviceInfo(session.deviceInfo) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ session.deviceInfo.os || 'Unknown OS' }}
|
||||
<span v-if="session.deviceInfo.osVersion"> {{ session.deviceInfo.osVersion }}</span>
|
||||
</div>
|
||||
<div v-if="session.deviceInfo.ip" class="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
IP: {{ session.deviceInfo.ip }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-400 dark:text-gray-500 italic">
|
||||
Sin información
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-mono text-gray-600 dark:text-gray-400">
|
||||
<span class="truncate max-w-xs inline-block" :title="session.token">
|
||||
{{ session.token.substring(0, 16) }}...
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ formatDate(session.createdAt) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ formatDate(session.expiresAt) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
:class="session.isExpired
|
||||
? 'px-2 py-1 text-xs font-semibold rounded bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
|
||||
: 'px-2 py-1 text-xs font-semibold rounded bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'"
|
||||
>
|
||||
{{ session.isExpired ? 'Expirada' : 'Activa' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">
|
||||
<button
|
||||
@click="confirmDeleteSession(session.token)"
|
||||
class="btn btn-danger text-xs"
|
||||
:disabled="session.isExpired"
|
||||
title="Eliminar sesión"
|
||||
>
|
||||
🗑️ Eliminar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="sessionsData && sessionsData.sessions && sessionsData.sessions.length === 0" class="card p-6 text-center">
|
||||
<UserGroupIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||
<p class="text-gray-600 dark:text-gray-400">No hay sesiones registradas</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="card p-6 text-center">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando sesiones...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import { UserGroupIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
const sessionsData = ref(null);
|
||||
const loading = ref(true);
|
||||
const currentUser = ref(authService.getUsername() || null);
|
||||
const isAdmin = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDeviceInfo(deviceInfo) {
|
||||
if (!deviceInfo) return 'Unknown';
|
||||
|
||||
const parts = [];
|
||||
|
||||
if (deviceInfo.browser) {
|
||||
parts.push(deviceInfo.browser);
|
||||
if (deviceInfo.browserVersion) {
|
||||
parts.push(deviceInfo.browserVersion.split('.')[0]); // Solo versión mayor
|
||||
}
|
||||
}
|
||||
|
||||
if (deviceInfo.device && deviceInfo.device !== 'Desktop') {
|
||||
parts.push(`(${deviceInfo.device})`);
|
||||
}
|
||||
|
||||
return parts.join(' ') || 'Unknown';
|
||||
}
|
||||
|
||||
async function loadSessions(showLoading = false) {
|
||||
if (!isAdmin.value) {
|
||||
accessDenied.value = true;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
accessDenied.value = false;
|
||||
|
||||
if (showLoading) {
|
||||
loading.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.getSessions();
|
||||
sessionsData.value = data;
|
||||
} catch (error) {
|
||||
console.error('Error cargando sesiones:', error);
|
||||
if (error.response?.status === 403) {
|
||||
accessDenied.value = true;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteSession(token) {
|
||||
if (!confirm('¿Estás seguro de que quieres eliminar esta sesión?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.deleteSession(token);
|
||||
// Recargar sesiones después de eliminar
|
||||
await loadSessions(false);
|
||||
} catch (error) {
|
||||
console.error('Error eliminando sesión:', error);
|
||||
alert('Error al eliminar la sesión: ' + (error.response?.data?.error || error.message));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
loadSessions(true);
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user