1 Commits

Author SHA1 Message Date
Omar Sánchez Pizarro
03bc550dab refactor frontend
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
2026-01-20 23:02:09 +01:00
129 changed files with 2125 additions and 13446 deletions

View File

@@ -18,8 +18,8 @@ yarn-debug.log*
yarn-error.log*
# Web
web/dashboard/dist/
web/dashboard/.vite/
web/frontend/dist/
web/frontend/.vite/
# IDE
.vscode/

4
.gitignore vendored
View File

@@ -177,5 +177,5 @@ yarn-debug.log*
yarn-error.log*
# Web build
web/dashboard/dist/
web/dashboard/.vite/
web/frontend/dist/
web/frontend/.vite/

View File

@@ -31,7 +31,7 @@ docker-compose up -d
Esto iniciará:
- **Redis** (puerto 6379) - Cache de artículos
- **Backend** (puerto 3001) - API Node.js
- **Dashboard** (puerto 3000) - Interfaz web Vue
- **Frontend** (puerto 3000) - Interfaz web Vue
- **Wallabicher Python** - Servicio principal de monitoreo
### 3. Acceder a la interfaz
@@ -51,7 +51,7 @@ Abre tu navegador en: **http://localhost:3000**
- **WebSocket**: ws://localhost:3001
- **Funciones**: API REST y WebSockets para la interfaz web
### Dashboard (Vue + Nginx)
### Frontend (Vue + Nginx)
- **Puerto**: 3000
- **URL**: http://localhost:3000
- **Funciones**: Interfaz web moderna
@@ -70,7 +70,7 @@ docker-compose logs -f
# Servicio específico
docker-compose logs -f wallabicher
docker-compose logs -f backend
docker-compose logs -f dashboard
docker-compose logs -f frontend
```
### Detener servicios
@@ -158,11 +158,11 @@ rm -rf monitor.log
mkdir -p logs
```
### El dashboard no carga
### El frontend no carga
Verifica los logs:
```bash
docker-compose logs dashboard
docker-compose logs frontend
```
### Reconstruir todo desde cero

View File

@@ -1,324 +0,0 @@
# Sistema de Keepalive y Seguimiento de Usuarios Activos
## 📝 Descripción
El sistema de keepalive permite rastrear en tiempo real qué usuarios están activos y cuándo fue su última actividad. Utiliza el WebSocket existente para mantener una conexión persistente y enviar heartbeats periódicos.
## 🎯 Características
### Backend
1. **WebSocket con Heartbeat**
- Envío automático de ping cada 60 segundos
- Detección de conexiones inactivas
- Cierre automático de conexiones sin respuesta
2. **Seguimiento de Actividad**
- Registro de última actividad en sesiones
- Estado activo/inactivo por usuario
- Timeout de inactividad: 5 minutos
3. **Base de Datos**
- Campo `lastActivity` en sesiones
- Campo `isActive` para marcar usuarios conectados
- Índices optimizados para consultas rápidas
4. **Endpoints**
- `GET /api/users/active` - Obtener usuarios activos en tiempo real
- `GET /api/admin/sessions` - Obtener todas las sesiones con estado de actividad
- Ambos requieren autenticación (el segundo requiere rol admin)
### Frontend
1. **Heartbeat Automático**
- Envío de heartbeat cada 30 segundos
- Respuesta automática a pings del servidor
2. **Detección de Actividad**
- Seguimiento de clicks, teclas, scroll y movimientos del ratón
- Throttling de 10 segundos para no sobrecargar el servidor
- Envío automático de eventos de actividad
3. **Componente de Usuarios Activos**
- Lista de usuarios conectados en tiempo real
- Indicadores de estado (activo/inactivo)
- Actualización automática cada 30 segundos
- Actualización en tiempo real vía WebSocket
## 🚀 Uso
### Ver Usuarios Activos
Los administradores pueden ver los usuarios activos en:
- **Dashboard principal** - Componente "Usuarios Activos" en la parte superior
- **Vista de Sesiones** - `/sessions` - Muestra todas las sesiones con estado de conexión
- **Endpoint API**: `GET /api/users/active`
### Ver Estado de Sesiones
La vista de **Sesiones** (`/sessions`) ahora muestra información detallada sobre el estado de cada sesión:
**Estadísticas mejoradas:**
- Total de sesiones
- Sesiones válidas (no expiradas)
- 🟢 Sesiones conectadas (con actividad reciente < 5 min)
- 🟡 Sesiones inactivas (válidas pero sin actividad)
- 🔴 Sesiones expiradas
- Usuarios únicos
**Por cada sesión:**
- Estado de validez: ✅ Válida / 🔴 Expirada
- Estado de conexión: 🟢 Conectado / 🟡 Inactivo
- Última actividad (relativa y absoluta)
- Información del dispositivo
- Token y fechas de creación/expiración
### Estructura de Respuesta
**GET /api/users/active:**
```json
{
"activeUsers": [
{
"username": "admin",
"role": "admin",
"status": "active",
"lastActivity": "2026-01-21T10:30:00.000Z",
"connectedViaWebSocket": true
},
{
"username": "user1",
"role": "user",
"status": "inactive",
"lastActivity": "2026-01-21T10:25:00.000Z",
"connectedViaWebSocket": false,
"deviceInfo": {
"browser": "Chrome",
"os": "Windows"
}
}
],
"total": 2,
"timestamp": "2026-01-21T10:35:00.000Z"
}
```
**GET /api/admin/sessions:**
```json
{
"sessions": [
{
"token": "abc123...",
"username": "admin",
"fingerprint": "xyz...",
"deviceInfo": {
"browser": "Chrome",
"browserVersion": "120.0",
"os": "Linux",
"ip": "192.168.1.100"
},
"createdAt": "2026-01-21T09:00:00.000Z",
"expiresAt": "2026-01-22T09:00:00.000Z",
"lastActivity": "2026-01-21T10:30:00.000Z",
"isActive": true,
"isExpired": false
}
],
"stats": {
"total": 10,
"active": 8,
"expired": 2,
"connected": 5,
"inactive": 3,
"byUser": {
"admin": 3,
"user1": 2
}
}
}
```
## 🔧 Configuración
### Timeouts y Intervalos
**Backend** (`web/backend/services/websocket.js`):
```javascript
// Tiempo de inactividad para marcar como inactivo (5 minutos)
const INACTIVE_TIMEOUT = 5 * 60 * 1000;
// Intervalo para limpiar conexiones inactivas (1 minuto)
const CLEANUP_INTERVAL = 60 * 1000;
```
**Frontend** (`web/dashboard/src/App.vue`):
```javascript
// Heartbeat cada 30 segundos
heartbeatInterval = setInterval(() => { ... }, 30000);
// Throttle de actividad: 10 segundos
activityThrottleTimeout = setTimeout(() => { ... }, 10000);
```
## 📊 Estados de Usuario
1. **Active** (Activo)
- Usuario conectado vía WebSocket
- Actividad reciente (< 5 minutos)
- Indicador verde
2. **Inactive** (Inactivo)
- Usuario conectado pero sin actividad reciente (> 5 minutos)
- Indicador amarillo
3. **Offline** (Desconectado)
- Sin conexión WebSocket
- No aparece en la lista de usuarios activos
## 🔄 Flujo de Datos
### Conexión WebSocket
```
Cliente conecta → Servidor valida token → Actualiza lastActivity
Servidor envía confirmación de conexión
Cliente inicia heartbeat cada 30s
Servidor responde con pong
Se actualiza lastActivity en DB
```
### Eventos de Usuario
```
Usuario interactúa (click, tecla, etc.)
Cliente envía evento de actividad (throttled)
Servidor actualiza lastActivity
Broadcast de cambio de estado a otros usuarios
```
### Desconexión
```
Cliente cierra conexión o timeout
Servidor marca isActive = false en DB
Broadcast de estado offline a otros usuarios
```
## 🎨 Componente ActiveUsers
Ubicación: `web/dashboard/src/components/ActiveUsers.vue`
### Props
Ninguna (componente standalone)
### Eventos Escuchados
- `user-status-change` - Cambios de estado de usuarios vía WebSocket
### Características
- **Actualización automática**: cada 30 segundos
- **Actualización en tiempo real**: vía eventos WebSocket
- **Indicadores visuales**:
- 🟢 Verde: usuario activo
- 🟡 Amarillo: usuario inactivo
- 📶 Icono de señal: conectado vía WebSocket
- **Badges de rol**: admin/user
- **Timestamp relativo**: "hace X minutos"
## 🔐 Seguridad
- Solo usuarios autenticados pueden ver usuarios activos
- El token se valida en cada conexión WebSocket
- Las sesiones expiran automáticamente según configuración
- Los heartbeats mantienen la sesión activa
## 📈 Escalabilidad
El sistema está diseñado para manejar múltiples usuarios concurrentes:
1. **Throttling de actividad**: evita spam de eventos
2. **Índices en MongoDB**: consultas optimizadas
3. **Limpieza periódica**: cierre de conexiones inactivas
4. **TTL en sesiones**: eliminación automática de sesiones expiradas
## 🐛 Debugging
Para ver logs de WebSocket en la consola del navegador:
```javascript
// En la consola del navegador
localStorage.setItem('debug', 'websocket');
```
Logs del servidor:
```
Cliente WebSocket conectado: username (role)
Cliente WebSocket desconectado: username
Usuario inactivo detectado: username
Cerrando conexión inactiva: username
```
## 🖥️ Vista de Sesiones Mejorada
La vista de Sesiones (`/sessions`) ahora incluye información completa sobre el estado de actividad:
### Estadísticas Ampliadas
```
┌─────────────────────────────────────────────────────────┐
│ [Total: 10] [Válidas: 8] [🟢 Conectadas: 5] │
│ [🟡 Inactivas: 3] [🔴 Expiradas: 2] [Usuarios: 3] │
└─────────────────────────────────────────────────────────┘
```
### Tabla de Sesiones
Cada fila muestra:
- **Usuario**: nombre del usuario
- **Dispositivo**: navegador, OS, IP
- **Última Actividad**: tiempo relativo (ej: "Hace 2 minutos") + timestamp exacto
- **Token**: primeros 16 caracteres
- **Creada**: timestamp de creación
- **Expira**: timestamp de expiración
- **Estado**: doble indicador
- ✅ Válida / 🔴 Expirada
- 🟢 Conectado / 🟡 Inactivo (solo si válida)
- **Acciones**: botón para eliminar sesión
### Diferencias de Estado
**✅ Válida + 🟢 Conectado:**
- Sesión no expirada
- Usuario con actividad reciente (< 5 minutos)
- Probablemente conectado vía WebSocket
**✅ Válida + 🟡 Inactivo:**
- Sesión no expirada
- Usuario sin actividad reciente (> 5 minutos)
- WebSocket desconectado o usuario inactivo
**🔴 Expirada:**
- Sesión pasada la fecha de expiración
- Se eliminará automáticamente por TTL de MongoDB
- No se puede eliminar manualmente (botón deshabilitado)
## 📝 Notas Adicionales
- El sistema es compatible con múltiples pestañas/dispositivos por usuario
- Cada conexión WebSocket se rastrea independientemente
- Los heartbeats se reinician automáticamente si se reconecta
- La actividad del usuario se detecta de forma pasiva (no intrusiva)
- La vista de Sesiones se actualiza manualmente (botón "🔄 Actualizar")
- Los usuarios activos se actualizan automáticamente cada 30 segundos

View File

@@ -1,257 +0,0 @@
# Sistema de Pagos con Stripe
Este documento explica cómo configurar y usar el sistema de pagos integrado con Stripe en Wallabicher.
## Configuración
### 1. Crear cuenta de Stripe
1. Visita [https://stripe.com](https://stripe.com) y crea una cuenta
2. Accede al Dashboard de Stripe
3. Obtén tus claves API desde: Developers → API keys
### 2. Configurar variables de entorno
Crea un archivo `.env` en `/web/backend/` con las siguientes variables:
```bash
# Stripe Configuration
STRIPE_SECRET_KEY=sk_test_xxxxx # Tu clave secreta de Stripe
STRIPE_WEBHOOK_SECRET=whsec_xxxxx # Secret del webhook (ver paso 3)
BASE_URL=http://localhost # URL base de tu aplicación
```
### 3. Configurar Webhooks de Stripe
Los webhooks son necesarios para que Stripe notifique a tu aplicación sobre eventos de pago:
1. Ve a Developers → Webhooks en el Dashboard de Stripe
2. Haz clic en "Add endpoint"
3. URL del endpoint: `https://tu-dominio.com/api/payments/webhook`
4. Selecciona los siguientes eventos:
- `checkout.session.completed`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_succeeded`
- `invoice.payment_failed`
5. Copia el "Signing secret" (whsec_xxxxx) y agrégalo a `STRIPE_WEBHOOK_SECRET`
### 4. Configurar productos y precios en Stripe (Opcional)
El sistema crea automáticamente los productos y precios en Stripe si no existen, pero puedes crearlos manualmente:
**Productos:**
- Wallabicher Básico
- Wallabicher Pro
- Wallabicher Enterprise
**Precios:**
- Cada producto debe tener precios mensual y anual
- Los `lookup_key` deben seguir el formato: `{planId}_{billingPeriod}` (ej: `basic_monthly`, `pro_yearly`)
### 5. Actualizar Docker Compose
Descomenta las variables de Stripe en `docker-compose.yml`:
```yaml
environment:
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- BASE_URL=${BASE_URL:-http://localhost}
```
Luego crea un archivo `.env` en la raíz del proyecto:
```bash
STRIPE_SECRET_KEY=sk_test_xxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxx
BASE_URL=https://tu-dominio.com
```
## Flujo de registro y pago
### 1. Registro de usuario
Los usuarios pueden registrarse desde la landing page o directamente en `/dashboard/register`:
1. Usuario selecciona un plan
2. Completa el formulario de registro (usuario, email, contraseña)
3. Si es plan gratuito:
- Se crea la cuenta inmediatamente
- Redirige al dashboard
4. Si es plan de pago:
- Se crea la cuenta
- Se inicia sesión automáticamente
- Redirige a Stripe Checkout
### 2. Checkout de Stripe
En Stripe Checkout el usuario:
- Ingresa información de tarjeta
- Confirma el pago
- Es redirigido de vuelta al dashboard
### 3. Gestión de suscripción
Desde `/dashboard/subscription` el usuario puede:
- Ver su plan actual y límites
- Ver uso de búsquedas
- Cambiar de plan
- Cancelar suscripción
- Reactivar suscripción cancelada
- Gestionar método de pago (portal de Stripe)
## Endpoints API
### Pagos
- `POST /api/payments/create-checkout-session` - Crear sesión de checkout
- `POST /api/payments/create-portal-session` - Abrir portal de cliente
- `POST /api/payments/cancel-subscription` - Cancelar suscripción
- `POST /api/payments/reactivate-subscription` - Reactivar suscripción
- `POST /api/payments/webhook` - Webhook de Stripe (sin autenticación)
### Usuarios
- `POST /api/users/register` - Registro público
- `POST /api/users/login` - Iniciar sesión
- `POST /api/users/logout` - Cerrar sesión
- `GET /api/users/me` - Información del usuario actual
### Suscripciones
- `GET /api/subscription/plans` - Obtener planes disponibles
- `GET /api/subscription/me` - Obtener suscripción actual
- `PUT /api/subscription/me` - Actualizar suscripción
## Límites de planes
Los límites se aplican automáticamente mediante middleware:
### Plan Gratuito
- 2 búsquedas simultáneas
- Solo Wallapop
- 50 notificaciones/día
### Plan Básico (€9.99/mes)
- 5 búsquedas simultáneas
- Wallapop + Vinted
- 200 notificaciones/día
### Plan Pro (€19.99/mes)
- 15 búsquedas simultáneas
- Todas las plataformas
- 1000 notificaciones/día
### Plan Enterprise (€49.99/mes)
- Búsquedas ilimitadas
- Todas las plataformas
- Notificaciones ilimitadas
## Modo de prueba vs Producción
### Modo de prueba (Desarrollo)
Usa claves de prueba (`sk_test_xxxxx`):
- No se procesan pagos reales
- Usa tarjetas de prueba: `4242 4242 4242 4242`
- Los webhooks requieren Stripe CLI o túnel (ngrok)
Para probar webhooks localmente:
```bash
stripe listen --forward-to localhost:3001/api/payments/webhook
```
### Modo producción
#### 1. Obtener claves LIVE de Stripe
1. Ve a https://dashboard.stripe.com/apikeys (modo **LIVE**)
2. Copia la clave secreta `sk_live_...`
3. **¡IMPORTANTE!** Nunca hagas commit de esta clave a git
#### 2. Configurar webhook en Stripe Dashboard
1. Ve a https://dashboard.stripe.com/webhooks (modo **LIVE**)
2. Click en **"Add endpoint"**
3. **Endpoint URL:** Tu dominio + la ruta del webhook
```
https://tudominio.com/api/payments/webhook
```
Ejemplos:
- `https://wallabag.midominio.com/api/payments/webhook`
- `https://api.miapp.com/api/payments/webhook`
4. **Selecciona estos eventos:**
- `checkout.session.completed`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_succeeded`
- `invoice.payment_failed`
5. **Guarda** y copia el **Webhook signing secret** (`whsec_...`)
#### 3. Variables de entorno en tu servidor
**Docker Compose (VPS/servidor propio):**
```bash
# En tu servidor, edita .env
STRIPE_SECRET_KEY=sk_live_TU_CLAVE_REAL
STRIPE_WEBHOOK_SECRET=whsec_SECRET_DEL_WEBHOOK
BASE_URL=https://tudominio.com
# Reinicia los contenedores
docker-compose restart backend
```
**Heroku:**
```bash
heroku config:set STRIPE_SECRET_KEY=sk_live_...
heroku config:set STRIPE_WEBHOOK_SECRET=whsec_...
heroku config:set BASE_URL=https://tu-app.herokuapp.com
```
**Railway/Render/Vercel:**
Añade las variables en el panel de configuración del servicio.
#### 4. ⚠️ Requisitos obligatorios
- **HTTPS habilitado** (Stripe requiere HTTPS en producción)
- Certificado SSL válido
- Dominio accesible públicamente
## Seguridad
- Las claves de Stripe NUNCA deben exponerse en el frontend
- Los webhooks verifican la firma automáticamente
- Las rutas de pago requieren autenticación (excepto webhook)
- Los administradores pueden gestionar suscripciones de todos los usuarios
## Troubleshooting
### Los webhooks no funcionan
1. Verifica que `STRIPE_WEBHOOK_SECRET` esté configurado
2. Verifica que la URL del webhook en Stripe sea correcta
3. En desarrollo, usa `stripe listen` para reenviar webhooks
4. Revisa los logs en Stripe Dashboard → Developers → Webhooks
### Los pagos no se reflejan
1. Verifica que el webhook esté recibiendo eventos
2. Verifica logs del backend para errores
3. Verifica que los metadatos (userId, planId) se estén enviando correctamente
### Error "Stripe no está configurado"
- Verifica que `STRIPE_SECRET_KEY` esté en variables de entorno
- Reinicia el servidor backend después de configurar
## Recursos
- [Documentación de Stripe](https://stripe.com/docs)
- [Stripe Checkout](https://stripe.com/docs/payments/checkout)
- [Stripe Webhooks](https://stripe.com/docs/webhooks)
- [Stripe CLI](https://stripe.com/docs/stripe-cli)

View File

@@ -44,7 +44,7 @@ Abre: **http://localhost:3000**
| Servicio | Puerto | Descripción |
|----------|--------|-------------|
| **Dashboard** | 3000 | Interfaz web Vue |
| **Frontend** | 3000 | Interfaz web Vue |
| **Backend** | 3001 | API Node.js |
| **Redis** | 6379 | Cache de artículos |
| **Wallabicher** | - | Servicio Python (interno) |

View File

@@ -37,9 +37,6 @@ services:
- MONGODB_DATABASE=wallabicher
- MONGODB_USERNAME=admin
- MONGODB_PASSWORD=adminpassword
- STRIPE_SECRET_KEY=sk_test_51SrpOfH73CrYqhOp2NfijzzU07ADADmwigscMVdLGzKu9zA83dsrODhfsaY1X4EFTSihhIB0lVtDQ2HpeOfMWTur00YLuuktSL
- STRIPE_WEBHOOK_SECRET=whsec_8ebec8c2aa82a791aa9f2cd68211e297a5d172aea62ebd7b771d230e3a597aa8
- BASE_URL=https://wb.pribyte.cloud/api
volumes:
# Montar archivos de configuración y datos en ubicación predecible
- ./config.yaml:/data/config.yaml:ro
@@ -58,46 +55,18 @@ services:
timeout: 10s
retries: 3
# Dashboard Vue
dashboard:
# Frontend Vue
frontend:
build:
context: ./web/dashboard
context: ./web/frontend
dockerfile: Dockerfile
container_name: wallabicher-dashboard
environment:
- NGINX_CONF=nginx-dashboard.conf
volumes:
- ./web/dashboard/nginx-dashboard.conf:/etc/nginx/conf.d/default.conf:ro
container_name: wallabicher-frontend
depends_on:
- backend
networks:
- wallabicher-network
restart: unless-stopped
# Landing page (Astro)
landing:
build:
context: ./web/landing
dockerfile: Dockerfile
container_name: wallabicher-landing
networks:
- wallabicher-network
restart: unless-stopped
# Nginx reverse proxy principal
nginx:
image: nginx:alpine
container_name: wallabicher-nginx
volumes:
- ./web/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- backend
- dashboard
- landing
networks:
- wallabicher-network
restart: unless-stopped
# Servicio Python principal (Wallabicher)
# NOTA: Para usar MongoDB, asegúrate de que config.yaml tenga:
# cache:

View File

@@ -1 +0,0 @@
/// <reference types="astro/client" />

View File

@@ -1 +0,0 @@
/// <reference path="../.astro/types.d.ts" />

View File

@@ -20,10 +20,10 @@
"docker:clean": "docker compose down -v && docker system prune -f",
"backend:dev": "cd web/backend && npm run dev",
"backend:start": "cd web/backend && npm start",
"dashboard:dev": "cd web/dashboard && npm run dev",
"dashboard:build": "cd web/dashboard && npm run build",
"dashboard:preview": "cd web/dashboard && npm run preview",
"install:all": "cd web/backend && npm install && cd ../dashboard && npm install",
"frontend:dev": "cd web/frontend && npm run dev",
"frontend:build": "cd web/frontend && npm run build",
"frontend:preview": "cd web/frontend && npm run preview",
"install:all": "cd web/backend && npm install && cd ../frontend && npm install",
"git:status": "git status",
"git:pull": "git pull",
"git:push": "git push",

View File

@@ -1,205 +0,0 @@
# Guía de Desarrollo
## Configuración de Puertos
### Desarrollo Local
| Servicio | Puerto | URL |
|----------|--------|-----|
| Dashboard (Vue) | 3000 | http://localhost:3000 |
| Backend (API) | 3001 | http://localhost:3001 |
| Landing (Astro) | 3002 | http://localhost:3002 |
### Producción (Docker)
| Servicio | Puerto Externo | Puerto Interno |
|----------|----------------|----------------|
| Nginx Proxy | 80 | - |
| Dashboard | - | 80 |
| Backend | - | 3001 |
| Landing | - | 80 |
## Ejecutar en Desarrollo Local
### 1. Backend (API)
```bash
cd web/backend
npm install
npm run dev
```
El backend estará disponible en `http://localhost:3001`
**Endpoints**:
- API: `http://localhost:3001/api/*`
- WebSocket: `ws://localhost:3001/ws`
### 2. Dashboard (Vue)
```bash
cd web/dashboard
npm install
npm run dev
```
El dashboard estará disponible en `http://localhost:3000`
**Nota**: Vite está configurado con proxy automático:
- `/api/*``http://localhost:3001/api/*`
- `/ws``ws://localhost:3001/ws`
Esto significa que puedes hacer peticiones a `/api/users` desde el dashboard y automáticamente se redirigirán al backend.
### 3. Landing (Astro)
```bash
cd web/landing
npm install
npm run dev
```
La landing estará disponible en `http://localhost:3002`
## Ejecutar con Docker (Producción)
```bash
# Construir imágenes
docker-compose build
# Iniciar servicios
docker-compose up -d
# Ver logs
docker-compose logs -f
# Detener servicios
docker-compose down
```
Todo estará disponible en `http://localhost`:
- Landing: `http://localhost/`
- Dashboard: `http://localhost/dashboard/`
- API: `http://localhost/api/`
## Reconstruir después de cambios
### Cambios en código (desarrollo)
No es necesario hacer nada, Vite y Node tienen hot-reload automático.
### Cambios en configuración o Docker
```bash
# Reconstruir servicio específico
docker-compose build dashboard
docker-compose build backend
docker-compose build landing
# Reconstruir todo
docker-compose build
# Reiniciar servicios
docker-compose up -d
```
## Diferencias entre Desarrollo y Producción
### Dashboard
- **Desarrollo**:
- Puerto 3000
- Vite proxy activo para `/api` y `/ws`
- Hot Module Replacement (HMR)
- Source maps
- **Producción**:
- Puerto 80 (interno)
- Sin proxy (nginx principal maneja todo)
- Código minificado y optimizado
- Assets con hash para cache
### Backend
- **Desarrollo**:
- Puerto 3001
- `node --watch` para auto-reload
- Variables de entorno por defecto
- **Producción**:
- Puerto 3001 (interno)
- `node` sin watch
- Variables de entorno de Docker
### Landing
- **Desarrollo**:
- Puerto 3002
- Servidor de desarrollo de Astro
- Hot reload
- **Producción**:
- Puerto 80 (interno)
- Archivos estáticos servidos por nginx
- Pre-renderizado
## Troubleshooting
### Puerto ya en uso
```bash
# Ver qué está usando el puerto
lsof -i :3000 # Dashboard
lsof -i :3001 # Backend
lsof -i :3002 # Landing
# Matar proceso
kill -9 <PID>
```
### Proxy no funciona en desarrollo
Asegúrate de que:
1. El backend está corriendo en el puerto 3001
2. Estás ejecutando `npm run dev` (modo desarrollo)
3. La configuración de proxy en `vite.config.js` está correcta
### Assets 404 en producción
Ver `NGINX_CONFIG.md` para detalles de configuración de nginx y assets.
## Variables de Entorno
### Backend (desarrollo local)
Crear archivo `.env` en `web/backend/`:
```env
PORT=3001
PROJECT_ROOT=/ruta/al/proyecto
MONGODB_HOST=localhost
MONGODB_PORT=27017
MONGODB_DATABASE=wallabicher
MONGODB_USERNAME=admin
MONGODB_PASSWORD=adminpassword
```
### Backend (Docker)
Las variables se configuran en `docker-compose.yml`:
- `PORT=3001`
- `PROJECT_ROOT=/data`
- Credenciales de MongoDB
## Base URLs
### Dashboard
La configuración de `base: '/dashboard/'` en `vite.config.js` hace que:
- Todos los assets se construyan con prefijo `/dashboard/`
- Vue Router use `/dashboard` como base
- Service Worker se registre en `/dashboard/sw.js`
**No cambiar** a menos que quieras cambiar la ruta del dashboard.
### Landing
Sin base (raíz por defecto). Se sirve desde `/`.
**No cambiar** a menos que quieras mover la landing a otra ruta.

View File

@@ -1,149 +0,0 @@
# Configuración de Nginx y Assets
## Arquitectura
```
Usuario
nginx (proxy principal) :80
├─→ /api → backend:3001
├─→ /ws → backend:3001
├─→ /dashboard → dashboard:80
└─→ / → landing:80
```
## Configuración de rutas
### 1. Dashboard (Vue + Vite)
**Base URL**: `/dashboard/`
- **Vite config**: `base: '/dashboard/'` (siempre)
- **Vue Router**: `createWebHistory('/dashboard')`
- **Nginx interno**: Sirve desde `/usr/share/nginx/html/dashboard/`
- **Assets**: Se construyen con prefijo `/dashboard/` automáticamente por Vite
**Flujo de peticiones**:
```
Usuario → http://localhost/dashboard/
nginx proxy → http://dashboard:80/dashboard/
nginx dashboard → /usr/share/nginx/html/dashboard/index.html
```
**Assets**:
```
Usuario → http://localhost/dashboard/assets/index-abc123.js
nginx proxy → http://dashboard:80/dashboard/assets/index-abc123.js
nginx dashboard → /usr/share/nginx/html/dashboard/assets/index-abc123.js
```
### 2. Landing (Astro)
**Base URL**: `/`
- **Astro config**: Sin base (raíz por defecto)
- **Nginx interno**: Sirve desde `/usr/share/nginx/html/`
- **Nginx config**: `web/landing/nginx.conf`
- **Assets**: Se construyen para la raíz
**Flujo de peticiones**:
```
Usuario → http://localhost/
nginx proxy → http://landing:80/
nginx landing → /usr/share/nginx/html/index.html
```
**Assets (ej: logo.jpg)**:
```
Usuario → http://localhost/logo.jpg
nginx proxy → http://landing:80/logo.jpg
nginx landing → /usr/share/nginx/html/logo.jpg
```
## Puertos
### Desarrollo local
```
Dashboard (Vue): http://localhost:3000
Backend (API): http://localhost:3001
Landing (Astro): http://localhost:3002
```
### Producción (Docker)
```
nginx (proxy): :80 (externo)
├─ dashboard: :80 (interno)
├─ backend: :3001 (interno)
└─ landing: :80 (interno)
```
## Desarrollo local
Para desarrollo local, el proxy de Vite está configurado solo para `mode === 'development'`:
```bash
# Terminal 1: Backend
cd web/backend
npm run dev
# → API en http://localhost:3001
# Terminal 2: Dashboard
cd web/dashboard
npm run dev
# → Dashboard en http://localhost:3000
# → Vite proxy activo: /api → localhost:3001, /ws → localhost:3001
# Terminal 3: Landing
cd web/landing
npm run dev
# → Landing en http://localhost:3002
# Producción (Docker)
docker-compose up -d
# → Todo en http://localhost:80
```
## Reconstruir después de cambios
Si cambias la configuración de nginx o los archivos de configuración:
```bash
# Reconstruir solo el dashboard
docker-compose build dashboard
# Reconstruir solo la landing
docker-compose build landing
# Reconstruir todo
docker-compose build
# Reiniciar servicios
docker-compose up -d
```
## Troubleshooting
### Assets 404 en dashboard
1. Verificar que Vite construyó con `base: '/dashboard/'`
2. Verificar que el Dockerfile copia a `/usr/share/nginx/html/dashboard`
3. Verificar que nginx-dashboard.conf tiene la location `/dashboard/`
### Assets 404 en landing
1. Verificar que Astro construyó sin base (raíz)
2. Verificar que el Dockerfile copia a `/usr/share/nginx/html/`
3. Verificar que nginx landing usa root `/usr/share/nginx/html`
### Service Worker no se registra
El Service Worker debe estar en `/dashboard/sw.js` y registrarse con scope `/dashboard/`

View File

@@ -10,9 +10,9 @@ cd web/backend
npm install
```
**Dashboard:**
**Frontend:**
```bash
cd web/dashboard
cd web/frontend
npm install
```

View File

@@ -18,6 +18,7 @@ COPY middlewares/ ./middlewares/
COPY routes/ ./routes/
COPY services/ ./services/
COPY utils/ ./utils/
COPY workers.json ./workers.json
# Exponer puerto
EXPOSE 3001

View File

@@ -1,120 +0,0 @@
// Planes de suscripción con sus límites y precios
export const SUBSCRIPTION_PLANS = {
free: {
id: 'free',
name: 'Gratis',
description: 'Perfecto para empezar',
price: {
monthly: 0,
yearly: 0,
},
limits: {
maxWorkers: 2, // Número máximo de búsquedas/workers
maxNotificationsPerDay: 50,
platforms: ['wallapop'], // Solo Wallapop
},
features: [
'Hasta 2 búsquedas simultáneas',
'Solo Wallapop',
'50 notificaciones por día',
'Soporte por email',
],
},
basic: {
id: 'basic',
name: 'Básico',
description: 'Para usuarios ocasionales',
price: {
monthly: 9.99,
yearly: 99.99, // ~17% descuento
},
limits: {
maxWorkers: 5,
maxNotificationsPerDay: 200,
platforms: ['wallapop', 'vinted'],
},
features: [
'Hasta 5 búsquedas simultáneas',
'Wallapop y Vinted',
'200 notificaciones por día',
'Soporte prioritario',
'Sin límite de favoritos',
],
},
pro: {
id: 'pro',
name: 'Pro',
description: 'Para usuarios avanzados',
price: {
monthly: 19.99,
yearly: 199.99, // ~17% descuento
},
limits: {
maxWorkers: 15,
maxNotificationsPerDay: 1000,
platforms: ['wallapop', 'vinted', 'buyee'],
},
features: [
'Hasta 15 búsquedas simultáneas',
'Todas las plataformas',
'1000 notificaciones por día',
'Soporte prioritario 24/7',
'API access',
'Webhooks personalizados',
],
},
enterprise: {
id: 'enterprise',
name: 'Enterprise',
description: 'Para equipos y uso intensivo',
price: {
monthly: 49.99,
yearly: 499.99, // ~17% descuento
},
limits: {
maxWorkers: -1, // Ilimitado
maxNotificationsPerDay: -1, // Ilimitado
platforms: ['wallapop', 'vinted', 'buyee'], // Todas
},
features: [
'Búsquedas ilimitadas',
'Notificaciones ilimitadas',
'Todas las plataformas',
'Soporte dedicado',
'API completa',
'Webhooks personalizados',
'Gestión de múltiples usuarios',
'Estadísticas avanzadas',
],
},
};
// Obtener plan por ID
export function getPlan(planId) {
return SUBSCRIPTION_PLANS[planId] || SUBSCRIPTION_PLANS.free;
}
// Verificar si un plan tiene una característica
export function hasFeature(planId, feature) {
const plan = getPlan(planId);
return plan.features.includes(feature);
}
// Verificar si un plan tiene acceso a una plataforma
export function hasPlatformAccess(planId, platform) {
const plan = getPlan(planId);
return plan.limits.platforms.includes(platform) || plan.limits.platforms.length === 0;
}
// Obtener límite de workers para un plan
export function getMaxWorkers(planId) {
const plan = getPlan(planId);
return plan.limits.maxWorkers;
}
// Obtener límite de notificaciones diarias
export function getMaxNotificationsPerDay(planId) {
const plan = getPlan(planId);
return plan.limits.maxNotificationsPerDay;
}

View File

@@ -1,14 +0,0 @@
# Stripe Configuration
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
# Base URL for redirects (production/development)
BASE_URL=http://localhost
# MongoDB Configuration (optional, if not in config.yaml)
# MONGODB_HOST=localhost
# MONGODB_PORT=27017
# MONGODB_DATABASE=wallabicher
# MONGODB_USERNAME=
# MONGODB_PASSWORD=

View File

@@ -1,133 +0,0 @@
import { getUser, getUserSubscription, getWorkerCount } from '../services/mongodb.js';
import { getPlan, getMaxWorkers, hasPlatformAccess } from '../config/subscriptionPlans.js';
// Middleware para verificar límites de suscripción
export async function checkSubscriptionLimits(req, res, next) {
try {
const username = req.user?.username;
if (!username) {
return res.status(401).json({ error: 'Authentication required' });
}
// Obtener usuario y suscripción
const user = await getUser(username);
if (!user) {
return res.status(404).json({ error: 'Usuario no encontrado' });
}
// Si es admin, no aplicar límites
if (user.role === 'admin') {
return next();
}
const subscription = await getUserSubscription(username);
const planId = subscription?.planId || 'free';
const plan = getPlan(planId);
// Añadir información del plan al request para uso posterior
req.userPlan = {
planId,
plan,
subscription,
};
next();
} catch (error) {
console.error('Error verificando límites de suscripción:', error);
res.status(500).json({ error: 'Error verificando límites de suscripción' });
}
}
// Middleware para verificar límite de workers
export async function checkWorkerLimit(req, res, next) {
try {
const username = req.user?.username;
const userPlan = req.userPlan;
if (!userPlan) {
// Si no hay userPlan, ejecutar checkSubscriptionLimits primero
await checkSubscriptionLimits(req, res, () => {});
if (res.headersSent) return;
}
const planId = req.userPlan.planId;
const maxWorkers = getMaxWorkers(planId);
// Si es ilimitado (-1), permitir
if (maxWorkers === -1) {
return next();
}
// Obtener número actual de workers
const currentWorkerCount = await getWorkerCount(username);
// Si estamos actualizando workers, verificar el nuevo número
if (req.method === 'PUT' && req.body?.items) {
const newWorkerCount = (req.body.items || []).filter(
(item, index) => !(req.body.disabled || []).includes(index)
).length;
if (newWorkerCount > maxWorkers) {
return res.status(403).json({
error: 'Límite de búsquedas excedido',
message: `Tu plan actual (${req.userPlan.plan.name}) permite hasta ${maxWorkers} búsquedas simultáneas. Estás intentando crear ${newWorkerCount}.`,
currentPlan: planId,
maxWorkers,
currentCount: currentWorkerCount,
requestedCount: newWorkerCount,
});
}
} else if (currentWorkerCount >= maxWorkers) {
// Si ya alcanzó el límite y está intentando crear más
return res.status(403).json({
error: 'Límite de búsquedas alcanzado',
message: `Tu plan actual (${req.userPlan.plan.name}) permite hasta ${maxWorkers} búsquedas simultáneas.`,
currentPlan: planId,
maxWorkers,
currentCount: currentWorkerCount,
});
}
next();
} catch (error) {
console.error('Error verificando límite de workers:', error);
res.status(500).json({ error: 'Error verificando límite de workers' });
}
}
// Middleware para verificar acceso a plataforma
export async function checkPlatformAccess(req, res, next) {
try {
const platform = req.body?.platform || req.query?.platform;
if (!platform) {
return next(); // Si no hay plataforma especificada, continuar
}
const userPlan = req.userPlan;
if (!userPlan) {
await checkSubscriptionLimits(req, res, () => {});
if (res.headersSent) return;
}
const planId = req.userPlan.planId;
if (!hasPlatformAccess(planId, platform)) {
return res.status(403).json({
error: 'Plataforma no disponible en tu plan',
message: `La plataforma "${platform}" no está disponible en tu plan actual (${req.userPlan.plan.name}).`,
currentPlan: planId,
requestedPlatform: platform,
availablePlatforms: req.userPlan.plan.limits.platforms,
});
}
next();
} catch (error) {
console.error('Error verificando acceso a plataforma:', error);
res.status(500).json({ error: 'Error verificando acceso a plataforma' });
}
}

View File

@@ -15,7 +15,6 @@
"express": "^4.18.2",
"mongodb": "^6.3.0",
"rate-limiter-flexible": "^5.0.3",
"stripe": "^20.2.0",
"web-push": "^3.6.7",
"ws": "^8.14.2",
"yaml": "^2.3.4"
@@ -1781,26 +1780,6 @@
"node": ">=8"
}
},
"node_modules/stripe": {
"version": "20.2.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.2.0.tgz",
"integrity": "sha512-m8niTfdm3nPP/yQswRWMwQxqEUcTtB3RTJQ9oo6NINDzgi7aPOadsH/fPXIIfL1Sc5+lqQFKSk7WiO6CXmvaeA==",
"license": "MIT",
"dependencies": {
"qs": "^6.14.1"
},
"engines": {
"node": ">=16"
},
"peerDependencies": {
"@types/node": ">=16"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",

View File

@@ -20,9 +20,8 @@
"chokidar": "^3.5.3",
"cors": "^2.8.5",
"express": "^4.18.2",
"mongodb": "^6.3.0",
"rate-limiter-flexible": "^5.0.3",
"stripe": "^20.2.0",
"mongodb": "^6.3.0",
"web-push": "^3.6.7",
"ws": "^8.14.2",
"yaml": "^2.3.4"

View File

@@ -30,8 +30,6 @@ router.get('/sessions', basicAuthMiddleware, adminAuthMiddleware, async (req, re
const sessionsByUser = {};
let activeSessions = 0;
let expiredSessions = 0;
let connectedSessions = 0; // Sesiones con actividad reciente
let inactiveSessions = 0; // Sesiones sin actividad pero no expiradas
sessions.forEach(session => {
if (!sessionsByUser[session.username]) {
@@ -43,13 +41,6 @@ router.get('/sessions', basicAuthMiddleware, adminAuthMiddleware, async (req, re
expiredSessions++;
} else {
activeSessions++;
// Contar sesiones conectadas (con actividad reciente)
if (session.isActive) {
connectedSessions++;
} else {
inactiveSessions++;
}
}
});
@@ -59,8 +50,6 @@ router.get('/sessions', basicAuthMiddleware, adminAuthMiddleware, async (req, re
total: sessions.length,
active: activeSessions,
expired: expiredSessions,
connected: connectedSessions,
inactive: inactiveSessions,
byUser: sessionsByUser,
},
});

View File

@@ -1,418 +0,0 @@
import express from 'express';
import { basicAuthMiddleware } from '../middlewares/auth.js';
import {
getStripeClient,
createCheckoutSession,
createCustomerPortalSession,
cancelSubscription,
reactivateSubscription,
verifyWebhookSignature,
} from '../services/stripe.js';
import {
getUser,
updateUserSubscription,
getUserSubscription,
} from '../services/mongodb.js';
import { getPlan } from '../config/subscriptionPlans.js';
const router = express.Router();
// Middleware de autenticación opcional (intenta autenticar pero no falla si no hay token)
async function optionalAuthMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (authHeader) {
// Si hay header de autorización, intentar autenticar
return basicAuthMiddleware(req, res, next);
} else {
// Si no hay header, continuar sin autenticación
req.user = null;
next();
}
}
// Crear sesión de checkout (permite autenticación opcional)
// Puede ser llamado por usuarios autenticados o durante el registro
router.post('/create-checkout-session', optionalAuthMiddleware, async (req, res) => {
try {
const stripeClient = getStripeClient();
if (!stripeClient) {
return res.status(503).json({
error: 'Pagos no disponibles',
message: 'El sistema de pagos no está configurado actualmente'
});
}
const { planId, billingPeriod, username: providedUsername, email: providedEmail } = req.body;
// Username puede venir del token (usuario autenticado) o del body (registro nuevo)
const username = req.user?.username || providedUsername;
if (!username) {
return res.status(400).json({
error: 'Se requiere username (autenticación o en el body)'
});
}
if (!planId || !billingPeriod) {
return res.status(400).json({
error: 'planId y billingPeriod son requeridos'
});
}
if (planId === 'free') {
return res.status(400).json({
error: 'El plan gratuito no requiere pago'
});
}
if (!['monthly', 'yearly'].includes(billingPeriod)) {
return res.status(400).json({
error: 'billingPeriod debe ser monthly o yearly'
});
}
// Obtener usuario
const user = await getUser(username);
if (!user) {
return res.status(404).json({ error: 'Usuario no encontrado' });
}
// Email puede venir del usuario en BD, del body, o generar uno
const email = user.email || providedEmail || `${username}@wallabicher.local`;
// Crear sesión de checkout
const session = await createCheckoutSession({
planId,
billingPeriod,
email,
userId: username,
});
console.log(`✅ Sesión de checkout creada para ${username}: ${planId} (${billingPeriod})`);
res.json({
success: true,
sessionId: session.id,
url: session.url,
});
} catch (error) {
console.error('Error creando sesión de checkout:', error);
res.status(500).json({
error: error.message,
message: 'Error al crear la sesión de pago'
});
}
});
// Crear portal del cliente
router.post('/create-portal-session', basicAuthMiddleware, async (req, res) => {
try {
const stripeClient = getStripeClient();
if (!stripeClient) {
return res.status(503).json({
error: 'Pagos no disponibles',
message: 'El sistema de pagos no está configurado actualmente'
});
}
const username = req.user.username;
const subscription = await getUserSubscription(username);
if (!subscription || !subscription.stripeCustomerId) {
return res.status(404).json({
error: 'No se encontró información de cliente de Stripe'
});
}
const session = await createCustomerPortalSession(subscription.stripeCustomerId);
res.json({
success: true,
url: session.url,
});
} catch (error) {
console.error('Error creando portal de cliente:', error);
res.status(500).json({
error: error.message,
message: 'Error al crear el portal de gestión'
});
}
});
// Cancelar suscripción
router.post('/cancel-subscription', basicAuthMiddleware, async (req, res) => {
try {
const stripeClient = getStripeClient();
if (!stripeClient) {
return res.status(503).json({
error: 'Pagos no disponibles',
message: 'El sistema de pagos no está configurado actualmente'
});
}
const username = req.user.username;
const subscription = await getUserSubscription(username);
if (!subscription || !subscription.stripeSubscriptionId) {
return res.status(404).json({
error: 'No se encontró suscripción activa'
});
}
await cancelSubscription(subscription.stripeSubscriptionId);
// Actualizar en BD
await updateUserSubscription(username, {
...subscription,
cancelAtPeriodEnd: true,
});
console.log(`✅ Suscripción cancelada para ${username}`);
res.json({
success: true,
message: 'Suscripción cancelada. Se mantendrá activa hasta el final del período'
});
} catch (error) {
console.error('Error cancelando suscripción:', error);
res.status(500).json({
error: error.message,
message: 'Error al cancelar la suscripción'
});
}
});
// Reactivar suscripción
router.post('/reactivate-subscription', basicAuthMiddleware, async (req, res) => {
try {
const stripeClient = getStripeClient();
if (!stripeClient) {
return res.status(503).json({
error: 'Pagos no disponibles',
message: 'El sistema de pagos no está configurado actualmente'
});
}
const username = req.user.username;
const subscription = await getUserSubscription(username);
if (!subscription || !subscription.stripeSubscriptionId) {
return res.status(404).json({
error: 'No se encontró suscripción'
});
}
await reactivateSubscription(subscription.stripeSubscriptionId);
// Actualizar en BD
await updateUserSubscription(username, {
...subscription,
cancelAtPeriodEnd: false,
});
console.log(`✅ Suscripción reactivada para ${username}`);
res.json({
success: true,
message: 'Suscripción reactivada correctamente'
});
} catch (error) {
console.error('Error reactivando suscripción:', error);
res.status(500).json({
error: error.message,
message: 'Error al reactivar la suscripción'
});
}
});
// Webhook de Stripe (sin autenticación)
// ⚠️ NOTA: express.raw() se aplica en server.js para esta ruta
// De lo contrario el body ya viene parseado y Stripe no puede verificar la firma
router.post('/webhook', async (req, res) => {
const signature = req.headers['stripe-signature'];
if (!signature) {
return res.status(400).json({ error: 'No stripe-signature header' });
}
let event;
try {
// Verificar firma del webhook
event = verifyWebhookSignature(req.body, signature);
} catch (error) {
console.error('Error verificando webhook:', error.message);
return res.status(400).json({ error: `Webhook verification failed: ${error.message}` });
}
console.log(`📩 Webhook recibido: ${event.type}`);
// Manejar eventos de Stripe
try {
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object);
break;
case 'invoice.payment_succeeded':
await handlePaymentSucceeded(event.data.object);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object);
break;
default:
console.log(` Evento no manejado: ${event.type}`);
}
res.json({ received: true });
} catch (error) {
console.error('Error procesando webhook:', error);
res.status(500).json({ error: error.message });
}
});
// Handlers de eventos de Stripe
async function handleCheckoutComplete(session) {
const userId = session.metadata.userId || session.client_reference_id;
const planId = session.metadata.planId;
const billingPeriod = session.metadata.billingPeriod;
if (!userId) {
console.error('❌ Checkout sin userId en metadata');
return;
}
console.log(`✅ Pago completado: ${userId}${planId} (${billingPeriod})`);
// Obtener detalles de la suscripción
const subscriptionId = session.subscription;
const customerId = session.customer;
const plan = getPlan(planId);
const now = new Date();
let periodEnd = new Date(now);
if (billingPeriod === 'yearly') {
periodEnd.setFullYear(periodEnd.getFullYear() + 1);
} else {
periodEnd.setMonth(periodEnd.getMonth() + 1);
}
// Actualizar suscripción en BD
await updateUserSubscription(userId, {
planId,
status: 'active',
billingPeriod,
currentPeriodStart: now,
currentPeriodEnd: periodEnd,
cancelAtPeriodEnd: false,
stripeCustomerId: customerId,
stripeSubscriptionId: subscriptionId,
});
// Activar usuario si estaba pendiente de pago
const { getDB } = await import('../services/mongodb.js');
const db = getDB();
if (db) {
const usersCollection = db.collection('users');
const user = await usersCollection.findOne({ username: userId });
if (user && user.status === 'pending_payment') {
await usersCollection.updateOne(
{ username: userId },
{
$set: {
status: 'active',
activatedAt: new Date(),
updatedAt: new Date(),
}
}
);
console.log(`✅ Usuario activado: ${userId}`);
}
}
console.log(`✅ Suscripción actualizada en BD: ${userId}`);
}
async function handleSubscriptionUpdated(subscription) {
const userId = subscription.metadata.userId;
if (!userId) {
console.error('❌ Subscription sin userId en metadata');
return;
}
console.log(`📝 Suscripción actualizada: ${userId}`);
const status = subscription.status; // active, past_due, canceled, etc.
const cancelAtPeriodEnd = subscription.cancel_at_period_end;
const currentPeriodEnd = new Date(subscription.current_period_end * 1000);
const currentPeriodStart = new Date(subscription.current_period_start * 1000);
// Actualizar en BD
const currentSubscription = await getUserSubscription(userId);
await updateUserSubscription(userId, {
...currentSubscription,
status,
cancelAtPeriodEnd,
currentPeriodStart,
currentPeriodEnd,
});
console.log(`✅ Suscripción actualizada en BD: ${userId} (${status})`);
}
async function handleSubscriptionDeleted(subscription) {
const userId = subscription.metadata.userId;
if (!userId) {
console.error('❌ Subscription sin userId en metadata');
return;
}
console.log(`❌ Suscripción eliminada: ${userId}`);
// Revertir a plan gratuito
await updateUserSubscription(userId, {
planId: 'free',
status: 'canceled',
cancelAtPeriodEnd: false,
currentPeriodStart: new Date(),
currentPeriodEnd: null,
stripeCustomerId: subscription.customer,
stripeSubscriptionId: null,
});
console.log(`✅ Usuario revertido a plan gratuito: ${userId}`);
}
async function handlePaymentSucceeded(invoice) {
const subscriptionId = invoice.subscription;
const customerId = invoice.customer;
console.log(`✅ Pago exitoso: ${customerId} - ${subscriptionId}`);
// Aquí podrías enviar un email de confirmación o actualizar estadísticas
}
async function handlePaymentFailed(invoice) {
const subscriptionId = invoice.subscription;
const customerId = invoice.customer;
console.log(`❌ Pago fallido: ${customerId} - ${subscriptionId}`);
// Aquí podrías enviar un email de aviso o marcar la cuenta con problemas
}
export default router;

View File

@@ -1,208 +0,0 @@
import express from 'express';
import { basicAuthMiddleware } from '../middlewares/auth.js';
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
import { getUser, getUserSubscription, updateUserSubscription, getWorkerCount } from '../services/mongodb.js';
import { SUBSCRIPTION_PLANS, getPlan } from '../config/subscriptionPlans.js';
const router = express.Router();
// Obtener planes disponibles (público)
router.get('/plans', (req, res) => {
try {
const plans = Object.values(SUBSCRIPTION_PLANS).map(plan => ({
id: plan.id,
name: plan.name,
description: plan.description,
price: plan.price,
limits: {
maxWorkers: plan.limits.maxWorkers,
maxNotificationsPerDay: plan.limits.maxNotificationsPerDay,
platforms: plan.limits.platforms,
},
features: plan.features,
}));
res.json({ plans });
} catch (error) {
console.error('Error obteniendo planes:', error);
res.status(500).json({ error: error.message });
}
});
// Obtener suscripción del usuario actual
router.get('/me', basicAuthMiddleware, async (req, res) => {
try {
const username = req.user.username;
const subscription = await getUserSubscription(username);
const user = await getUser(username);
const workerCount = await getWorkerCount(username);
const planId = subscription?.planId || 'free';
const plan = getPlan(planId);
res.json({
subscription: {
planId,
plan: {
id: plan.id,
name: plan.name,
description: plan.description,
price: plan.price,
limits: plan.limits,
features: plan.features,
},
status: subscription?.status || 'active',
currentPeriodStart: subscription?.currentPeriodStart || user?.createdAt,
currentPeriodEnd: subscription?.currentPeriodEnd || null,
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd || false,
},
usage: {
workers: workerCount,
maxWorkers: plan.limits.maxWorkers === -1 ? 'Ilimitado' : plan.limits.maxWorkers,
},
});
} catch (error) {
console.error('Error obteniendo suscripción:', error);
res.status(500).json({ error: error.message });
}
});
// Actualizar suscripción (requiere admin o para el propio usuario en caso de cancelación)
router.put('/me', basicAuthMiddleware, async (req, res) => {
try {
const username = req.user.username;
const { planId, status, cancelAtPeriodEnd } = req.body;
if (!planId) {
return res.status(400).json({ error: 'planId es requerido' });
}
// Verificar que el plan existe
const plan = getPlan(planId);
if (!plan) {
return res.status(400).json({ error: 'Plan no válido' });
}
// Solo permitir actualizar a plan gratuito o cancelar suscripción
// Para actualizar a planes de pago, se requiere integración con pasarela de pago
if (planId !== 'free' && !req.user.role === 'admin') {
return res.status(403).json({
error: 'Para actualizar a un plan de pago, contacta con soporte o usa la pasarela de pago'
});
}
const subscription = await getUserSubscription(username);
// Calcular fechas del período
const now = new Date();
let currentPeriodStart = subscription?.currentPeriodStart || now;
let currentPeriodEnd = null;
if (planId !== 'free') {
// Para planes de pago, establecer período mensual o anual según corresponda
// Por ahora, asumimos mensual (30 días)
currentPeriodEnd = new Date(now);
currentPeriodEnd.setMonth(currentPeriodEnd.getMonth() + 1);
}
await updateUserSubscription(username, {
planId,
status: status || 'active',
currentPeriodStart,
currentPeriodEnd,
cancelAtPeriodEnd: cancelAtPeriodEnd || false,
});
console.log(`✅ Suscripción actualizada para ${username}: ${planId}`);
res.json({
success: true,
message: 'Suscripción actualizada correctamente',
subscription: {
planId,
status: status || 'active',
currentPeriodStart,
currentPeriodEnd,
cancelAtPeriodEnd: cancelAtPeriodEnd || false,
},
});
} catch (error) {
console.error('Error actualizando suscripción:', error);
res.status(500).json({ error: error.message });
}
});
// Obtener suscripción de cualquier usuario (solo admin)
router.get('/:username', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
try {
const { username } = req.params;
const subscription = await getUserSubscription(username);
const user = await getUser(username);
const workerCount = await getWorkerCount(username);
const planId = subscription?.planId || 'free';
const plan = getPlan(planId);
res.json({
subscription: {
planId,
plan: {
id: plan.id,
name: plan.name,
description: plan.description,
price: plan.price,
limits: plan.limits,
features: plan.features,
},
status: subscription?.status || 'active',
currentPeriodStart: subscription?.currentPeriodStart || user?.createdAt,
currentPeriodEnd: subscription?.currentPeriodEnd || null,
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd || false,
},
usage: {
workers: workerCount,
maxWorkers: plan.limits.maxWorkers === -1 ? 'Ilimitado' : plan.limits.maxWorkers,
},
});
} catch (error) {
console.error('Error obteniendo suscripción:', error);
res.status(500).json({ error: error.message });
}
});
// Actualizar suscripción de cualquier usuario (solo admin)
router.put('/:username', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
try {
const { username } = req.params;
const { planId, status, currentPeriodStart, currentPeriodEnd, cancelAtPeriodEnd } = req.body;
if (!planId) {
return res.status(400).json({ error: 'planId es requerido' });
}
// Verificar que el plan existe
const plan = getPlan(planId);
if (!plan) {
return res.status(400).json({ error: 'Plan no válido' });
}
await updateUserSubscription(username, {
planId,
status: status || 'active',
currentPeriodStart: currentPeriodStart ? new Date(currentPeriodStart) : new Date(),
currentPeriodEnd: currentPeriodEnd ? new Date(currentPeriodEnd) : null,
cancelAtPeriodEnd: cancelAtPeriodEnd || false,
});
console.log(`✅ Suscripción actualizada para ${username}: ${planId} por admin ${req.user.username}`);
res.json({
success: true,
message: `Suscripción de ${username} actualizada correctamente`,
});
} catch (error) {
console.error('Error actualizando suscripción:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -1,105 +1,12 @@
import express from 'express';
import bcrypt from 'bcrypt';
import { getDB, getUser, createUser, deleteUser as deleteUserFromDB, getAllUsers, updateUserPassword, getActiveSessions } from '../services/mongodb.js';
import { getDB, getUser, createUser, deleteUser as deleteUserFromDB, getAllUsers, updateUserPassword } from '../services/mongodb.js';
import { basicAuthMiddleware, createSession, invalidateSession, invalidateUserSessions } from '../middlewares/auth.js';
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
import { combineFingerprint } from '../utils/fingerprint.js';
import { getActiveUsers } from '../services/websocket.js';
const router = express.Router();
// Endpoint de registro (público)
router.post('/register', async (req, res) => {
try {
const db = getDB();
if (!db) {
return res.status(500).json({ error: 'MongoDB no está disponible' });
}
const { username, password, email, planId } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'username y password son requeridos' });
}
if (username.length < 3) {
return res.status(400).json({ error: 'El nombre de usuario debe tener al menos 3 caracteres' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'La contraseña debe tener al menos 6 caracteres' });
}
// Validar email si se proporciona
if (email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({ error: 'Email no válido' });
}
}
// Verificar si el usuario ya existe
const existingUser = await getUser(username);
if (existingUser) {
return res.status(409).json({ error: 'El usuario ya existe' });
}
// Verificar si es plan de pago y si Stripe está disponible
const selectedPlanId = planId || 'free';
const isPaidPlan = selectedPlanId !== 'free';
if (isPaidPlan) {
// Para planes de pago, verificar que Stripe esté configurado
const { getStripeClient } = await import('../services/stripe.js');
const stripeClient = getStripeClient();
if (!stripeClient) {
return res.status(503).json({
error: 'El sistema de pagos no está disponible actualmente',
message: 'No se pueden procesar planes de pago en este momento. Por favor, intenta con el plan gratuito o contacta con soporte.'
});
}
}
// Hashear contraseña y crear usuario
const passwordHash = await bcrypt.hash(password, 10);
await createUser({
username,
passwordHash,
email: email || null,
role: 'user',
// Si es plan de pago, marcar como pendiente hasta que se complete el pago
status: isPaidPlan ? 'pending_payment' : 'active',
});
// Crear suscripción inicial
const { updateUserSubscription } = await import('../services/mongodb.js');
await updateUserSubscription(username, {
planId: selectedPlanId,
status: isPaidPlan ? 'pending' : 'active',
currentPeriodStart: new Date(),
currentPeriodEnd: null,
cancelAtPeriodEnd: false,
});
console.log(`✅ Usuario registrado: ${username} (${selectedPlanId}) - Estado: ${isPaidPlan ? 'pending_payment' : 'active'}`);
res.json({
success: true,
message: 'Usuario creado correctamente',
username,
planId: selectedPlanId,
requiresPayment: isPaidPlan,
});
} catch (error) {
console.error('Error registrando usuario:', error);
// Manejar error de duplicado
if (error.code === 11000) {
return res.status(409).json({ error: 'El usuario ya existe' });
}
res.status(500).json({ error: error.message });
}
});
// Endpoint de login (público)
router.post('/login', async (req, res) => {
try {
@@ -135,23 +42,6 @@ router.post('/login', async (req, res) => {
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' });
}
// Verificar estado del usuario
if (user.status === 'pending_payment') {
return res.status(403).json({
error: 'Payment pending',
message: 'Tu cuenta está pendiente de pago. Por favor, completa el proceso de pago para activar tu cuenta.',
status: 'pending_payment'
});
}
if (user.status === 'suspended' || user.status === 'disabled') {
return res.status(403).json({
error: 'Account suspended',
message: 'Tu cuenta ha sido suspendida. Contacta con soporte.',
status: user.status
});
}
// Generar fingerprint del dispositivo
const { fingerprint, deviceInfo } = combineFingerprint(clientFingerprint, clientDeviceInfo, req);
@@ -376,71 +266,5 @@ router.delete('/:username', basicAuthMiddleware, adminAuthMiddleware, async (req
}
});
// Obtener usuarios activos/conectados (requiere autenticación)
router.get('/active', basicAuthMiddleware, async (req, res) => {
try {
const db = getDB();
if (!db) {
return res.status(500).json({ error: 'MongoDB no está disponible' });
}
// Obtener usuarios activos del WebSocket
const activeUsersWS = getActiveUsers();
// Obtener sesiones activas de la base de datos
const activeSessions = await getActiveSessions();
// Combinar información y eliminar duplicados
const userMap = new Map();
// Añadir usuarios del WebSocket
for (const user of activeUsersWS) {
if (!userMap.has(user.username)) {
userMap.set(user.username, {
username: user.username,
role: user.role,
status: user.status,
lastActivity: user.lastActivity,
connectedViaWebSocket: true
});
}
}
// Añadir usuarios de sesiones activas (que podrían no estar en WebSocket)
for (const session of activeSessions) {
if (!userMap.has(session.username)) {
const user = await getUser(session.username);
userMap.set(session.username, {
username: session.username,
role: user?.role || 'user',
status: 'active',
lastActivity: session.lastActivity?.toISOString() || null,
connectedViaWebSocket: false,
deviceInfo: session.deviceInfo
});
}
}
// Convertir a array
const activeUsers = Array.from(userMap.values());
// Ordenar por última actividad (más recientes primero)
activeUsers.sort((a, b) => {
const dateA = a.lastActivity ? new Date(a.lastActivity).getTime() : 0;
const dateB = b.lastActivity ? new Date(b.lastActivity).getTime() : 0;
return dateB - dateA;
});
res.json({
activeUsers,
total: activeUsers.length,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Error obteniendo usuarios activos:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -2,7 +2,6 @@ import express from 'express';
import { basicAuthMiddleware } from '../middlewares/auth.js';
import { broadcast } from '../services/websocket.js';
import { getWorkers, setWorkers, getDB } from '../services/mongodb.js';
import { checkSubscriptionLimits, checkWorkerLimit, checkPlatformAccess } from '../middlewares/subscriptionLimits.js';
const router = express.Router();
@@ -24,7 +23,7 @@ router.get('/', basicAuthMiddleware, async (req, res) => {
});
// Actualizar workers del usuario autenticado (requiere autenticación)
router.put('/', basicAuthMiddleware, checkSubscriptionLimits, checkWorkerLimit, checkPlatformAccess, async (req, res) => {
router.put('/', basicAuthMiddleware, async (req, res) => {
try {
const db = getDB();
if (!db) {

View File

@@ -8,7 +8,6 @@ import { initVAPIDKeys } from './services/webPush.js';
import { initWebSocket } from './services/websocket.js';
import { startArticleMonitoring } from './services/articleMonitor.js';
import { initFileWatcher } from './services/fileWatcher.js';
import { initStripe } from './services/stripe.js';
import routes from './routes/index.js';
import workersRouter from './routes/workers.js';
import articlesRouter from './routes/articles.js';
@@ -19,8 +18,6 @@ import telegramRouter from './routes/telegram.js';
import pushRouter from './routes/push.js';
import usersRouter from './routes/users.js';
import adminRouter from './routes/admin.js';
import subscriptionRouter from './routes/subscription.js';
import paymentsRouter from './routes/payments.js';
const app = express();
const server = createServer(app);
@@ -30,12 +27,6 @@ app.set('trust proxy', true);
// Middlewares globales
app.use(cors());
// ⚠️ IMPORTANTE: Webhook de Stripe necesita el body RAW (sin parsear)
// Por eso usamos express.raw() SOLO para esta ruta, ANTES de express.json()
app.use('/api/payments/webhook', express.raw({ type: 'application/json' }));
// Ahora sí, parseamos JSON para todas las demás rutas
app.use(express.json());
// Aplicar rate limiting a todas las rutas API
@@ -44,9 +35,6 @@ app.use('/api', rateLimitMiddleware);
// Inicializar VAPID keys para Web Push
initVAPIDKeys();
// Inicializar Stripe
initStripe();
// Inicializar WebSocket
initWebSocket(server);
@@ -61,8 +49,6 @@ app.use('/api/telegram', telegramRouter);
app.use('/api/push', pushRouter);
app.use('/api/users', usersRouter);
app.use('/api/admin', adminRouter);
app.use('/api/subscription', subscriptionRouter);
app.use('/api/payments', paymentsRouter);
// Inicializar servidor
async function startServer() {

View File

@@ -1101,30 +1101,6 @@ export async function getSession(token) {
}
}
// Actualizar actividad de una sesión
export async function updateSessionActivity(token, isActive = true) {
if (!db) {
return false;
}
try {
const sessionsCollection = db.collection('sessions');
await sessionsCollection.updateOne(
{ token },
{
$set: {
lastActivity: new Date(),
isActive: isActive,
},
}
);
return true;
} catch (error) {
console.error('Error actualizando actividad de sesión:', error.message);
return false;
}
}
export async function deleteSession(token) {
if (!db) {
return false;
@@ -1163,59 +1139,17 @@ export async function getAllSessions() {
try {
const sessionsCollection = db.collection('sessions');
const sessions = await sessionsCollection.find({}).toArray();
const now = new Date();
const INACTIVE_TIMEOUT = 5 * 60 * 1000; // 5 minutos
return sessions.map(session => {
const isExpired = session.expiresAt ? new Date(session.expiresAt) < now : false;
const timeSinceActivity = session.lastActivity ? now - new Date(session.lastActivity) : null;
const isActive = session.isActive && timeSinceActivity && timeSinceActivity < INACTIVE_TIMEOUT;
return {
token: session.token,
username: session.username,
fingerprint: session.fingerprint || null,
deviceInfo: session.deviceInfo || null,
createdAt: session.createdAt,
expiresAt: session.expiresAt,
lastActivity: session.lastActivity || null,
isActive: isActive || false,
isExpired: isExpired,
};
});
} catch (error) {
console.error('Error obteniendo todas las sesiones:', error.message);
return [];
}
}
// Obtener sesiones activas (usuarios conectados)
export async function getActiveSessions() {
if (!db) {
return [];
}
try {
const sessionsCollection = db.collection('sessions');
const now = new Date();
const INACTIVE_TIMEOUT = 5 * 60 * 1000; // 5 minutos
const recentTime = new Date(now - INACTIVE_TIMEOUT);
// Buscar sesiones activas (con actividad reciente)
const sessions = await sessionsCollection.find({
isActive: true,
lastActivity: { $gte: recentTime },
expiresAt: { $gt: now }
}).toArray();
return sessions.map(session => ({
token: session.token,
username: session.username,
lastActivity: session.lastActivity,
deviceInfo: session.deviceInfo || null,
fingerprint: session.fingerprint || null,
deviceInfo: session.deviceInfo || null,
createdAt: session.createdAt,
expiresAt: session.expiresAt,
isExpired: session.expiresAt ? new Date(session.expiresAt) < new Date() : false,
}));
} catch (error) {
console.error('Error obteniendo sesiones activas:', error.message);
console.error('Error obteniendo todas las sesiones:', error.message);
return [];
}
}
@@ -1343,105 +1277,6 @@ export async function updateArticleFavorite(platform, id, is_favorite, username)
}
}
// Funciones para suscripciones
export async function getUserSubscription(username) {
if (!db) {
return null;
}
try {
const usersCollection = db.collection('users');
const user = await usersCollection.findOne({ username });
if (!user) {
return null;
}
// Si no tiene suscripción, retornar plan gratuito por defecto
if (!user.subscription) {
return {
planId: 'free',
status: 'active',
currentPeriodStart: user.createdAt || new Date(),
currentPeriodEnd: null, // Plan gratuito no expira
cancelAtPeriodEnd: false,
};
}
return user.subscription;
} catch (error) {
console.error(`Error obteniendo suscripción de ${username}:`, error.message);
return null;
}
}
export async function updateUserSubscription(username, subscriptionData) {
if (!db) {
throw new Error('MongoDB no está disponible');
}
try {
const usersCollection = db.collection('users');
// Verificar que el usuario existe
const user = await usersCollection.findOne({ username });
if (!user) {
throw new Error(`Usuario ${username} no existe`);
}
// Actualizar suscripción
await usersCollection.updateOne(
{ username },
{
$set: {
subscription: {
planId: subscriptionData.planId || 'free',
status: subscriptionData.status || 'active',
billingPeriod: subscriptionData.billingPeriod || 'monthly',
currentPeriodStart: subscriptionData.currentPeriodStart || new Date(),
currentPeriodEnd: subscriptionData.currentPeriodEnd || null,
cancelAtPeriodEnd: subscriptionData.cancelAtPeriodEnd || false,
stripeCustomerId: subscriptionData.stripeCustomerId || null,
stripeSubscriptionId: subscriptionData.stripeSubscriptionId || null,
updatedAt: new Date(),
},
updatedAt: new Date(),
},
}
);
return true;
} catch (error) {
console.error(`Error actualizando suscripción de ${username}:`, error.message);
throw error;
}
}
export async function getWorkerCount(username) {
if (!db) {
return 0;
}
try {
const workersCollection = db.collection('workers');
const workersData = await workersCollection.findOne({ username });
if (!workersData || !workersData.items) {
return 0;
}
// Contar solo workers activos (no deshabilitados)
const activeWorkers = (workersData.items || []).filter(
(item, index) => !(workersData.disabled || []).includes(index)
);
return activeWorkers.length;
} catch (error) {
console.error(`Error contando workers de ${username}:`, error.message);
return 0;
}
}
// Cerrar conexión
export async function closeMongoDB() {
if (mongoClient) {

View File

@@ -1,226 +0,0 @@
import Stripe from 'stripe';
import { SUBSCRIPTION_PLANS } from '../config/subscriptionPlans.js';
let stripeClient = null;
// Inicializar Stripe
export function initStripe() {
let stripeSecretKey = process.env.STRIPE_SECRET_KEY;
if (!stripeSecretKey) {
stripeSecretKey = 'sk_test_51SrpOfH73CrYqhOp2NfijzzU07ADADmwigscMVdLGzKu9zA83dsrODhfsaY1X4EFTSihhIB0lVtDQ2HpeOfMWTur00YLuuktSL';
}
if (!stripeSecretKey) {
console.warn('⚠️ STRIPE_SECRET_KEY no configurado. Los pagos estarán deshabilitados.');
return null;
}
try {
stripeClient = new Stripe(stripeSecretKey, {
apiVersion: '2024-12-18.acacia',
});
console.log('✅ Stripe inicializado correctamente');
return stripeClient;
} catch (error) {
console.error('Error inicializando Stripe:', error.message);
return null;
}
}
// Obtener cliente de Stripe
export function getStripeClient() {
return stripeClient;
}
// Crear sesión de checkout de Stripe
export async function createCheckoutSession({ planId, billingPeriod, email, userId }) {
if (!stripeClient) {
throw new Error('Stripe no está configurado');
}
const plan = SUBSCRIPTION_PLANS[planId];
if (!plan) {
throw new Error('Plan no válido');
}
if (planId === 'free') {
throw new Error('El plan gratuito no requiere pago');
}
// Precio según período de facturación
const priceAmount = billingPeriod === 'yearly' ? plan.price.yearly : plan.price.monthly;
const priceCents = Math.round(priceAmount * 100); // Convertir a centavos
// Crear precio en Stripe (o usar precio existente si ya lo tienes)
const priceId = await getOrCreatePrice(planId, billingPeriod, priceCents);
// URL de éxito y cancelación
const baseUrl = process.env.BASE_URL || 'http://localhost';
const successUrl = `${baseUrl}/dashboard/?session_id={CHECKOUT_SESSION_ID}&payment_success=true`;
const cancelUrl = `${baseUrl}/dashboard/?payment_cancelled=true`;
// Crear sesión de checkout
const session = await stripeClient.checkout.sessions.create({
mode: 'subscription',
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: successUrl,
cancel_url: cancelUrl,
customer_email: email,
client_reference_id: userId,
metadata: {
userId,
planId,
billingPeriod,
},
subscription_data: {
metadata: {
userId,
planId,
billingPeriod,
},
},
});
return session;
}
// Obtener o crear precio en Stripe
async function getOrCreatePrice(planId, billingPeriod, priceCents) {
const plan = SUBSCRIPTION_PLANS[planId];
// Buscar precio existente (usando lookup_key)
const lookupKey = `${planId}_${billingPeriod}`;
try {
const prices = await stripeClient.prices.list({
lookup_keys: [lookupKey],
limit: 1,
});
if (prices.data.length > 0) {
return prices.data[0].id;
}
} catch (error) {
console.log('Precio no encontrado, creando nuevo...');
}
// Si no existe, crear producto y precio
let product;
try {
// Buscar producto existente
const products = await stripeClient.products.list({
limit: 100,
});
product = products.data.find(p => p.metadata.planId === planId);
// Si no existe, crear producto
if (!product) {
product = await stripeClient.products.create({
name: `Wallabicher ${plan.name}`,
description: plan.description,
metadata: {
planId,
},
});
}
} catch (error) {
console.error('Error creando/buscando producto:', error);
throw error;
}
// Crear precio
const price = await stripeClient.prices.create({
product: product.id,
unit_amount: priceCents,
currency: 'eur',
recurring: {
interval: billingPeriod === 'yearly' ? 'year' : 'month',
},
lookup_key: lookupKey,
metadata: {
planId,
billingPeriod,
},
});
return price.id;
}
// Crear portal del cliente para gestionar suscripción
export async function createCustomerPortalSession(customerId) {
if (!stripeClient) {
throw new Error('Stripe no está configurado');
}
const baseUrl = process.env.BASE_URL || 'http://localhost';
const returnUrl = `${baseUrl}/dashboard/`;
const session = await stripeClient.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
});
return session;
}
// Cancelar suscripción
export async function cancelSubscription(subscriptionId) {
if (!stripeClient) {
throw new Error('Stripe no está configurado');
}
// Cancelar al final del período
const subscription = await stripeClient.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
return subscription;
}
// Reactivar suscripción cancelada
export async function reactivateSubscription(subscriptionId) {
if (!stripeClient) {
throw new Error('Stripe no está configurado');
}
const subscription = await stripeClient.subscriptions.update(subscriptionId, {
cancel_at_period_end: false,
});
return subscription;
}
// Obtener suscripción por ID
export async function getSubscription(subscriptionId) {
if (!stripeClient) {
throw new Error('Stripe no está configurado');
}
return await stripeClient.subscriptions.retrieve(subscriptionId);
}
// Verificar webhook signature
export function verifyWebhookSignature(payload, signature) {
if (!stripeClient) {
throw new Error('Stripe no está configurado');
}
let webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
webhookSecret = 'whsec_n6tTKRSG38WJQDRX8jLjZTs7kPKxbdNP';
}
if (!webhookSecret) {
throw new Error('STRIPE_WEBHOOK_SECRET no configurado');
}
return stripeClient.webhooks.constructEvent(payload, signature, webhookSecret);
}

View File

@@ -1,17 +1,11 @@
import { WebSocketServer } from 'ws';
import { getDB, getSession, getUser, deleteSession as deleteSessionFromDB, updateSessionActivity } from './mongodb.js';
import { getDB, getSession, getUser, deleteSession as deleteSessionFromDB } from './mongodb.js';
let wss = null;
// Duración de la sesión en milisegundos (24 horas)
const SESSION_DURATION = 24 * 60 * 60 * 1000;
// Tiempo de inactividad para marcar como inactivo (5 minutos)
const INACTIVE_TIMEOUT = 5 * 60 * 1000;
// Intervalo para limpiar conexiones inactivas (1 minuto)
const CLEANUP_INTERVAL = 60 * 1000;
// Inicializar WebSocket Server
export function initWebSocket(server) {
wss = new WebSocketServer({ server, path: '/ws' });
@@ -79,90 +73,18 @@ export function initWebSocket(server) {
role: user.role || 'user',
token: token
};
ws.isAlive = true;
ws.lastActivity = new Date();
// Actualizar estado de conexión en la base de datos
await updateSessionActivity(token, true);
console.log(`Cliente WebSocket conectado: ${session.username} (${user.role || 'user'})`);
// Enviar confirmación de conexión
ws.send(JSON.stringify({
type: 'connection',
status: 'connected',
timestamp: new Date().toISOString()
}));
// Broadcast a otros clientes que este usuario se conectó
broadcastUserStatus(session.username, 'online');
} catch (error) {
console.error('Error validando token WebSocket:', error);
ws.close(1011, 'Error de autenticación');
return;
}
// Manejar mensajes del cliente
ws.on('message', async (data) => {
try {
const message = JSON.parse(data.toString());
// Manejar diferentes tipos de mensajes
switch (message.type) {
case 'ping':
case 'heartbeat':
// Actualizar actividad
ws.isAlive = true;
ws.lastActivity = new Date();
// Actualizar en base de datos
if (ws.user && ws.user.token) {
await updateSessionActivity(ws.user.token, true);
}
// Responder con pong
ws.send(JSON.stringify({
type: 'pong',
timestamp: new Date().toISOString()
}));
break;
case 'activity':
// El usuario realizó alguna actividad (click, scroll, etc.)
ws.isAlive = true;
ws.lastActivity = new Date();
if (ws.user && ws.user.token) {
await updateSessionActivity(ws.user.token, true);
}
break;
default:
console.log(`Mensaje WebSocket no manejado: ${message.type}`);
}
} catch (error) {
console.error('Error procesando mensaje WebSocket:', error);
}
});
// Manejar pong nativo de WebSocket
ws.on('pong', () => {
ws.isAlive = true;
ws.lastActivity = new Date();
});
ws.on('close', async () => {
ws.on('close', () => {
if (ws.user) {
console.log(`Cliente WebSocket desconectado: ${ws.user.username}`);
// Actualizar estado en base de datos
if (ws.user.token) {
await updateSessionActivity(ws.user.token, false);
}
// Broadcast a otros clientes que este usuario se desconectó
broadcastUserStatus(ws.user.username, 'offline');
} else {
console.log('Cliente WebSocket desconectado');
}
@@ -173,81 +95,9 @@ export function initWebSocket(server) {
});
});
// Iniciar limpieza periódica de conexiones inactivas
startCleanupInterval();
return wss;
}
// Limpiar conexiones inactivas periódicamente
let cleanupIntervalId = null;
function startCleanupInterval() {
if (cleanupIntervalId) {
clearInterval(cleanupIntervalId);
}
cleanupIntervalId = setInterval(() => {
if (!wss) return;
const now = new Date();
wss.clients.forEach(async (ws) => {
// Si el cliente no ha respondido, marcarlo como inactivo
if (ws.isAlive === false) {
console.log(`Cerrando conexión inactiva: ${ws.user?.username || 'desconocido'}`);
// Actualizar estado en base de datos antes de cerrar
if (ws.user && ws.user.token) {
await updateSessionActivity(ws.user.token, false);
}
return ws.terminate();
}
// Verificar si ha pasado el timeout de inactividad
if (ws.lastActivity && (now - ws.lastActivity) > INACTIVE_TIMEOUT) {
console.log(`Usuario inactivo detectado: ${ws.user?.username || 'desconocido'}`);
// Actualizar estado en base de datos pero mantener conexión
if (ws.user && ws.user.token) {
await updateSessionActivity(ws.user.token, false);
}
}
// Marcar como no vivo y enviar ping
ws.isAlive = false;
ws.ping();
});
}, CLEANUP_INTERVAL);
}
// Detener limpieza
function stopCleanupInterval() {
if (cleanupIntervalId) {
clearInterval(cleanupIntervalId);
cleanupIntervalId = null;
}
}
// Broadcast del estado de un usuario específico
function broadcastUserStatus(username, status) {
if (!wss) return;
const message = JSON.stringify({
type: 'user_status',
username: username,
status: status, // 'online' | 'offline' | 'inactive'
timestamp: new Date().toISOString()
});
wss.clients.forEach((client) => {
if (client.readyState === 1) { // WebSocket.OPEN
client.send(message);
}
});
}
// Broadcast a todos los clientes WebSocket
export function broadcast(data) {
if (!wss) return;
@@ -264,51 +114,3 @@ export function getWebSocketServer() {
return wss;
}
// Obtener usuarios activos conectados
export function getActiveUsers() {
if (!wss) return [];
const activeUsers = [];
const now = new Date();
wss.clients.forEach((ws) => {
if (ws.user && ws.readyState === 1) { // WebSocket.OPEN
const isActive = ws.lastActivity && (now - ws.lastActivity) <= INACTIVE_TIMEOUT;
activeUsers.push({
username: ws.user.username,
role: ws.user.role,
status: isActive ? 'active' : 'inactive',
lastActivity: ws.lastActivity?.toISOString() || null,
connectedAt: ws.lastActivity?.toISOString() || null
});
}
});
// Eliminar duplicados (un usuario puede tener múltiples conexiones)
const uniqueUsers = [];
const seenUsernames = new Set();
for (const user of activeUsers) {
if (!seenUsernames.has(user.username)) {
seenUsernames.add(user.username);
uniqueUsers.push(user);
}
}
return uniqueUsers;
}
// Cerrar todas las conexiones y limpiar
export function closeWebSocket() {
stopCleanupInterval();
if (wss) {
wss.clients.forEach((ws) => {
ws.close(1001, 'Servidor cerrando');
});
wss.close();
wss = null;
}
}

View File

@@ -139,7 +139,7 @@ function parseUserAgent(userAgent) {
}
/**
* Genera un fingerprint desde el dashboard (cuando se envía desde el cliente)
* Genera un fingerprint desde el frontend (cuando se envía desde el cliente)
* @param {string} fingerprintHash - Hash del fingerprint generado en el cliente
* @param {Object} deviceInfo - Información del dispositivo del cliente
* @param {Object} req - Request object de Express

View File

@@ -1,29 +0,0 @@
FROM node:18-alpine AS builder
WORKDIR /app
# Copiar archivos de dependencias
COPY package.json package-lock.json* ./
# Instalar dependencias
RUN npm ci
# Copiar código fuente
COPY . .
# Construir aplicación con base /dashboard/
RUN npm run build
# Stage de producción - servir con nginx
FROM nginx:alpine
# Copiar archivos construidos (ya incluyen el prefijo /dashboard en las rutas)
COPY --from=builder /app/dist /usr/share/nginx/html/dashboard
# Copiar configuración de nginx (se puede sobrescribir con volumen)
COPY nginx-dashboard.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,25 +0,0 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html/dashboard;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
# Cache static assets ANTES de SPA routing
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# SPA routing - todo desde la raíz del contenedor
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,25 +0,0 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
# Cache static assets ANTES de SPA routing
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# SPA routing - todas las rutas con prefijo /dashboard/
location /dashboard/ {
try_files $uri $uri/ /dashboard/index.html;
}
}

View File

@@ -1,188 +0,0 @@
<template>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Usuarios Activos
</h3>
<button
@click="refreshUsers"
:disabled="loading"
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 disabled:opacity-50"
title="Actualizar"
>
<ArrowPathIcon
class="w-5 h-5"
:class="{ 'animate-spin': loading }"
/>
</button>
</div>
<!-- Loading State -->
<div v-if="loading && !activeUsers.length" class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-gray-200 border-t-primary-600"></div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Cargando usuarios activos...</p>
</div>
<!-- No Active Users -->
<div v-else-if="!activeUsers.length" class="text-center py-8">
<UsersIcon class="w-12 h-12 mx-auto text-gray-400 mb-2" />
<p class="text-sm text-gray-500 dark:text-gray-400">No hay usuarios activos en este momento</p>
</div>
<!-- Active Users List -->
<div v-else class="space-y-2">
<div
v-for="user in activeUsers"
:key="user.username"
class="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<div class="flex items-center space-x-3 flex-1 min-w-0">
<!-- Status Indicator -->
<div class="flex-shrink-0">
<div
class="w-3 h-3 rounded-full"
:class="{
'bg-green-500': user.status === 'active',
'bg-yellow-500': user.status === 'inactive',
'bg-gray-400': user.status !== 'active' && user.status !== 'inactive'
}"
:title="user.status === 'active' ? 'Activo' : 'Inactivo'"
></div>
</div>
<!-- User Info -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{{ user.username }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<span v-if="user.lastActivity">
Última actividad: {{ formatTime(user.lastActivity) }}
</span>
<span v-else>Sin actividad reciente</span>
</p>
</div>
<!-- Role Badge -->
<div class="flex-shrink-0">
<span
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
:class="{
'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400': user.role === 'admin',
'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400': user.role === 'user'
}"
>
{{ user.role }}
</span>
</div>
<!-- WebSocket Indicator -->
<div v-if="user.connectedViaWebSocket" class="flex-shrink-0" title="Conectado vía WebSocket">
<SignalIcon class="w-4 h-4 text-green-500" />
</div>
</div>
</div>
</div>
<!-- Stats -->
<div v-if="activeUsers.length" class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<p class="text-xs text-gray-500 dark:text-gray-400 text-center">
Total: {{ activeUsers.length }} {{ activeUsers.length === 1 ? 'usuario activo' : 'usuarios activos' }}
<span v-if="lastUpdate"> Actualizado {{ formatTime(lastUpdate) }}</span>
</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { ArrowPathIcon, UsersIcon, SignalIcon } from '@heroicons/vue/24/outline';
import api from '../services/api';
const activeUsers = ref([]);
const loading = ref(false);
const lastUpdate = ref(null);
let refreshInterval = null;
async function refreshUsers() {
loading.value = true;
try {
const data = await api.getActiveUsers();
activeUsers.value = data.activeUsers || [];
lastUpdate.value = new Date().toISOString();
} catch (error) {
console.error('Error obteniendo usuarios activos:', error);
} finally {
loading.value = false;
}
}
function formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
const now = new Date();
const diffInSeconds = Math.floor((now - date) / 1000);
if (diffInSeconds < 60) {
return 'hace unos segundos';
} else if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60);
return `hace ${minutes} ${minutes === 1 ? 'minuto' : 'minutos'}`;
} else if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `hace ${hours} ${hours === 1 ? 'hora' : 'horas'}`;
} else {
return date.toLocaleString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
}
// Actualizar cuando un usuario cambie de estado (via WebSocket)
function handleUserStatusChange(event) {
const { username, status } = event.detail;
// Buscar usuario en la lista
const userIndex = activeUsers.value.findIndex(u => u.username === username);
if (status === 'online') {
// Si es online y no está en la lista, añadirlo
if (userIndex === -1) {
refreshUsers(); // Recargar lista completa
} else {
// Actualizar estado
activeUsers.value[userIndex].status = 'active';
activeUsers.value[userIndex].lastActivity = new Date().toISOString();
}
} else if (status === 'offline') {
// Si es offline, eliminarlo de la lista
if (userIndex !== -1) {
activeUsers.value.splice(userIndex, 1);
}
}
}
onMounted(() => {
// Cargar usuarios activos inicialmente
refreshUsers();
// Actualizar cada 30 segundos
refreshInterval = setInterval(refreshUsers, 30000);
// Escuchar cambios de estado de usuarios
window.addEventListener('user-status-change', handleUserStatusChange);
});
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
window.removeEventListener('user-status-change', handleUserStatusChange);
});
</script>

View File

@@ -1,119 +0,0 @@
import { createApp } from 'vue';
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 Settings from './views/Settings.vue';
import Subscription from './views/Subscription.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 Register from './views/Register.vue';
import './style.css';
import authService from './services/auth';
const routes = [
{ path: '/login', component: Login, name: 'login' },
{ path: '/register', component: Register, name: 'register' },
{ path: '/', component: Dashboard, meta: { requiresAuth: true } }, // Redirige a /dashboard
{ 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: '/subscription', component: Subscription, meta: { requiresAuth: true } },
{ path: '/settings', component: Settings, meta: { requiresAuth: true } },
{ path: '/users', component: Users, meta: { requiresAuth: true, requiresAdmin: true } },
{ path: '/logs', component: Logs, meta: { requiresAuth: true } },
{ path: '/rate-limiter', component: RateLimiter, meta: { requiresAuth: true } },
{ path: '/sessions', component: Sessions, meta: { requiresAuth: true } },
];
const router = createRouter({
history: createWebHistory('/dashboard'),
routes,
});
// Guard de navegación para verificar autenticación
router.beforeEach(async (to, from, next) => {
// Si la ruta es /login o /register y ya está autenticado, redirigir al dashboard
if (to.path === '/login' || to.path === '/register') {
if (authService.hasCredentials()) {
const isValid = await authService.validateSession();
if (isValid) {
next('/');
return;
}
}
next();
return;
}
// Si accede a la raíz y está autenticado, redirigir al dashboard
if (to.path === '/') {
if (authService.hasCredentials()) {
const isValid = await authService.validateSession();
if (isValid) {
next();
return;
}
}
// Si no está autenticado, redirigir a login
next('/login');
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;
}
// Verificar si requiere permisos de administrador
if (to.meta.requiresAdmin) {
const isAdmin = authService.isAdmin();
if (!isAdmin) {
// No es admin, redirigir a dashboard (raíz relativa)
next('/');
return;
}
}
}
// Continuar la navegación
next();
});
const app = createApp(App);
app.use(router);
app.mount('#app');
// Registrar Service Worker automáticamente al cargar la app
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/dashboard/sw.js', {
scope: '/dashboard/'
});
console.log('Service Worker registrado:', registration.scope);
} catch (error) {
console.error('Error registrando Service Worker:', error);
}
});
}

View File

@@ -1,223 +0,0 @@
import axios from 'axios';
import authService from './auth';
// Usar variable de entorno si está disponible, sino usar '/api' (proxy en desarrollo)
const baseURL = import.meta.env.VITE_API_BASE_URL || '/api';
console.log('baseURL', baseURL);
const api = axios.create({
baseURL,
headers: {
'Content-Type': 'application/json',
},
});
// Interceptor para añadir autenticación a las peticiones
api.interceptors.request.use(
(config) => {
const authHeader = authService.getAuthHeader();
if (authHeader) {
config.headers.Authorization = authHeader;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Interceptor para manejar errores 401 (no autenticado)
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Disparar evento personalizado para mostrar diálogo de login
window.dispatchEvent(new CustomEvent('auth-required', {
detail: {
message: 'Se requiere autenticación para esta acción',
config: error.config
}
}));
}
return Promise.reject(error);
}
);
export default {
// Estadísticas
async getStats() {
const response = await api.get('/stats');
return response.data;
},
// Workers
async getWorkers() {
const response = await api.get('/workers');
return response.data;
},
async updateWorkers(workers) {
const response = await api.put('/workers', workers);
return response.data;
},
// Favoritos
async getFavorites() {
const response = await api.get('/favorites');
return response.data;
},
async addFavorite(favorite) {
const response = await api.post('/favorites', favorite);
return response.data;
},
async removeFavorite(platform, id) {
const response = await api.delete(`/favorites/${platform}/${id}`);
return response.data;
},
// Artículos
async getArticles(limit = 100, offset = 0, additionalParams = {}) {
const params = { limit, offset, ...additionalParams };
const response = await api.get('/articles', { params });
return response.data;
},
async getArticleFacets() {
const response = await api.get('/articles/facets');
return response.data;
},
async searchArticles(query, mode = 'AND') {
const response = await api.get('/articles/search', {
params: { q: query, mode: mode },
});
return response.data;
},
async getArticle(platform, id) {
const response = await api.get(`/articles/${platform}/${id}`);
return response.data;
},
// Logs
async getLogs(limit = 500, sinceLine = null) {
const params = { limit };
if (sinceLine !== null && sinceLine > 0) {
params.since = sinceLine;
}
const response = await api.get('/logs', { params });
return response.data;
},
// Configuración
async getConfig() {
const response = await api.get('/config');
return response.data;
},
// Telegram
async getTelegramConfig() {
const response = await api.get('/telegram/config');
return response.data;
},
async setTelegramConfig(config) {
const response = await api.put('/telegram/config', config);
return response.data;
},
async getTelegramThreads() {
const response = await api.get('/telegram/threads');
return response.data;
},
// Usuarios
async getUsers() {
const response = await api.get('/users');
return response.data;
},
async createUser(userData) {
const response = await api.post('/users', userData);
return response.data;
},
async deleteUser(username) {
const response = await api.delete(`/users/${username}`);
return response.data;
},
async changePassword(passwordData) {
const response = await api.post('/users/change-password', passwordData);
return response.data;
},
async getActiveUsers() {
const response = await api.get('/users/active');
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;
},
// Suscripciones
async getSubscriptionPlans() {
const response = await api.get('/subscription/plans');
return response.data;
},
async getMySubscription() {
const response = await api.get('/subscription/me');
return response.data;
},
async updateSubscription(subscriptionData) {
const response = await api.put('/subscription/me', subscriptionData);
return response.data;
},
// Admin: Actualizar suscripción de otro usuario
async updateUserSubscription(username, subscriptionData) {
const response = await api.put(`/subscription/${username}`, subscriptionData);
return response.data;
},
// Métodos genéricos para rutas adicionales
async post(url, data) {
const response = await api.post(url, data);
return response.data;
},
async get(url, config) {
const response = await api.get(url, config);
return response.data;
},
async put(url, data) {
const response = await api.put(url, data);
return response.data;
},
async delete(url) {
const response = await api.delete(url);
return response.data;
},
};

View File

@@ -1,230 +0,0 @@
// Servicio de autenticación para gestionar tokens
import { getDeviceFingerprint } from './fingerprint.js';
const AUTH_STORAGE_KEY = 'wallabicher_token';
const USERNAME_STORAGE_KEY = 'wallabicher_username';
const ROLE_STORAGE_KEY = 'wallabicher_role';
class AuthService {
constructor() {
this.token = this.loadToken();
this.username = this.loadUsername();
this.role = this.loadRole();
}
// Cargar token desde localStorage
loadToken() {
try {
return localStorage.getItem(AUTH_STORAGE_KEY) || '';
} catch (error) {
console.error('Error cargando token:', error);
return '';
}
}
// Cargar username desde localStorage
loadUsername() {
try {
return localStorage.getItem(USERNAME_STORAGE_KEY) || '';
} catch (error) {
console.error('Error cargando username:', error);
return '';
}
}
// Guardar token, username y role en localStorage
saveSession(token, username, role = 'user') {
try {
this.token = token;
this.username = username;
this.role = role;
localStorage.setItem(AUTH_STORAGE_KEY, token);
localStorage.setItem(USERNAME_STORAGE_KEY, username);
localStorage.setItem(ROLE_STORAGE_KEY, role);
return true;
} catch (error) {
console.error('Error guardando sesión:', error);
return false;
}
}
// Cargar role desde localStorage
loadRole() {
try {
return localStorage.getItem(ROLE_STORAGE_KEY) || 'user';
} catch (error) {
console.error('Error cargando role:', error);
return 'user';
}
}
// Eliminar token, username y role
clearSession() {
try {
this.token = '';
this.username = '';
this.role = 'user';
localStorage.removeItem(AUTH_STORAGE_KEY);
localStorage.removeItem(USERNAME_STORAGE_KEY);
localStorage.removeItem(ROLE_STORAGE_KEY);
return true;
} catch (error) {
console.error('Error eliminando sesión:', error);
return false;
}
}
// Obtener token actual
getToken() {
return this.token;
}
// Obtener username actual
getUsername() {
return this.username;
}
// Obtener role actual
getRole() {
return this.role;
}
// Verificar si es admin
isAdmin() {
return this.role === 'admin';
}
// Verificar si hay sesión activa (token guardado)
hasCredentials() {
return !!this.token;
}
// Generar header de autenticación Bearer
getAuthHeader() {
if (!this.token) {
return null;
}
return `Bearer ${this.token}`;
}
// Hacer login (llamar al endpoint de login)
async login(username, password) {
try {
// Obtener fingerprint del dispositivo
let fingerprintData = null;
try {
fingerprintData = await getDeviceFingerprint();
} catch (error) {
console.warn('Error obteniendo fingerprint, continuando sin él:', error);
}
const requestBody = {
username,
password,
};
// Agregar fingerprint si está disponible
if (fingerprintData) {
requestBody.fingerprint = fingerprintData.fingerprint;
requestBody.deviceInfo = fingerprintData.deviceInfo;
}
const response = await fetch('/api/users/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Error en login');
}
if (data.success && data.token) {
const role = data.role || 'user';
this.saveSession(data.token, data.username, role);
return { success: true, token: data.token, username: data.username, role };
}
throw new Error('Respuesta inválida del servidor');
} catch (error) {
console.error('Error en login:', error);
throw error;
}
}
// Hacer logout (llamar al endpoint de logout)
async logout() {
try {
const token = this.token;
if (token) {
try {
await fetch('/api/users/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
} catch (error) {
// Si falla el logout en el servidor, aún así limpiar localmente
console.error('Error al cerrar sesión en el servidor:', error);
}
}
this.clearSession();
return true;
} catch (error) {
console.error('Error en logout:', error);
this.clearSession(); // Limpiar localmente de todas formas
return false;
}
}
// Verificar si el token sigue siendo válido
async validateSession() {
if (!this.token) {
return false;
}
try {
const response = await fetch('/api/users/me', {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.token}`,
},
});
if (response.ok) {
const data = await response.json();
if (data.success && data.authenticated) {
// Actualizar role si está disponible
if (data.role) {
this.role = data.role;
localStorage.setItem(ROLE_STORAGE_KEY, data.role);
}
return true;
}
}
// Si el token es inválido, limpiar sesión
if (response.status === 401) {
this.clearSession();
}
return false;
} catch (error) {
console.error('Error validando sesión:', error);
return false;
}
}
}
// Exportar instancia singleton
const authService = new AuthService();
export default authService;

View File

@@ -1,394 +0,0 @@
<template>
<div class="fixed inset-0 bg-gradient-to-br from-primary-50 via-teal-50 to-cyan-50 dark:from-gray-950 dark:via-gray-900 dark:to-primary-950 overflow-y-auto">
<div class="min-h-screen flex items-center justify-center p-4 py-12">
<!-- Logo flotante en la esquina -->
<div class="absolute top-4 left-4">
<img src="/logo.jpg" alt="Wallabicher" class="w-12 h-12 rounded-xl shadow-lg" />
</div>
<div class="w-full max-w-6xl">
<!-- Header -->
<div class="text-center mb-8">
<h1 class="text-4xl sm:text-5xl font-bold text-gray-900 dark:text-white mb-2">
Crea tu cuenta en
<span class="bg-gradient-to-r from-primary-600 via-teal-600 to-cyan-600 bg-clip-text text-transparent">
Wallabicher
</span>
</h1>
<p class="text-lg text-gray-600 dark:text-gray-400 mb-2">
Elige el plan perfecto para ti y empieza a monitorizar marketplaces
</p>
<p class="text-sm text-gray-500 dark:text-gray-500 flex items-center justify-center gap-2">
<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="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"></path>
</svg>
Haz clic en un plan para seleccionarlo
</p>
</div>
<div class="grid lg:grid-cols-3 gap-6 mb-8">
<!-- Planes -->
<div
v-for="plan in plans"
:key="plan.id"
@click="selectPlan(plan.id)"
:class="[
'group relative p-6 rounded-2xl border-2 cursor-pointer transition-all duration-300 transform hover:-translate-y-2 hover:shadow-xl',
selectedPlan === plan.id
? 'border-primary-500 dark:border-primary-400 shadow-2xl scale-105 bg-white dark:bg-gray-800 ring-4 ring-primary-100 dark:ring-primary-900/50'
: 'border-gray-200 dark:border-gray-700 hover:border-primary-300 dark:hover:border-primary-700 bg-white/80 dark:bg-gray-800/80 hover:bg-white dark:hover:bg-gray-800'
]"
>
<!-- Badge popular -->
<div v-if="plan.id === 'basic'" class="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 bg-gradient-to-r from-primary-600 to-teal-600 text-white text-xs font-semibold rounded-full">
Más Popular
</div>
<!-- Checkmark si está seleccionado -->
<div v-if="selectedPlan === plan.id" class="absolute top-4 right-4 w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center shadow-lg animate-scale-in">
<svg class="w-5 h-5 text-white" 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>
<!-- Indicador de clickeable si NO está seleccionado -->
<div v-else class="absolute top-4 right-4 w-8 h-8 border-2 border-gray-300 dark:border-gray-600 rounded-full flex items-center justify-center group-hover:border-primary-500 dark:group-hover:border-primary-400 transition-colors">
<svg class="w-4 h-4 text-gray-400 group-hover:text-primary-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</div>
<div class="text-center mb-6">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">{{ plan.name }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">{{ plan.description }}</p>
<div class="mb-4">
<div v-if="billingPeriod === 'monthly'">
<span class="text-3xl font-bold text-gray-900 dark:text-white">
{{ plan.price.monthly.toFixed(2) }}
</span>
<span class="text-gray-600 dark:text-gray-400">/mes</span>
</div>
<div v-else>
<span class="text-3xl font-bold text-gray-900 dark:text-white">
{{ plan.price.yearly.toFixed(2) }}
</span>
<span class="text-gray-600 dark:text-gray-400">/año</span>
<div class="text-sm text-primary-600 dark:text-primary-400 mt-1">
{{ (plan.price.yearly / 12).toFixed(2) }}/mes
</div>
</div>
</div>
</div>
<ul class="space-y-2 mb-4">
<li v-for="(feature, idx) in plan.features.slice(0, 4)" :key="idx" class="flex items-start text-sm">
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400 mr-2 mt-0.5 flex-shrink-0" 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>
<span class="text-gray-700 dark:text-gray-300">{{ feature }}</span>
</li>
</ul>
</div>
</div>
<!-- Formulario de registro -->
<div class="max-w-md mx-auto bg-white dark:bg-gray-800 rounded-2xl shadow-2xl p-8 border border-gray-200 dark:border-gray-700">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2 text-center">
Completa tu registro
</h2>
<div class="text-center mb-6 p-3 bg-primary-50 dark:bg-primary-900/20 rounded-lg border border-primary-200 dark:border-primary-800">
<p class="text-sm text-gray-600 dark:text-gray-400">
Plan seleccionado:
<span class="font-bold text-primary-600 dark:text-primary-400">
{{ plans.find(p => p.id === selectedPlan)?.name || 'Gratis' }}
</span>
</p>
</div>
<!-- Toggle mensual/anual -->
<div class="flex justify-center mb-6">
<div class="inline-flex items-center bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
<button
@click="billingPeriod = 'monthly'"
:class="[
'px-4 py-2 rounded-md text-sm font-medium transition-all',
billingPeriod === 'monthly'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-700 dark:text-gray-300'
]"
>
Mensual
</button>
<button
@click="billingPeriod = 'yearly'"
:class="[
'px-4 py-2 rounded-md text-sm font-medium transition-all',
billingPeriod === 'yearly'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-700 dark:text-gray-300'
]"
>
Anual
<span class="ml-1 text-xs text-primary-600 dark:text-primary-400">-17%</span>
</button>
</div>
</div>
<form @submit.prevent="handleRegister" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Nombre de usuario
</label>
<input
v-model="formData.username"
type="text"
required
minlength="3"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="usuario123"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email
</label>
<input
v-model="formData.email"
type="email"
required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="tu@email.com"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Contraseña
</label>
<input
v-model="formData.password"
type="password"
required
minlength="6"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Confirmar contraseña
</label>
<input
v-model="formData.confirmPassword"
type="password"
required
minlength="6"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
<div v-if="error" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded-lg text-sm">
{{ error }}
</div>
<button
type="submit"
:disabled="loading"
class="w-full py-3 px-4 bg-gradient-to-r from-primary-600 to-teal-600 hover:from-primary-700 hover:to-teal-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="!loading">
{{ selectedPlan === 'free' ? 'Crear cuenta gratis' : `Continuar al pago (${getPlanPrice()})` }}
</span>
<span v-else>Procesando...</span>
</button>
</form>
<div class="mt-6 text-center">
<p class="text-sm text-gray-600 dark:text-gray-400">
¿Ya tienes cuenta?
<router-link to="/login" class="text-primary-600 dark:text-primary-400 hover:underline font-semibold">
Inicia sesión
</router-link>
</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import api from '../services/api';
export default {
name: 'Register',
setup() {
const router = useRouter();
const plans = ref([]);
const selectedPlan = ref('free');
const billingPeriod = ref('monthly');
const loading = ref(false);
const error = ref('');
const formData = ref({
username: '',
email: '',
password: '',
confirmPassword: '',
});
const getPlanPrice = () => {
const plan = plans.value.find(p => p.id === selectedPlan.value);
if (!plan) return '0.00';
return billingPeriod.value === 'yearly'
? plan.price.yearly.toFixed(2)
: plan.price.monthly.toFixed(2);
};
const selectPlan = (planId) => {
selectedPlan.value = planId;
error.value = '';
};
const handleRegister = async () => {
error.value = '';
// Validaciones
if (formData.value.password !== formData.value.confirmPassword) {
error.value = 'Las contraseñas no coinciden';
return;
}
if (formData.value.password.length < 6) {
error.value = 'La contraseña debe tener al menos 6 caracteres';
return;
}
if (formData.value.username.length < 3) {
error.value = 'El nombre de usuario debe tener al menos 3 caracteres';
return;
}
loading.value = true;
try {
// 1. Registrar usuario
const registerResponse = await api.post('/users/register', {
username: formData.value.username,
email: formData.value.email,
password: formData.value.password,
planId: selectedPlan.value,
});
if (!registerResponse.success) {
throw new Error(registerResponse.error || 'Error al registrar usuario');
}
// 2. Si es plan gratuito, hacer login y redirigir al dashboard
if (selectedPlan.value === 'free') {
const loginResponse = await api.post('/users/login', {
username: formData.value.username,
password: formData.value.password,
});
if (loginResponse.token) {
localStorage.setItem('token', loginResponse.token);
localStorage.setItem('username', loginResponse.username);
localStorage.setItem('role', loginResponse.role || 'user');
router.push('/');
}
} else {
// 3. Si es plan de pago, crear sesión de checkout directamente (sin login)
// El usuario se activará cuando se complete el pago
try {
const checkoutResponse = await api.post('/payments/create-checkout-session', {
planId: selectedPlan.value,
billingPeriod: billingPeriod.value,
username: formData.value.username,
email: formData.value.email,
});
if (checkoutResponse.url) {
// Guardar info para después del pago
sessionStorage.setItem('pendingUsername', formData.value.username);
// Redirigir a Stripe Checkout
window.location.href = checkoutResponse.url;
} else {
throw new Error('No se pudo crear la sesión de pago');
}
} catch (checkoutError) {
// Si falla el checkout, mostrar error pero el usuario YA está creado
console.error('Error creando sesión de checkout:', checkoutError);
error.value = 'Usuario creado, pero hubo un error al procesar el pago. Por favor, intenta iniciar sesión y actualizar tu suscripción desde el panel.';
// Opcional: podríamos eliminar el usuario aquí si queremos ser más estrictos
// Pero es mejor dejarlo para que pueda intentar pagar después desde el dashboard
}
}
} catch (err) {
console.error('Error en registro:', err);
error.value = err.response?.data?.message || err.response?.data?.error || err.message || 'Error al registrar usuario';
} finally {
loading.value = false;
}
};
onMounted(async () => {
try {
// Cargar planes
const response = await api.get('/subscription/plans');
plans.value = response.plans || [];
// Preseleccionar el plan de la URL si existe
const urlParams = new URLSearchParams(window.location.search);
const planFromUrl = urlParams.get('plan');
if (planFromUrl && plans.value.find(p => p.id === planFromUrl)) {
selectedPlan.value = planFromUrl;
}
} catch (err) {
console.error('Error cargando planes:', err);
}
});
return {
plans,
selectedPlan,
billingPeriod,
formData,
loading,
error,
selectPlan,
handleRegister,
getPlanPrice,
};
},
};
</script>
<style scoped>
@keyframes scale-in {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.animate-scale-in {
animation: scale-in 0.3s ease-out;
}
</style>

View File

@@ -1,314 +0,0 @@
<template>
<div class="space-y-6">
<!-- Page Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Configuración</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Gestiona tu configuración personal y preferencias
</p>
</div>
</div>
<!-- Cambiar Contraseña -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Cambiar Contraseña</h3>
<p class="card-subtitle">Actualiza tu contraseña de acceso</p>
</div>
<form @submit.prevent="handleChangePassword" class="space-y-4">
<div v-if="passwordError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
{{ passwordError }}
</div>
<div v-if="passwordSuccess" class="bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded">
{{ passwordSuccess }}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Contraseña actual <span class="text-red-500">*</span>
</label>
<input
v-model="passwordForm.currentPassword"
type="password"
class="input"
placeholder="••••••••"
required
autocomplete="current-password"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Nueva contraseña <span class="text-red-500">*</span>
</label>
<input
v-model="passwordForm.newPassword"
type="password"
class="input"
placeholder="••••••••"
required
minlength="6"
autocomplete="new-password"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Mínimo 6 caracteres
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirmar nueva contraseña <span class="text-red-500">*</span>
</label>
<input
v-model="passwordForm.newPasswordConfirm"
type="password"
class="input"
placeholder="••••••••"
required
minlength="6"
autocomplete="new-password"
/>
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="submit"
class="btn btn-primary"
:disabled="loadingAction"
>
{{ loadingAction ? 'Cambiando...' : 'Cambiar Contraseña' }}
</button>
</div>
</form>
</div>
<!-- Configuración de Telegram -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Configuración de Telegram</h3>
<p class="card-subtitle">Configura tu bot de Telegram y canal para recibir notificaciones</p>
</div>
<form @submit.prevent="saveTelegramConfig" class="space-y-4">
<div v-if="telegramError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
{{ telegramError }}
</div>
<div v-if="telegramSuccess" class="bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded">
{{ telegramSuccess }}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Token del Bot <span class="text-red-500">*</span>
</label>
<input
v-model="telegramForm.token"
type="password"
class="input"
placeholder="Ej: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
required
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Obtén tu token desde @BotFather en Telegram
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Canal o Grupo <span class="text-red-500">*</span>
</label>
<input
v-model="telegramForm.channel"
type="text"
class="input"
placeholder="Ej: @micanal o -1001234567890"
required
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Usa @nombrecanal para canales públicos o el ID numérico para grupos/canales privados
</p>
</div>
<div class="flex items-center">
<input
v-model="telegramForm.enable_polling"
type="checkbox"
id="enable_polling"
class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<label for="enable_polling" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
Habilitar polling del bot (para comandos /favs, /threads, etc.)
</label>
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="submit"
class="btn btn-primary"
:disabled="loadingAction"
>
{{ loadingAction ? 'Guardando...' : 'Guardar Configuración' }}
</button>
</div>
</form>
</div>
<!-- Información de la cuenta -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Información de la Cuenta</h3>
<p class="card-subtitle">Datos de tu cuenta</p>
</div>
<div class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Nombre de usuario</p>
<p class="text-base text-gray-900 dark:text-gray-100 font-semibold">{{ currentUser }}</p>
</div>
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Rol</p>
<span
:class="isAdmin ? 'badge badge-warning' : 'badge badge-primary'"
>
{{ isAdmin ? 'Administrador' : 'Usuario' }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import api from '../services/api';
import authService from '../services/auth';
const loadingAction = ref(false);
const passwordError = ref('');
const passwordSuccess = ref('');
const telegramError = ref('');
const telegramSuccess = ref('');
const passwordForm = ref({
currentPassword: '',
newPassword: '',
newPasswordConfirm: '',
});
const telegramForm = ref({
token: '',
channel: '',
enable_polling: false
});
const currentUser = computed(() => {
return authService.getUsername() || '';
});
const isAdmin = computed(() => {
return authService.isAdmin();
});
async function handleChangePassword() {
passwordError.value = '';
passwordSuccess.value = '';
loadingAction.value = true;
if (!passwordForm.value.currentPassword || !passwordForm.value.newPassword || !passwordForm.value.newPasswordConfirm) {
passwordError.value = 'Todos los campos son requeridos';
loadingAction.value = false;
return;
}
if (passwordForm.value.newPassword.length < 6) {
passwordError.value = 'La nueva contraseña debe tener al menos 6 caracteres';
loadingAction.value = false;
return;
}
if (passwordForm.value.newPassword !== passwordForm.value.newPasswordConfirm) {
passwordError.value = 'Las contraseñas no coinciden';
loadingAction.value = false;
return;
}
try {
await api.changePassword({
currentPassword: passwordForm.value.currentPassword,
newPassword: passwordForm.value.newPassword,
});
passwordSuccess.value = 'Contraseña actualizada correctamente. Por favor, inicia sesión nuevamente.';
// Invalidar la sesión actual - el usuario deberá hacer login nuevamente
setTimeout(async () => {
await authService.logout();
// Recargar página para forzar nuevo login
window.location.reload();
}, 2000);
} catch (error) {
console.error('Error cambiando contraseña:', error);
if (error.response?.data?.error) {
passwordError.value = error.response.data.error;
} else {
passwordError.value = 'Error cambiando contraseña. Intenta de nuevo.';
}
} finally {
loadingAction.value = false;
}
}
async function loadTelegramConfig() {
try {
const config = await api.getTelegramConfig();
if (config) {
telegramForm.value = {
token: config.token || '',
channel: config.channel || '',
enable_polling: config.enable_polling || false
};
}
} catch (error) {
console.error('Error cargando configuración de Telegram:', error);
telegramError.value = 'Error cargando la configuración de Telegram';
}
}
async function saveTelegramConfig() {
telegramError.value = '';
telegramSuccess.value = '';
if (!telegramForm.value.token || !telegramForm.value.channel) {
telegramError.value = 'Token y canal son requeridos';
return;
}
loadingAction.value = true;
try {
await api.setTelegramConfig(telegramForm.value);
telegramSuccess.value = 'Configuración de Telegram guardada correctamente';
setTimeout(() => {
telegramSuccess.value = '';
}, 3000);
} catch (error) {
console.error('Error guardando configuración de Telegram:', error);
if (error.response?.data?.error) {
telegramError.value = error.response.data.error;
} else {
telegramError.value = 'Error al guardar la configuración de Telegram';
}
} finally {
loadingAction.value = false;
}
}
onMounted(() => {
loadTelegramConfig();
});
</script>

View File

@@ -1,424 +0,0 @@
<template>
<div class="space-y-6">
<!-- Page Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Suscripción</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Gestiona tu plan de suscripción y límites
</p>
</div>
</div>
<!-- Current Plan Card -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Plan Actual</h3>
<p class="card-subtitle">Tu plan de suscripción actual y uso</p>
</div>
<div v-if="loading" class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Cargando información...</p>
</div>
<div v-else-if="subscription && subscription.subscription && subscription.subscription.plan" class="space-y-6">
<!-- Plan Info -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="p-6 bg-gradient-to-br from-primary-50 to-teal-50 dark:from-primary-900/20 dark:to-teal-900/20 rounded-xl border border-primary-200 dark:border-primary-800">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Plan</h4>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ subscription.subscription.plan?.name || 'Gratis' }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{{ subscription.subscription.plan?.description || 'Plan gratuito' }}</p>
</div>
<div class="p-6 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-700 rounded-xl border border-gray-200 dark:border-gray-700">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Estado</h4>
<span
:class="subscription.subscription?.status === 'active' ? 'badge badge-success' : 'badge badge-warning'"
class="text-lg font-semibold"
>
{{ subscription.subscription?.status === 'active' ? 'Activo' : 'Inactivo' }}
</span>
<p v-if="subscription.subscription?.currentPeriodEnd" class="text-sm text-gray-600 dark:text-gray-400 mt-2">
Renovación: {{ formatDate(subscription.subscription.currentPeriodEnd) }}
</p>
</div>
</div>
<!-- Actions -->
<div v-if="subscription.subscription.planId !== 'free'" class="flex flex-wrap gap-3 pb-6 border-b border-gray-200 dark:border-gray-700">
<button
v-if="!subscription.subscription.cancelAtPeriodEnd"
@click="openCancelModal"
class="btn btn-outline-danger"
:disabled="actionLoading"
>
Cancelar suscripción
</button>
<button
v-else
@click="reactivateSubscription"
class="btn btn-primary"
:disabled="actionLoading"
>
Reactivar suscripción
</button>
<button
@click="openCustomerPortal"
class="btn btn-outline"
:disabled="actionLoading"
>
Gestionar pago
</button>
</div>
<!-- Cancel Warning -->
<div v-if="subscription.subscription.cancelAtPeriodEnd" class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<div class="flex items-start space-x-3">
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<div>
<p class="text-sm font-semibold text-yellow-800 dark:text-yellow-300">
Suscripción cancelada
</p>
<p class="text-sm text-yellow-700 dark:text-yellow-400 mt-1">
Tu suscripción se mantendrá activa hasta {{ formatDate(subscription.subscription.currentPeriodEnd) }}.
Después se cambiará al plan gratuito automáticamente.
</p>
</div>
</div>
</div>
<!-- Usage Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Búsquedas activas</span>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ subscription.usage?.workers || 0 }} / {{ subscription.usage?.maxWorkers || 'N/A' }}
</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="bg-gradient-to-r from-primary-600 to-teal-600 h-2 rounded-full transition-all duration-300"
:style="{ width: getUsagePercentage(subscription.usage?.workers || 0, subscription.usage?.maxWorkers) + '%' }"
></div>
</div>
</div>
<div>
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Plataformas disponibles</h4>
<div class="flex flex-wrap gap-2">
<span
v-for="platform in (subscription.subscription.plan?.limits?.platforms || [])"
:key="platform"
class="badge badge-primary"
>
{{ platform }}
</span>
</div>
</div>
</div>
<!-- Features -->
<div>
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Características incluidas</h4>
<ul class="grid grid-cols-1 md:grid-cols-2 gap-2">
<li v-for="feature in (subscription.subscription.plan?.features || [])" :key="feature" class="flex items-start">
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400 mr-2 mt-0.5 flex-shrink-0" 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>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ feature }}</span>
</li>
</ul>
</div>
</div>
<div v-else-if="subscription && (!subscription.subscription || !subscription.subscription.plan)" class="text-center py-8">
<p class="text-gray-600 dark:text-gray-400">No se pudo cargar la información del plan. Por favor, recarga la página.</p>
</div>
</div>
<!-- Available Plans -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Planes Disponibles</h3>
<p class="card-subtitle">Cambia tu plan en cualquier momento</p>
</div>
<!-- Mensaje informativo -->
<div class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 mr-3 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h4 class="text-sm font-semibold text-blue-800 dark:text-blue-300 mb-1">Actualiza tu plan</h4>
<p class="text-sm text-blue-700 dark:text-blue-400">
Puedes cambiar a un plan superior en cualquier momento. Para planes de pago, serás redirigido a Stripe Checkout. El cambio a plan gratuito es inmediato.
</p>
</div>
</div>
</div>
<div v-if="loadingPlans" class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
<div v-else-if="plans && plans.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div
v-for="plan in plans"
:key="plan.id"
class="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-xl"
:class="
plan.id === subscription?.subscription?.planId
? 'border-primary-500 dark:border-primary-400 bg-primary-50 dark:bg-primary-900/20 shadow-lg'
: 'border-gray-200 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 hover:-translate-y-1'
"
>
<!-- Badge de plan popular -->
<div v-if="plan.id === 'basic'" class="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 bg-gradient-to-r from-primary-600 to-teal-600 text-white text-xs font-semibold rounded-full">
Más Popular
</div>
<div class="text-center mb-4">
<h4 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-1">{{ plan.name }}</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">{{ plan.description }}</p>
<div class="mb-4">
<span class="text-3xl font-bold text-gray-900 dark:text-gray-100">
{{ plan.price.monthly.toFixed(2) }}
</span>
<span class="text-gray-600 dark:text-gray-400">/mes</span>
<p v-if="plan.price.yearly > 0" class="text-xs text-primary-600 dark:text-primary-400 mt-1">
o {{ plan.price.yearly.toFixed(2) }}/año (ahorra 17%)
</p>
</div>
</div>
<ul class="space-y-2 mb-6">
<li v-for="feature in plan.features.slice(0, 4)" :key="feature" class="flex items-start text-sm">
<svg class="w-4 h-4 text-primary-600 dark:text-primary-400 mr-2 mt-0.5 flex-shrink-0" 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>
<span class="text-gray-700 dark:text-gray-300">{{ feature }}</span>
</li>
</ul>
<button
v-if="plan.id !== subscription?.subscription?.planId"
@click="upgradePlan(plan.id)"
:disabled="upgrading"
class="w-full btn transition-all duration-300"
:class="plan.id === 'free'
? 'btn-outline'
: 'btn-primary shadow-lg hover:shadow-xl'"
>
<span v-if="!upgrading">
{{ plan.id === 'free' ? 'Cambiar a gratuito' : 'Seleccionar plan' }}
</span>
<span v-else class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" 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>
Procesando...
</span>
</button>
<div v-else class="w-full text-center py-3 px-4 bg-gradient-to-r from-primary-100 to-teal-100 dark:from-primary-900/50 dark:to-teal-900/50 text-primary-700 dark:text-primary-400 rounded-lg font-semibold border-2 border-primary-300 dark:border-primary-700">
Plan Actual
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import api from '../services/api';
const subscription = ref(null);
const plans = ref([]);
const loading = ref(true);
const loadingPlans = ref(true);
const upgrading = ref(false);
const actionLoading = ref(false);
const showCancelModal = ref(false);
function formatDate(date) {
if (!date) return 'N/A';
return new Date(date).toLocaleDateString('es-ES', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
function getUsagePercentage(current, max) {
if (max === 'Ilimitado' || max === -1) return 0;
if (typeof max === 'string') return 0;
if (max === 0) return 0;
return Math.min((current / max) * 100, 100);
}
async function loadSubscription() {
try {
loading.value = true;
const data = await api.getMySubscription();
// Asegurar que la estructura de datos sea correcta
if (data && !data.plan && data.subscription) {
// Si viene la estructura del backend directamente
subscription.value = data;
} else if (data) {
subscription.value = data;
} else {
subscription.value = null;
}
} catch (error) {
console.error('Error cargando suscripción:', error);
if (error.response?.status === 401) {
// El usuario no está autenticado, se manejará automáticamente
subscription.value = null;
return;
}
subscription.value = null;
// Mostrar error genérico (solo en consola, no interrumpir la UI)
} finally {
loading.value = false;
}
}
async function loadPlans() {
try {
loadingPlans.value = true;
const data = await api.getSubscriptionPlans();
plans.value = data.plans || [];
} catch (error) {
console.error('Error cargando planes:', error);
// Error al cargar planes (solo en consola)
} finally {
loadingPlans.value = false;
}
}
async function upgradePlan(planId) {
if (upgrading.value) return;
const selectedPlan = plans.value.find(p => p.id === planId);
if (!selectedPlan) return;
// Si es plan de pago, crear sesión de checkout
if (planId !== 'free') {
// Preguntar período de facturación
const billingPeriodChoice = confirm('¿Deseas facturación anual? (17% de descuento)\n\nAceptar = Anual\nCancelar = Mensual');
const chosenBillingPeriod = billingPeriodChoice ? 'yearly' : 'monthly';
try {
upgrading.value = true;
const response = await api.post('/payments/create-checkout-session', {
planId,
billingPeriod: chosenBillingPeriod,
});
if (response.url) {
// Redirigir a Stripe Checkout
window.location.href = response.url;
}
} catch (error) {
console.error('Error creando sesión de checkout:', error);
const errorMessage = error.response?.data?.message || error.response?.data?.error || 'Error al crear la sesión de pago';
alert(errorMessage);
} finally {
upgrading.value = false;
}
} else {
// Plan gratuito
if (!confirm(`¿Estás seguro de que quieres cambiar al plan ${selectedPlan.name}?`)) {
return;
}
try {
upgrading.value = true;
await api.updateSubscription({ planId });
await loadSubscription();
} catch (error) {
console.error('Error actualizando plan:', error);
const errorMessage = error.response?.data?.message || error.response?.data?.error || 'Error al actualizar el plan';
alert(errorMessage);
} finally {
upgrading.value = false;
}
}
}
function openCancelModal() {
if (confirm('¿Estás seguro de que quieres cancelar tu suscripción? Se mantendrá activa hasta el final del período de facturación.')) {
cancelSubscription();
}
}
async function cancelSubscription() {
try {
actionLoading.value = true;
await api.post('/payments/cancel-subscription');
alert('Suscripción cancelada. Se mantendrá activa hasta el final del período.');
await loadSubscription();
} catch (error) {
console.error('Error cancelando suscripción:', error);
const errorMessage = error.response?.data?.message || error.response?.data?.error || 'Error al cancelar la suscripción';
alert(errorMessage);
} finally {
actionLoading.value = false;
}
}
async function reactivateSubscription() {
try {
actionLoading.value = true;
await api.post('/payments/reactivate-subscription');
alert('Suscripción reactivada correctamente.');
await loadSubscription();
} catch (error) {
console.error('Error reactivando suscripción:', error);
const errorMessage = error.response?.data?.message || error.response?.data?.error || 'Error al reactivar la suscripción';
alert(errorMessage);
} finally {
actionLoading.value = false;
}
}
async function openCustomerPortal() {
try {
actionLoading.value = true;
const response = await api.post('/payments/create-portal-session');
if (response.url) {
window.location.href = response.url;
}
} catch (error) {
console.error('Error abriendo portal de cliente:', error);
const errorMessage = error.response?.data?.message || error.response?.data?.error || 'Error al abrir el portal de gestión';
alert(errorMessage);
} finally {
actionLoading.value = false;
}
}
onMounted(() => {
loadSubscription();
loadPlans();
// Verificar si venimos de un pago exitoso
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('payment_success') === 'true') {
alert('¡Pago procesado exitosamente! Tu suscripción ha sido activada.');
// Limpiar URL
window.history.replaceState({}, document.title, window.location.pathname);
} else if (urlParams.get('payment_cancelled') === 'true') {
alert('Pago cancelado. Puedes intentarlo de nuevo cuando quieras.');
// Limpiar URL
window.history.replaceState({}, document.title, window.location.pathname);
}
});
</script>

View File

@@ -1,915 +0,0 @@
<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">Gestión de Usuarios</h1>
<div class="flex flex-wrap gap-2">
<button
v-if="isAdmin"
@click="showAddModal = true"
class="btn btn-primary text-xs sm:text-sm whitespace-nowrap"
>
+ Crear Usuario
</button>
</div>
</div>
<div v-if="!isAdmin" class="card text-center py-12">
<p class="text-gray-600 dark:text-gray-400 text-lg font-semibold mb-2">Acceso Denegado</p>
<p class="text-gray-500 dark:text-gray-500 text-sm">
Solo los administradores pueden acceder a esta sección.
</p>
<router-link to="/settings" class="btn btn-primary mt-4 inline-block">
Ir a Configuración
</router-link>
</div>
<div v-else-if="loading" class="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 usuarios...</p>
</div>
<div v-else class="space-y-4">
<!-- Lista de usuarios -->
<div v-if="users.length > 0" class="grid grid-cols-1 gap-4">
<div
v-for="user in users"
:key="user.username"
class="card hover:shadow-lg transition-shadow overflow-hidden"
>
<!-- Header con nombre y badges -->
<div class="flex items-center justify-between mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<!-- Avatar -->
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center text-white font-bold text-lg">
{{ user.username.charAt(0).toUpperCase() }}
</div>
<div>
<div class="flex items-center gap-2 mb-1">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">{{ user.username }}</h3>
<span
v-if="user.username === currentUser"
class="px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
>
</span>
<span
v-if="user.role === 'admin'"
class="px-2 py-0.5 text-xs font-semibold rounded-full bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200"
>
Admin
</span>
</div>
<!-- Estado de conexión inline -->
<div v-if="isAdmin" class="flex items-center gap-2">
<span
:class="{
'px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200': getUserConnectionStatus(user.username) === 'active',
'px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200': getUserConnectionStatus(user.username) === 'inactive',
'px-2 py-0.5 text-xs font-semibold rounded-full bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300': getUserConnectionStatus(user.username) === 'offline'
}"
>
{{ getUserConnectionStatus(user.username) === 'active' ? '🟢 Conectado' : getUserConnectionStatus(user.username) === 'inactive' ? '🟡 Inactivo' : '⚫ Desconectado' }}
</span>
<span v-if="getUserLastActivity(user.username)" class="text-xs text-gray-500 dark:text-gray-400">
{{ formatRelativeTime(getUserLastActivity(user.username)) }}
</span>
</div>
</div>
</div>
<!-- Botones de acción -->
<div class="flex gap-2">
<button
v-if="isAdmin"
@click="openSubscriptionModal(user.username)"
class="btn btn-primary text-xs sm:text-sm"
title="Gestionar suscripción"
>
💳 Plan
</button>
<button
v-if="user.username !== currentUser && isAdmin"
@click="confirmDeleteUser(user.username)"
class="btn btn-danger text-xs sm:text-sm"
title="Eliminar usuario"
>
🗑 Eliminar
</button>
</div>
</div>
<!-- Grid de información -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Columna izquierda: Información general -->
<div class="space-y-3">
<div>
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Información</h4>
<div class="space-y-2 text-sm">
<div v-if="user.createdAt" class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Creado:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ formatDate(user.createdAt) }}</span>
</div>
<div v-if="user.createdBy" class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Por:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ user.createdBy }}</span>
</div>
<div v-if="user.updatedAt" class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Actualizado:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ formatDate(user.updatedAt) }}</span>
</div>
<div v-if="isAdmin && getUserSessionCount(user.username) > 0" class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Sesiones activas:</span>
<span class="font-semibold text-primary-600 dark:text-primary-400">{{ getUserSessionCount(user.username) }}</span>
</div>
</div>
</div>
</div>
<!-- Columna derecha: Suscripción -->
<div>
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Suscripción</h4>
<div v-if="userSubscriptions[user.username]" class="p-3 bg-gradient-to-br from-primary-50 to-teal-50 dark:from-primary-900/20 dark:to-teal-900/20 rounded-lg border border-primary-200 dark:border-primary-700">
<div class="flex items-center justify-between mb-2">
<div>
<p class="text-xs text-gray-600 dark:text-gray-400">Plan</p>
<p class="text-lg font-bold text-primary-700 dark:text-primary-300">
{{ userSubscriptions[user.username].subscription?.plan?.name || 'Gratis' }}
</p>
</div>
<span
:class="userSubscriptions[user.username].subscription?.status === 'active' ? 'badge badge-success' : 'badge badge-warning'"
class="text-xs"
>
{{ userSubscriptions[user.username].subscription?.status === 'active' ? 'Activo' : 'Inactivo' }}
</span>
</div>
<div v-if="userSubscriptions[user.username].usage" class="pt-2 border-t border-primary-200 dark:border-primary-800">
<div class="flex justify-between items-center">
<span class="text-xs text-gray-600 dark:text-gray-400">Uso de búsquedas</span>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ userSubscriptions[user.username].usage.workers }} / {{ userSubscriptions[user.username].usage.maxWorkers === 'Ilimitado' ? '∞' : userSubscriptions[user.username].usage.maxWorkers }}
</span>
</div>
<!-- Barra de progreso -->
<div v-if="userSubscriptions[user.username].usage.maxWorkers !== 'Ilimitado'" class="mt-2 w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="bg-gradient-to-r from-primary-500 to-primary-600 h-2 rounded-full transition-all"
:style="{ width: `${Math.min(100, (userSubscriptions[user.username].usage.workers / userSubscriptions[user.username].usage.maxWorkers) * 100)}%` }"
></div>
</div>
</div>
</div>
<div v-else class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 text-center text-sm text-gray-500 dark:text-gray-400">
Sin información de suscripción
</div>
</div>
</div>
</div>
</div>
<div v-else class="card text-center py-12">
<p class="text-gray-600 dark:text-gray-400 mb-4">No hay usuarios configurados</p>
<button @click="showAddModal = true" class="btn btn-primary">
+ Crear primer usuario
</button>
</div>
</div>
<!-- Modal para crear/editar usuario -->
<div
v-if="showAddModal"
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
@click.self="closeAddModal"
>
<div class="card max-w-md w-full max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">Crear Usuario</h2>
<button
@click="closeAddModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Cerrar"
>
</button>
</div>
<form @submit.prevent="handleCreateUser" class="space-y-4">
<div v-if="addError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
{{ addError }}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Nombre de usuario <span class="text-red-500">*</span>
</label>
<input
v-model="userForm.username"
type="text"
class="input"
placeholder="nuevo_usuario"
required
minlength="3"
autocomplete="username"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Mínimo 3 caracteres
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Contraseña <span class="text-red-500">*</span>
</label>
<input
v-model="userForm.password"
type="password"
class="input"
placeholder="••••••••"
required
minlength="6"
autocomplete="new-password"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Mínimo 6 caracteres
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirmar contraseña <span class="text-red-500">*</span>
</label>
<input
v-model="userForm.passwordConfirm"
type="password"
class="input"
placeholder="••••••••"
required
minlength="6"
autocomplete="new-password"
/>
</div>
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="closeAddModal"
class="btn btn-secondary text-sm sm:text-base"
>
Cancelar
</button>
<button
type="submit"
class="btn btn-primary text-sm sm:text-base"
:disabled="loadingAction"
>
{{ loadingAction ? 'Creando...' : 'Crear Usuario' }}
</button>
</div>
</form>
</div>
</div>
<!-- Modal para gestionar suscripción (solo admin) -->
<div
v-if="showSubscriptionModal && selectedUserForSubscription"
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
@click.self="closeSubscriptionModal"
>
<div class="card max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">
Gestionar Suscripción: {{ selectedUserForSubscription }}
</h2>
<button
@click="closeSubscriptionModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Cerrar"
>
</button>
</div>
<div v-if="loadingSubscription" class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Cargando información...</p>
</div>
<div v-else class="space-y-6">
<!-- Información actual -->
<div v-if="selectedUserSubscription && selectedUserSubscription.subscription" class="p-4 bg-gradient-to-br from-primary-50 to-teal-50 dark:from-primary-900/20 dark:to-teal-900/20 rounded-xl border border-primary-200 dark:border-primary-800">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Plan Actual</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-xs text-gray-600 dark:text-gray-400">Plan</p>
<p class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{ selectedUserSubscription.subscription?.plan?.name || 'Gratis' }}
</p>
</div>
<div>
<p class="text-xs text-gray-600 dark:text-gray-400">Estado</p>
<span
:class="selectedUserSubscription.subscription?.status === 'active' ? 'badge badge-success' : 'badge badge-warning'"
>
{{ selectedUserSubscription.subscription?.status === 'active' ? 'Activo' : 'Inactivo' }}
</span>
</div>
<div v-if="selectedUserSubscription.usage">
<p class="text-xs text-gray-600 dark:text-gray-400">Búsquedas</p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ selectedUserSubscription.usage.workers }} / {{ selectedUserSubscription.usage.maxWorkers === 'Ilimitado' ? '∞' : selectedUserSubscription.usage.maxWorkers }}
</p>
</div>
</div>
</div>
<!-- Cambiar plan -->
<div>
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Cambiar Plan</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
v-for="plan in availablePlans"
:key="plan.id"
@click="subscriptionForm.planId = plan.id"
class="p-4 rounded-lg border-2 text-left transition-all"
:class="
subscriptionForm.planId === plan.id
? 'border-primary-500 dark:border-primary-400 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-primary-300 dark:hover:border-primary-700'
"
>
<div class="flex items-center justify-between">
<div>
<p class="font-bold text-gray-900 dark:text-gray-100">{{ plan.name }}</p>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">{{ plan.description }}</p>
</div>
<div v-if="subscriptionForm.planId === plan.id" class="text-primary-600 dark:text-primary-400">
</div>
</div>
</button>
</div>
</div>
<!-- Formulario de actualización -->
<form @submit.prevent="handleUpdateUserSubscription" class="space-y-4">
<div v-if="subscriptionError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
{{ subscriptionError }}
</div>
<div v-if="subscriptionSuccess" class="bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded">
{{ subscriptionSuccess }}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Estado de la suscripción
</label>
<select
v-model="subscriptionForm.status"
class="input"
>
<option value="active">Activo</option>
<option value="inactive">Inactivo</option>
<option value="cancelled">Cancelado</option>
</select>
</div>
<div class="flex items-center">
<input
v-model="subscriptionForm.cancelAtPeriodEnd"
type="checkbox"
id="cancelAtPeriodEnd"
class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<label for="cancelAtPeriodEnd" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
Cancelar al final del período
</label>
</div>
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="closeSubscriptionModal"
class="btn btn-secondary text-sm sm:text-base"
>
Cancelar
</button>
<button
type="submit"
class="btn btn-primary text-sm sm:text-base"
:disabled="loadingAction"
>
{{ loadingAction ? 'Actualizando...' : 'Actualizar Suscripción' }}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal de confirmación para eliminar -->
<div
v-if="userToDelete"
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
@click.self="userToDelete = null"
>
<div class="card max-w-md w-full">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">Confirmar Eliminación</h2>
<button
@click="userToDelete = null"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Cerrar"
>
</button>
</div>
<p class="text-gray-700 dark:text-gray-300 mb-4">
¿Estás seguro de que deseas eliminar al usuario <strong>{{ userToDelete }}</strong>?
</p>
<p class="text-sm text-red-600 dark:text-red-400 mb-4">
Esta acción no se puede deshacer.
</p>
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="userToDelete = null"
class="btn btn-secondary text-sm sm:text-base"
>
Cancelar
</button>
<button
@click="handleDeleteUser"
class="btn btn-danger text-sm sm:text-base"
:disabled="loadingAction"
>
{{ loadingAction ? 'Eliminando...' : 'Eliminar Usuario' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import api from '../services/api';
import authService from '../services/auth';
const users = ref([]);
const loading = ref(true);
const loadingAction = ref(false);
const showAddModal = ref(false);
const showSubscriptionModal = ref(false);
const userToDelete = ref(null);
const selectedUserForSubscription = ref(null);
const loadingSubscription = ref(false);
const userSubscriptions = ref({});
const availablePlans = ref([]);
const subscriptionError = ref('');
const subscriptionSuccess = ref('');
const addError = ref('');
const activeUsers = ref([]);
const userSessions = ref({});
const userForm = ref({
username: '',
password: '',
passwordConfirm: '',
});
const subscriptionForm = ref({
planId: 'free',
status: 'active',
cancelAtPeriodEnd: false,
});
const selectedUserSubscription = ref(null);
const isAuthenticated = computed(() => authService.hasCredentials());
const currentUser = computed(() => {
return authService.getUsername() || '';
});
const isAdmin = computed(() => {
return authService.isAdmin();
});
function formatDate(dateString) {
if (!dateString) return 'N/A';
try {
const date = new Date(dateString);
return date.toLocaleString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch (e) {
return dateString;
}
}
function formatRelativeTime(timestamp) {
if (!timestamp) return 'Nunca';
const date = new Date(timestamp);
const now = new Date();
const diffInSeconds = Math.floor((now - date) / 1000);
if (diffInSeconds < 60) {
return 'Hace unos segundos';
} else if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60);
return `Hace ${minutes} ${minutes === 1 ? 'minuto' : 'minutos'}`;
} else if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `Hace ${hours} ${hours === 1 ? 'hora' : 'horas'}`;
} else {
const days = Math.floor(diffInSeconds / 86400);
return `Hace ${days} ${days === 1 ? 'día' : 'días'}`;
}
}
// Obtener el estado de conexión de un usuario
function getUserConnectionStatus(username) {
// Buscar si el usuario tiene alguna sesión activa
const activeSessions = userSessions.value[username] || [];
if (activeSessions.length === 0) {
return 'offline';
}
// Verificar si alguna sesión está activamente conectada
const hasActiveSession = activeSessions.some(session => session.isActive);
if (hasActiveSession) {
return 'active';
}
// Si tiene sesiones válidas pero ninguna activa
const hasValidSession = activeSessions.some(session => !session.isExpired);
if (hasValidSession) {
return 'inactive';
}
return 'offline';
}
// Obtener la última actividad de un usuario (de todas sus sesiones)
function getUserLastActivity(username) {
const sessions = userSessions.value[username] || [];
if (sessions.length === 0) {
return null;
}
// Encontrar la sesión con la actividad más reciente
let latestActivity = null;
for (const session of sessions) {
if (session.lastActivity) {
const activityDate = new Date(session.lastActivity);
if (!latestActivity || activityDate > latestActivity) {
latestActivity = activityDate;
}
}
}
return latestActivity ? latestActivity.toISOString() : null;
}
// Obtener el número de sesiones activas de un usuario
function getUserSessionCount(username) {
const sessions = userSessions.value[username] || [];
return sessions.filter(s => !s.isExpired).length;
}
async function loadUsers() {
loading.value = true;
try {
const data = await api.getUsers();
users.value = data.users || [];
// Cargar información de suscripción para todos los usuarios (solo si es admin)
if (isAdmin.value) {
await Promise.all([
loadAllUserSubscriptions(),
loadUserSessions(),
]);
}
} catch (error) {
console.error('Error cargando usuarios:', error);
// El modal de login se manejará automáticamente desde App.vue
} finally {
loading.value = false;
}
}
async function loadUserSessions() {
if (!isAdmin.value) return;
try {
// Obtener todas las sesiones
const data = await api.getSessions();
const sessions = data.sessions || [];
// Agrupar sesiones por usuario
const sessionsByUser = {};
for (const session of sessions) {
if (!sessionsByUser[session.username]) {
sessionsByUser[session.username] = [];
}
sessionsByUser[session.username].push(session);
}
userSessions.value = sessionsByUser;
} catch (error) {
console.error('Error cargando sesiones de usuarios:', error);
}
}
async function loadAllUserSubscriptions() {
// Cargar suscripciones de todos los usuarios en paralelo
const subscriptionPromises = users.value.map(async (user) => {
try {
// Usar el endpoint de admin para obtener suscripción de cualquier usuario
const response = await fetch(`/api/subscription/${user.username}`, {
method: 'GET',
headers: {
'Authorization': authService.getAuthHeader(),
},
});
if (response.ok) {
const data = await response.json();
console.log(`Suscripción de ${user.username}:`, data);
userSubscriptions.value[user.username] = data;
} else {
console.warn(`No se pudo cargar suscripción de ${user.username}, usando valores por defecto`);
// Valores por defecto si no se puede cargar
userSubscriptions.value[user.username] = {
subscription: {
planId: 'free',
status: 'active',
plan: { name: 'Gratis', description: 'Plan gratuito' }
},
usage: { workers: 0, maxWorkers: 2 }
};
}
} catch (error) {
console.error(`Error cargando suscripción de ${user.username}:`, error);
// Valores por defecto en caso de error
userSubscriptions.value[user.username] = {
subscription: {
planId: 'free',
status: 'active',
plan: { name: 'Gratis', description: 'Plan gratuito' }
},
usage: { workers: 0, maxWorkers: 2 }
};
}
});
await Promise.all(subscriptionPromises);
}
async function loadAvailablePlans() {
try {
const data = await api.getSubscriptionPlans();
availablePlans.value = data.plans || [];
} catch (error) {
console.error('Error cargando planes:', error);
}
}
async function openSubscriptionModal(username) {
selectedUserForSubscription.value = username;
showSubscriptionModal.value = true;
loadingSubscription.value = true;
subscriptionError.value = '';
subscriptionSuccess.value = '';
try {
// Cargar planes disponibles
await loadAvailablePlans();
// Cargar suscripción del usuario seleccionado
const response = await fetch(`/api/subscription/${username}`, {
method: 'GET',
headers: {
'Authorization': authService.getAuthHeader(),
},
});
if (response.ok) {
const data = await response.json();
selectedUserSubscription.value = data;
subscriptionForm.value = {
planId: data.subscription?.planId || 'free',
status: data.subscription?.status || 'active',
cancelAtPeriodEnd: data.subscription?.cancelAtPeriodEnd || false,
};
} else {
// Si no hay suscripción, usar valores por defecto
selectedUserSubscription.value = {
subscription: {
planId: 'free',
status: 'active',
plan: { name: 'Gratis', description: 'Plan gratuito' }
},
usage: { workers: 0, maxWorkers: 2 },
};
subscriptionForm.value = {
planId: 'free',
status: 'active',
cancelAtPeriodEnd: false,
};
}
} catch (error) {
console.error('Error cargando suscripción:', error);
subscriptionError.value = 'Error al cargar información de suscripción';
} finally {
loadingSubscription.value = false;
}
}
function closeSubscriptionModal() {
showSubscriptionModal.value = false;
selectedUserForSubscription.value = null;
selectedUserSubscription.value = null;
subscriptionError.value = '';
subscriptionSuccess.value = '';
subscriptionForm.value = {
planId: 'free',
status: 'active',
cancelAtPeriodEnd: false,
};
}
async function handleUpdateUserSubscription() {
if (!selectedUserForSubscription.value) return;
subscriptionError.value = '';
subscriptionSuccess.value = '';
loadingAction.value = true;
try {
await api.updateUserSubscription(selectedUserForSubscription.value, {
planId: subscriptionForm.value.planId,
status: subscriptionForm.value.status,
cancelAtPeriodEnd: subscriptionForm.value.cancelAtPeriodEnd,
});
subscriptionSuccess.value = 'Suscripción actualizada correctamente';
// Actualizar la información en la lista
await loadAllUserSubscriptions();
// Recargar la información del modal después de un momento
setTimeout(async () => {
await openSubscriptionModal(selectedUserForSubscription.value);
}, 1000);
} catch (error) {
console.error('Error actualizando suscripción:', error);
subscriptionError.value = error.response?.data?.error || 'Error al actualizar la suscripción';
} finally {
loadingAction.value = false;
}
}
async function handleCreateUser() {
addError.value = '';
loadingAction.value = true;
if (!userForm.value.username || !userForm.value.password || !userForm.value.passwordConfirm) {
addError.value = 'Todos los campos son requeridos';
loadingAction.value = false;
return;
}
if (userForm.value.username.length < 3) {
addError.value = 'El nombre de usuario debe tener al menos 3 caracteres';
loadingAction.value = false;
return;
}
if (userForm.value.password.length < 6) {
addError.value = 'La contraseña debe tener al menos 6 caracteres';
loadingAction.value = false;
return;
}
if (userForm.value.password !== userForm.value.passwordConfirm) {
addError.value = 'Las contraseñas no coinciden';
loadingAction.value = false;
return;
}
try {
await api.createUser({
username: userForm.value.username,
password: userForm.value.password,
});
closeAddModal();
await loadUsers();
} catch (error) {
console.error('Error creando usuario:', error);
if (error.response?.data?.error) {
addError.value = error.response.data.error;
} else {
addError.value = 'Error creando usuario. Intenta de nuevo.';
}
} finally {
loadingAction.value = false;
}
}
async function handleDeleteUser() {
if (!userToDelete.value) return;
loadingAction.value = true;
try {
await api.deleteUser(userToDelete.value);
userToDelete.value = null;
await loadUsers();
} catch (error) {
console.error('Error eliminando usuario:', error);
alert(error.response?.data?.error || 'Error eliminando usuario. Intenta de nuevo.');
} finally {
loadingAction.value = false;
}
}
function confirmDeleteUser(username) {
userToDelete.value = username;
}
function closeAddModal() {
showAddModal.value = false;
addError.value = '';
userForm.value = {
username: '',
password: '',
passwordConfirm: '',
};
}
function handleAuthLogout() {
// Cuando el usuario se desconecta globalmente, limpiar datos
users.value = [];
showAddModal.value = false;
userToDelete.value = null;
addError.value = '';
activeUsers.value = [];
userSessions.value = {};
}
// Manejar cambios de estado de usuarios vía WebSocket
function handleUserStatusChange(event) {
const { username, status } = event.detail;
// Recargar sesiones para actualizar el estado
if (isAdmin.value) {
loadUserSessions();
}
}
let refreshInterval = null;
onMounted(() => {
loadUsers();
window.addEventListener('auth-logout', handleAuthLogout);
// Escuchar evento de login exitoso para recargar usuarios
window.addEventListener('auth-login', () => {
loadUsers();
});
// Escuchar cambios de estado de usuarios
window.addEventListener('user-status-change', handleUserStatusChange);
// Actualizar sesiones periódicamente (cada 30 segundos)
if (isAdmin.value) {
refreshInterval = setInterval(() => {
loadUserSessions();
}, 30000);
}
});
onUnmounted(() => {
window.removeEventListener('auth-logout', handleAuthLogout);
window.removeEventListener('auth-login', loadUsers);
window.removeEventListener('user-status-change', handleUserStatusChange);
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
</script>

View File

@@ -11,19 +11,18 @@ RUN npm ci
# Copiar código fuente
COPY . .
# Construir aplicación Astro
# Construir aplicación
RUN npm run build
# Stage de producción - servir con nginx
FROM nginx:alpine
# Copiar archivos construidos a la raíz
# Copiar archivos construidos
COPY --from=builder /app/dist /usr/share/nginx/html
# Copiar configuración de nginx
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Exponer puerto
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

49
web/frontend/nginx.conf Normal file
View File

@@ -0,0 +1,49 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
# SPA routing
location / {
try_files $uri $uri/ /index.html;
}
# API proxy
location /api {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket proxy
location /ws {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View File

@@ -1,11 +1,11 @@
{
"name": "wallabicher-dashboard",
"name": "wallabicher-frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wallabicher-dashboard",
"name": "wallabicher-frontend",
"version": "1.0.0",
"dependencies": {
"@fingerprintjs/fingerprintjs": "^5.0.1",

View File

@@ -1,5 +1,5 @@
{
"name": "wallabicher-dashboard",
"name": "wallabicher-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {

View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -20,8 +20,8 @@ self.addEventListener('push', (event) => {
let notificationData = {
title: 'Wallabicher',
body: 'Tienes nuevas notificaciones',
icon: '/dashboard/android-chrome-192x192.png',
badge: '/dashboard/android-chrome-192x192.png',
icon: '/android-chrome-192x192.png',
badge: '/android-chrome-192x192.png',
tag: 'wallabicher-notification',
requireInteraction: false,
data: {}
@@ -89,7 +89,7 @@ self.addEventListener('notificationclick', (event) => {
} else {
// Si no hay URL, abrir la app
event.waitUntil(
clients.openWindow('/dashboard/')
clients.openWindow('/')
);
}
});

View File

@@ -1,7 +1,7 @@
<template>
<div class="min-h-screen bg-gray-100 dark:bg-gray-900" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
<!-- Sidebar - Solo mostrar si no estamos en login o register -->
<template v-if="!isPublicRoute">
<!-- Sidebar - Solo mostrar si no estamos en login -->
<template v-if="$route.path !== '/login'">
<!-- Sidebar -->
<aside
class="fixed top-0 left-0 z-40 h-screen transition-all duration-300 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 shadow-lg"
@@ -83,9 +83,9 @@
</template>
<!-- Main Content -->
<div class="transition-all duration-300" :class="isPublicRoute ? '' : (sidebarCollapsed ? 'ml-20' : 'ml-64')">
<!-- Header - Solo mostrar si no estamos en login o register -->
<header v-if="!isPublicRoute" class="sticky top-0 z-30 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
<div class="transition-all duration-300" :class="$route.path === '/login' ? '' : (sidebarCollapsed ? 'ml-20' : 'ml-64')">
<!-- Header - Solo mostrar si no estamos en login -->
<header v-if="$route.path !== '/login'" class="sticky top-0 z-30 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
<div class="flex items-center justify-between h-16 px-6">
<!-- Breadcrumbs -->
<div class="flex items-center space-x-2">
@@ -138,13 +138,13 @@
</header>
<!-- Page Content -->
<main :class="isPublicRoute ? '' : 'p-6 pb-20'">
<main :class="$route.path === '/login' ? '' : 'p-6 pb-20'">
<router-view />
</main>
</div>
<!-- Footer Fixed - Solo mostrar si no estamos en login o register -->
<footer v-if="!isPublicRoute" 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'">
<!-- 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.
@@ -164,12 +164,10 @@ import {
DocumentTextIcon,
HeartIcon,
Cog6ToothIcon,
CogIcon,
UserGroupIcon,
DocumentMagnifyingGlassIcon,
ShieldExclamationIcon,
ClockIcon,
CreditCardIcon,
Bars3Icon,
XMarkIcon,
SunIcon,
@@ -178,43 +176,32 @@ import {
BellSlashIcon,
ArrowRightOnRectangleIcon,
} from '@heroicons/vue/24/outline';
import pushNotificationService from './services/pushNotifications';
import authService from './services/auth';
import { useRouter, useRoute } from 'vue-router';
import api from './services/api';
import ToastContainer from './components/ToastContainer.vue';
import pushNotificationService from './application/services/PushNotificationService.js';
import authService from './application/services/AuthService.js';
import webSocketService from './core/websocket/WebSocketService.js';
import { useRouter } from 'vue-router';
import { useAuth } from './presentation/composables/useAuth.js';
import { useWebSocket } from './presentation/composables/useWebSocket.js';
import { useDarkMode } from './presentation/composables/useDarkMode.js';
import ToastContainer from './presentation/components/ToastContainer.vue';
const allNavItems = [
{ path: '/', name: 'Dashboard', icon: HomeIcon, adminOnly: false },
{ path: '/articles', name: 'Artículos', icon: DocumentTextIcon, adminOnly: false },
{ path: '/favorites', name: 'Favoritos', icon: HeartIcon, adminOnly: false },
{ path: '/workers', name: 'Workers', icon: Cog6ToothIcon, adminOnly: false },
{ path: '/subscription', name: 'Suscripción', icon: CreditCardIcon, adminOnly: false },
{ path: '/settings', name: 'Configuración', icon: CogIcon, adminOnly: false },
{ path: '/users', name: 'Usuarios', icon: UserGroupIcon, adminOnly: true },
{ 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();
const route = useRoute();
const wsConnected = ref(false);
const { currentUser, isAuthenticated, isAdmin, username } = useAuth();
const { isConnected: wsConnected } = useWebSocket();
const { isDark, toggle: toggleDarkMode } = useDarkMode();
const sidebarCollapsed = ref(false);
const darkMode = ref(false);
const pushEnabled = ref(false);
const currentUser = ref(authService.getUsername() || null);
const isAdmin = ref(false);
let ws = null;
let heartbeatInterval = null;
let activityThrottleTimeout = null;
const isDark = computed(() => darkMode.value);
const isAuthenticated = computed(() => authService.hasCredentials());
// Rutas públicas que no deben mostrar el sidebar/header
const publicRoutes = ['/login', '/register'];
const isPublicRoute = computed(() => publicRoutes.includes(route.path));
// Filtrar navItems según el rol del usuario
const navItems = computed(() => {
@@ -228,27 +215,6 @@ const navItems = computed(() => {
});
function toggleDarkMode() {
darkMode.value = !darkMode.value;
if (darkMode.value) {
document.documentElement.classList.add('dark');
localStorage.setItem('darkMode', 'true');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('darkMode', 'false');
}
}
function initDarkMode() {
const saved = localStorage.getItem('darkMode');
if (saved === 'true' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
darkMode.value = true;
document.documentElement.classList.add('dark');
} else {
darkMode.value = false;
document.documentElement.classList.remove('dark');
}
}
async function enablePushNotifications() {
try {
@@ -298,67 +264,35 @@ function getCurrentPageTitle() {
}
function handleAuthChange() {
currentUser.value = authService.getUsername() || null;
isAdmin.value = authService.isAdmin();
// Reconectar websocket cuando cambie la autenticación (login)
if (authService.hasCredentials()) {
connectWebSocket();
// WebSocket will be connected automatically by AuthService
const token = authService.getToken();
if (token) {
webSocketService.connect(token);
}
}
async function handleLogout() {
// Cerrar conexión WebSocket antes de hacer logout
if (ws) {
ws.close();
ws = null;
wsConnected.value = false;
}
// Llamar al endpoint de logout e invalidar token
await authService.logout();
// Redirigir a login después del logout
router.push('/login');
// Disparar evento para que los componentes se actualicen
window.dispatchEvent(new CustomEvent('auth-logout'));
// Mostrar mensaje informativo
console.log('Sesión cerrada correctamente');
}
onMounted(async () => {
initDarkMode();
currentUser.value = authService.getUsername() || null;
isAdmin.value = authService.isAdmin();
await checkPushStatus();
// Escuchar eventos de autenticación
window.addEventListener('auth-login', handleAuthChange);
window.addEventListener('auth-logout', handleAuthChange);
// Escuchar eventos de actividad del usuario para enviar al servidor
// Estos eventos ayudan a mantener la sesión activa
window.addEventListener('click', sendUserActivity);
window.addEventListener('keydown', sendUserActivity);
window.addEventListener('scroll', sendUserActivity, { passive: true });
window.addEventListener('mousemove', sendUserActivity, { passive: true });
// Escuchar eventos de WebSocket
window.addEventListener('ws-connected', () => {
// Connection state is managed by useWebSocket composable
});
// Si hay credenciales, validar y conectar websocket
// Si hay credenciales, 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 (!publicRoutes.includes(router.currentRoute.value.path)) {
router.push('/login');
}
} else {
// Solo conectar websocket si el token es válido
connectWebSocket();
const token = authService.getToken();
if (token) {
webSocketService.connect(token);
}
}
});
@@ -366,171 +300,6 @@ onMounted(async () => {
onUnmounted(() => {
window.removeEventListener('auth-login', handleAuthChange);
window.removeEventListener('auth-logout', handleAuthChange);
// Limpiar listeners de actividad
window.removeEventListener('click', sendUserActivity);
window.removeEventListener('keydown', sendUserActivity);
window.removeEventListener('scroll', sendUserActivity);
window.removeEventListener('mousemove', sendUserActivity);
// Detener heartbeat
stopHeartbeat();
if (ws) {
ws.close();
}
});
function connectWebSocket() {
// Cerrar conexión existente si hay una
if (ws) {
ws.close();
ws = null;
}
// Limpiar heartbeat interval previo
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
// Verificar si hay token de autenticación
const token = authService.getToken();
if (!token) {
console.log('No hay token de autenticación, no se conectará WebSocket');
wsConnected.value = false;
return;
}
let wsUrl;
// Si hay una URL de API configurada, usarla para WebSocket también
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
if (apiBaseUrl && apiBaseUrl !== '/api') {
// Extraer el host de la URL de la API y construir la URL del WebSocket
try {
const url = new URL(apiBaseUrl);
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${url.host}/ws?token=${encodeURIComponent(token)}`;
} catch (e) {
// Si falla el parsing, usar la configuración por defecto
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
}
} else {
// Use relative path so Vite proxy can handle it
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
}
ws = new WebSocket(wsUrl);
ws.onopen = () => {
wsConnected.value = true;
console.log('WebSocket conectado');
// Iniciar heartbeat cada 30 segundos
startHeartbeat();
};
ws.onclose = (event) => {
wsConnected.value = false;
// Detener heartbeat
stopHeartbeat();
// Si el cierre fue por autenticación fallida (código 1008), no reintentar
if (event.code === 1008) {
console.log('WebSocket cerrado: autenticación fallida');
// Si el token aún existe, intentar reconectar después de un delay más largo
// para dar tiempo a que el usuario se autentique de nuevo
if (authService.hasCredentials()) {
setTimeout(connectWebSocket, 5000);
}
} else {
// Para otros errores, reintentar después de 3 segundos si hay token
if (authService.hasCredentials()) {
console.log('WebSocket desconectado, reintentando...');
setTimeout(connectWebSocket, 3000);
}
}
};
ws.onerror = (error) => {
console.error('Error WebSocket:', error);
wsConnected.value = false;
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// Manejar respuesta de pong
if (data.type === 'pong') {
// El servidor respondió al heartbeat
return;
}
// Manejar confirmación de conexión
if (data.type === 'connection' && data.status === 'connected') {
console.log('Conexión WebSocket confirmada');
return;
}
// Manejar cambios de estado de usuarios
if (data.type === 'user_status') {
console.log(`Usuario ${data.username} cambió a estado: ${data.status}`);
// Emitir evento para que otros componentes lo manejen
window.dispatchEvent(new CustomEvent('user-status-change', { detail: data }));
return;
}
// Los componentes individuales manejarán los mensajes (incluyendo ToastContainer)
window.dispatchEvent(new CustomEvent('ws-message', { detail: data }));
};
}
// Enviar heartbeat al servidor periódicamente
function startHeartbeat() {
// Detener heartbeat previo si existe
stopHeartbeat();
// Enviar heartbeat cada 30 segundos
heartbeatInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'heartbeat',
timestamp: new Date().toISOString()
}));
}
}, 30000); // 30 segundos
}
function stopHeartbeat() {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
}
// Enviar actividad del usuario al servidor (throttled)
function sendUserActivity() {
// Si ya hay un timeout pendiente, no hacer nada
if (activityThrottleTimeout) {
return;
}
// Enviar actividad y crear throttle de 10 segundos
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'activity',
timestamp: new Date().toISOString()
}));
}
// Throttle: no enviar más actividad por 10 segundos
activityThrottleTimeout = setTimeout(() => {
activityThrottleTimeout = null;
}, 10000);
}
</script>

View File

@@ -0,0 +1,17 @@
import adminRepository from '../../domain/repositories/AdminRepository.js';
class AdminService {
async getRateLimiterInfo() {
return await adminRepository.getRateLimiterInfo();
}
async getSessions() {
return await adminRepository.getSessions();
}
async deleteSession(token) {
await adminRepository.deleteSession(token);
}
}
export default new AdminService();

View File

@@ -0,0 +1,21 @@
import articleRepository from '../../domain/repositories/ArticleRepository.js';
class ArticleService {
async getArticles(limit = 100, offset = 0, filters = {}) {
return await articleRepository.getArticles(limit, offset, filters);
}
async getArticleFacets() {
return await articleRepository.getArticleFacets();
}
async searchArticles(query, mode = 'AND') {
return await articleRepository.searchArticles(query, mode);
}
async getArticle(platform, id) {
return await articleRepository.getArticle(platform, id);
}
}
export default new ArticleService();

View File

@@ -0,0 +1,124 @@
import authRepository from '../../domain/repositories/AuthRepository.js';
import storageService from '../../core/storage/StorageService.js';
import { User } from '../../domain/entities/User.js';
import apiClient from '../../core/http/ApiClient.js';
import webSocketService from '../../core/websocket/WebSocketService.js';
const AUTH_STORAGE_KEY = 'wallabicher_auth';
class AuthService {
constructor() {
this.currentUser = this.loadUser();
this.setupApiClient();
}
loadUser() {
const authData = storageService.get(AUTH_STORAGE_KEY);
if (authData && authData.token) {
return new User(authData);
}
return null;
}
setupApiClient() {
if (this.currentUser?.token) {
apiClient.setAuthToken(this.currentUser.token);
}
}
async login(username, password) {
const data = await authRepository.login(username, password);
if (data.success && data.token) {
const user = new User({
username: data.username,
role: data.role || 'user',
token: data.token,
});
this.currentUser = user;
storageService.set(AUTH_STORAGE_KEY, user.toJSON());
apiClient.setAuthToken(user.token);
webSocketService.connect(user.token);
window.dispatchEvent(new CustomEvent('auth-login', { detail: user }));
return user;
}
throw new Error('Respuesta inválida del servidor');
}
async logout() {
if (this.currentUser?.token) {
try {
await authRepository.logout(this.currentUser.token);
} catch (error) {
console.error('Error al cerrar sesión en el servidor:', error);
}
}
this.currentUser = null;
storageService.remove(AUTH_STORAGE_KEY);
apiClient.clearAuthToken();
webSocketService.close();
window.dispatchEvent(new CustomEvent('auth-logout'));
}
async validateSession() {
if (!this.currentUser?.token) {
return false;
}
const data = await authRepository.validateSession(this.currentUser.token);
if (data) {
if (data.role) {
this.currentUser.role = data.role;
storageService.set(AUTH_STORAGE_KEY, this.currentUser.toJSON());
}
return true;
}
this.clearSession();
return false;
}
clearSession() {
this.currentUser = null;
storageService.remove(AUTH_STORAGE_KEY);
apiClient.clearAuthToken();
webSocketService.close();
}
getCurrentUser() {
return this.currentUser;
}
getToken() {
return this.currentUser?.token || null;
}
getUsername() {
return this.currentUser?.username || null;
}
getRole() {
return this.currentUser?.role || null;
}
isAdmin() {
return this.currentUser?.isAdmin || false;
}
hasCredentials() {
return !!this.currentUser?.token;
}
getAuthHeader() {
return this.currentUser?.token ? `Bearer ${this.currentUser.token}` : null;
}
}
export default new AuthService();

View File

@@ -0,0 +1,19 @@
import favoriteRepository from '../../domain/repositories/FavoriteRepository.js';
import { Favorite } from '../../domain/entities/Favorite.js';
class FavoriteService {
async getFavorites() {
return await favoriteRepository.getFavorites();
}
async addFavorite(platform, id) {
const favorite = new Favorite({ platform, id });
return await favoriteRepository.addFavorite(favorite);
}
async removeFavorite(platform, id) {
await favoriteRepository.removeFavorite(platform, id);
}
}
export default new FavoriteService();

View File

@@ -0,0 +1,9 @@
import logRepository from '../../domain/repositories/LogRepository.js';
class LogService {
async getLogs(limit = 500, sinceLine = null) {
return await logRepository.getLogs(limit, sinceLine);
}
}
export default new LogService();

View File

@@ -1,11 +1,10 @@
// Servicio para manejar notificaciones push
// Push Notification Service
class PushNotificationService {
constructor() {
this.registration = null;
this.subscription = null;
}
// Registrar Service Worker
async registerServiceWorker() {
if ('serviceWorker' in navigator) {
try {
@@ -13,22 +12,17 @@ class PushNotificationService {
scope: '/'
});
this.registration = registration;
console.log('Service Worker registrado:', registration.scope);
return registration;
} catch (error) {
console.error('Error registrando Service Worker:', error);
return null;
}
} else {
console.warn('Service Workers no están soportados en este navegador');
return null;
}
return null;
}
// Solicitar permisos de notificación
async requestPermission() {
if (!('Notification' in window)) {
console.warn('Este navegador no soporta notificaciones');
return 'denied';
}
@@ -41,15 +35,13 @@ class PushNotificationService {
}
try {
const permission = await Notification.requestPermission();
return permission;
return await Notification.requestPermission();
} catch (error) {
console.error('Error solicitando permiso:', error);
return 'denied';
}
}
// Suscribirse a notificaciones push
async subscribe() {
if (!this.registration) {
await this.registerServiceWorker();
@@ -60,30 +52,22 @@ class PushNotificationService {
}
try {
// Verificar si ya existe una suscripción
this.subscription = await this.registration.pushManager.getSubscription();
if (this.subscription) {
console.log('Ya existe una suscripción push');
return this.subscription;
}
// Obtener la clave pública del servidor
const response = await fetch('/api/push/public-key');
const { publicKey } = await response.json();
// Convertir la clave pública a formato ArrayBuffer
const applicationServerKey = this.urlBase64ToUint8Array(publicKey);
// Crear nueva suscripción
this.subscription = await this.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
});
console.log('Suscripción push creada:', this.subscription);
// Enviar la suscripción al servidor
await this.sendSubscriptionToServer(this.subscription);
return this.subscription;
@@ -93,7 +77,6 @@ class PushNotificationService {
}
}
// Enviar suscripción al servidor
async sendSubscriptionToServer(subscription) {
try {
const response = await fetch('/api/push/subscribe', {
@@ -108,7 +91,6 @@ class PushNotificationService {
throw new Error('Error enviando suscripción al servidor');
}
console.log('Suscripción enviada al servidor');
return await response.json();
} catch (error) {
console.error('Error enviando suscripción:', error);
@@ -116,7 +98,6 @@ class PushNotificationService {
}
}
// Cancelar suscripción
async unsubscribe() {
if (this.subscription) {
try {
@@ -129,7 +110,6 @@ class PushNotificationService {
body: JSON.stringify(this.subscription),
});
this.subscription = null;
console.log('Suscripción cancelada');
return true;
} catch (error) {
console.error('Error cancelando suscripción:', error);
@@ -139,7 +119,6 @@ class PushNotificationService {
return false;
}
// Verificar estado de suscripción
async checkSubscription() {
if (!this.registration) {
await this.registerServiceWorker();
@@ -158,7 +137,6 @@ class PushNotificationService {
}
}
// Convertir clave pública de base64 URL a Uint8Array
urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
@@ -174,7 +152,6 @@ class PushNotificationService {
return outputArray;
}
// Inicializar todo el proceso
async init() {
const permission = await this.requestPermission();
if (permission === 'granted') {
@@ -191,5 +168,4 @@ class PushNotificationService {
}
}
// Exportar instancia singleton
export default new PushNotificationService();

View File

@@ -0,0 +1,9 @@
import statsRepository from '../../domain/repositories/StatsRepository.js';
class StatsService {
async getStats() {
return await statsRepository.getStats();
}
}
export default new StatsService();

View File

@@ -0,0 +1,17 @@
import telegramRepository from '../../domain/repositories/TelegramRepository.js';
class TelegramService {
async getConfig() {
return await telegramRepository.getConfig();
}
async setConfig(config) {
return await telegramRepository.setConfig(config);
}
async getThreads() {
return await telegramRepository.getThreads();
}
}
export default new TelegramService();

View File

@@ -0,0 +1,21 @@
import userRepository from '../../domain/repositories/UserRepository.js';
class UserService {
async getUsers() {
return await userRepository.getUsers();
}
async createUser(userData) {
return await userRepository.createUser(userData);
}
async deleteUser(username) {
await userRepository.deleteUser(username);
}
async changePassword(passwordData) {
return await userRepository.changePassword(passwordData);
}
}
export default new UserService();

View File

@@ -0,0 +1,13 @@
import workerRepository from '../../domain/repositories/WorkerRepository.js';
class WorkerService {
async getWorkers() {
return await workerRepository.getWorkers();
}
async updateWorkers(workers) {
return await workerRepository.updateWorkers(workers);
}
}
export default new WorkerService();

View File

@@ -0,0 +1,3 @@
// Core configuration
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
export const WS_BASE_URL = API_BASE_URL.replace(/^https?:/, window.location.protocol === 'https:' ? 'wss:' : 'ws:').replace(/^\/api$/, `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`);

View File

@@ -0,0 +1,75 @@
import axios from 'axios';
import { API_BASE_URL } from '../config/index.js';
class ApiClient {
constructor(baseURL = API_BASE_URL) {
this.client = axios.create({
baseURL,
headers: {
'Content-Type': 'application/json',
},
});
this.setupInterceptors();
}
setupInterceptors() {
// Request interceptor - add auth header
this.client.interceptors.request.use(
(config) => {
const authHeader = this.getAuthHeader();
if (authHeader) {
config.headers.Authorization = authHeader;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor - handle 401 errors
this.client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
window.dispatchEvent(new CustomEvent('auth-required', {
detail: {
message: 'Se requiere autenticación para esta acción',
config: error.config,
},
}));
}
return Promise.reject(error);
}
);
}
setAuthToken(token) {
this.token = token;
}
clearAuthToken() {
this.token = null;
}
getAuthHeader() {
return this.token ? `Bearer ${this.token}` : null;
}
get(url, config = {}) {
return this.client.get(url, config);
}
post(url, data, config = {}) {
return this.client.post(url, data, config);
}
put(url, data, config = {}) {
return this.client.put(url, data, config);
}
delete(url, config = {}) {
return this.client.delete(url, config);
}
}
export default new ApiClient();

View File

@@ -0,0 +1,46 @@
// LocalStorage adapter for storage abstraction
export class LocalStorageAdapter {
constructor() {
this.storage = localStorage;
}
get(key) {
try {
const item = this.storage.getItem(key);
return item ? JSON.parse(item) : null;
} catch (error) {
console.error(`Error reading from localStorage key "${key}":`, error);
return null;
}
}
set(key, value) {
try {
this.storage.setItem(key, JSON.stringify(value));
return true;
} catch (error) {
console.error(`Error writing to localStorage key "${key}":`, error);
return false;
}
}
remove(key) {
try {
this.storage.removeItem(key);
return true;
} catch (error) {
console.error(`Error removing from localStorage key "${key}":`, error);
return false;
}
}
clear() {
try {
this.storage.clear();
return true;
} catch (error) {
console.error('Error clearing localStorage:', error);
return false;
}
}
}

View File

@@ -0,0 +1,25 @@
import { LocalStorageAdapter } from './LocalStorageAdapter.js';
class StorageService {
constructor(adapter) {
this.adapter = adapter || new LocalStorageAdapter();
}
get(key) {
return this.adapter.get(key);
}
set(key, value) {
return this.adapter.set(key, value);
}
remove(key) {
return this.adapter.remove(key);
}
clear() {
return this.adapter.clear();
}
}
export default new StorageService();

View File

@@ -0,0 +1,146 @@
import { WS_BASE_URL } from '../config/index.js';
class WebSocketService {
constructor() {
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 3000;
this.listeners = new Set();
}
connect(token) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
return;
}
this.setToken(token);
this.close();
try {
const wsUrl = this.buildWebSocketUrl(token);
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.onOpen();
};
this.ws.onclose = (event) => {
this.onClose(event);
};
this.ws.onerror = (error) => {
this.onError(error);
};
this.ws.onmessage = (event) => {
this.onMessage(event);
};
} catch (error) {
console.error('Error creating WebSocket connection:', error);
}
}
buildWebSocketUrl(token) {
if (!token) {
throw new Error('Token is required for WebSocket connection');
}
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
if (apiBaseUrl && apiBaseUrl !== '/api') {
try {
const url = new URL(apiBaseUrl);
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${url.host}/ws?token=${encodeURIComponent(token)}`;
} catch (e) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
}
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
}
onOpen() {
console.log('WebSocket connected');
window.dispatchEvent(new CustomEvent('ws-connected'));
}
onClose(event) {
console.log('WebSocket disconnected', event.code);
window.dispatchEvent(new CustomEvent('ws-disconnected', { detail: { code: event.code } }));
if (event.code === 1008) {
// Authentication failed, don't reconnect
return;
}
// Attempt to reconnect
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
setTimeout(() => {
if (this.token) {
this.connect(this.token);
}
}, this.reconnectDelay);
}
}
onError(error) {
console.error('WebSocket error:', error);
window.dispatchEvent(new CustomEvent('ws-error', { detail: error }));
}
onMessage(event) {
try {
const data = JSON.parse(event.data);
window.dispatchEvent(new CustomEvent('ws-message', { detail: data }));
// Notify all listeners
this.listeners.forEach(listener => {
try {
listener(data);
} catch (error) {
console.error('Error in WebSocket listener:', error);
}
});
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
}
getToken() {
// Token will be passed when connecting
return this.token || null;
}
setToken(token) {
this.token = token;
}
close() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.token = null;
}
isConnected() {
return this.ws && this.ws.readyState === WebSocket.OPEN;
}
addListener(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
removeListener(callback) {
this.listeners.delete(callback);
}
}
export default new WebSocketService();

View File

@@ -0,0 +1,59 @@
// Article entity
export class Article {
constructor(data) {
this.id = data.id;
this.platform = data.platform;
this.title = data.title || 'Sin título';
this.description = data.description || '';
this.price = data.price;
this.currency = data.currency || '€';
this.url = data.url;
this.images = data.images || [];
this.location = data.location;
this.allows_shipping = data.allows_shipping;
this.modified_at = data.modified_at;
this.notifiedAt = data.notifiedAt;
this.addedAt = data.addedAt;
this.username = data.username;
this.worker_name = data.worker_name;
this.is_favorite = data.is_favorite || false;
}
get displayPrice() {
if (this.price === null || this.price === undefined) return 'N/A';
return `${this.price} ${this.currency}`;
}
get hasImages() {
return this.images && this.images.length > 0;
}
get primaryImage() {
return this.hasImages ? this.images[0] : null;
}
get uniqueId() {
return `${this.platform}-${this.id}`;
}
toJSON() {
return {
id: this.id,
platform: this.platform,
title: this.title,
description: this.description,
price: this.price,
currency: this.currency,
url: this.url,
images: this.images,
location: this.location,
allows_shipping: this.allows_shipping,
modified_at: this.modified_at,
notifiedAt: this.notifiedAt,
addedAt: this.addedAt,
username: this.username,
worker_name: this.worker_name,
is_favorite: this.is_favorite,
};
}
}

View File

@@ -0,0 +1,19 @@
// Favorite entity
export class Favorite {
constructor({ platform, id, article = null }) {
this.platform = platform;
this.id = String(id);
this.article = article;
}
get uniqueId() {
return `${this.platform}-${this.id}`;
}
toJSON() {
return {
platform: this.platform,
id: this.id,
};
}
}

View File

@@ -0,0 +1,24 @@
// User entity
export class User {
constructor({ username, role = 'user', token = null }) {
this.username = username;
this.role = role;
this.token = token;
}
get isAdmin() {
return this.role === 'admin';
}
get isAuthenticated() {
return !!this.token;
}
toJSON() {
return {
username: this.username,
role: this.role,
token: this.token,
};
}
}

View File

@@ -0,0 +1,19 @@
import apiClient from '../../core/http/ApiClient.js';
class AdminRepository {
async getRateLimiterInfo() {
const response = await apiClient.get('/admin/rate-limiter');
return response.data;
}
async getSessions() {
const response = await apiClient.get('/admin/sessions');
return response.data;
}
async deleteSession(token) {
await apiClient.delete(`/admin/sessions/${token}`);
}
}
export default new AdminRepository();

View File

@@ -0,0 +1,35 @@
import apiClient from '../../core/http/ApiClient.js';
import { Article } from '../entities/Article.js';
class ArticleRepository {
async getArticles(limit = 100, offset = 0, filters = {}) {
const params = { limit, offset, ...filters };
const response = await apiClient.get('/articles', { params });
return {
articles: response.data.articles.map(a => new Article(a)),
total: response.data.total,
};
}
async getArticleFacets() {
const response = await apiClient.get('/articles/facets');
return response.data;
}
async searchArticles(query, mode = 'AND') {
const response = await apiClient.get('/articles/search', {
params: { q: query, mode },
});
return {
articles: response.data.articles.map(a => new Article(a)),
total: response.data.total,
};
}
async getArticle(platform, id) {
const response = await apiClient.get(`/articles/${platform}/${id}`);
return new Article(response.data);
}
}
export default new ArticleRepository();

View File

@@ -0,0 +1,76 @@
import apiClient from '../../core/http/ApiClient.js';
import { getDeviceFingerprint } from '../../shared/utils/fingerprint.js';
class AuthRepository {
async login(username, password) {
let fingerprintData = null;
try {
fingerprintData = await getDeviceFingerprint();
} catch (error) {
console.warn('Error obteniendo fingerprint, continuando sin él:', error);
}
const requestBody = {
username,
password,
};
if (fingerprintData) {
requestBody.fingerprint = fingerprintData.fingerprint;
requestBody.deviceInfo = fingerprintData.deviceInfo;
}
const response = await fetch('/api/users/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Error en login');
}
return data;
}
async logout(token) {
try {
await fetch('/api/users/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
} catch (error) {
console.error('Error al cerrar sesión en el servidor:', error);
}
}
async validateSession(token) {
try {
const response = await fetch('/api/users/me', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
return data.success && data.authenticated ? data : null;
}
return null;
} catch (error) {
console.error('Error validando sesión:', error);
return null;
}
}
}
export default new AuthRepository();

View File

@@ -0,0 +1,20 @@
import apiClient from '../../core/http/ApiClient.js';
import { Favorite } from '../entities/Favorite.js';
class FavoriteRepository {
async getFavorites() {
const response = await apiClient.get('/favorites');
return response.data.map(f => new Favorite(f));
}
async addFavorite(favorite) {
const response = await apiClient.post('/favorites', favorite.toJSON());
return new Favorite(response.data);
}
async removeFavorite(platform, id) {
await apiClient.delete(`/favorites/${platform}/${id}`);
}
}
export default new FavoriteRepository();

View File

@@ -0,0 +1,14 @@
import apiClient from '../../core/http/ApiClient.js';
class LogRepository {
async getLogs(limit = 500, sinceLine = null) {
const params = { limit };
if (sinceLine !== null && sinceLine > 0) {
params.since = sinceLine;
}
const response = await apiClient.get('/logs', { params });
return response.data;
}
}
export default new LogRepository();

View File

@@ -0,0 +1,10 @@
import apiClient from '../../core/http/ApiClient.js';
class StatsRepository {
async getStats() {
const response = await apiClient.get('/stats');
return response.data;
}
}
export default new StatsRepository();

View File

@@ -0,0 +1,20 @@
import apiClient from '../../core/http/ApiClient.js';
class TelegramRepository {
async getConfig() {
const response = await apiClient.get('/telegram/config');
return response.data;
}
async setConfig(config) {
const response = await apiClient.put('/telegram/config', config);
return response.data;
}
async getThreads() {
const response = await apiClient.get('/telegram/threads');
return response.data;
}
}
export default new TelegramRepository();

View File

@@ -0,0 +1,24 @@
import apiClient from '../../core/http/ApiClient.js';
class UserRepository {
async getUsers() {
const response = await apiClient.get('/users');
return response.data;
}
async createUser(userData) {
const response = await apiClient.post('/users', userData);
return response.data;
}
async deleteUser(username) {
await apiClient.delete(`/users/${username}`);
}
async changePassword(passwordData) {
const response = await apiClient.post('/users/change-password', passwordData);
return response.data;
}
}
export default new UserRepository();

View File

@@ -0,0 +1,15 @@
import apiClient from '../../core/http/ApiClient.js';
class WorkerRepository {
async getWorkers() {
const response = await apiClient.get('/workers');
return response.data;
}
async updateWorkers(workers) {
const response = await apiClient.put('/workers', workers);
return response.data;
}
}
export default new WorkerRepository();

32
web/frontend/src/main.js Normal file
View File

@@ -0,0 +1,32 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './presentation/router/index.js';
import authService from './application/services/AuthService.js';
import webSocketService from './core/websocket/WebSocketService.js';
import './style.css';
const app = createApp(App);
app.use(router);
app.mount('#app');
// Initialize WebSocket connection if authenticated
if (authService.hasCredentials()) {
const token = authService.getToken();
if (token) {
webSocketService.connect(token);
}
}
// Register Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('Service Worker registrado:', registration.scope);
} catch (error) {
console.error('Error registrando Service Worker:', error);
}
});
}

View File

@@ -145,8 +145,9 @@ 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';
import authService from '../../application/services/AuthService.js';
import favoriteService from '../../application/services/FavoriteService.js';
import { formatDate } from '../../shared/utils/date.js';
const router = useRouter();
@@ -181,10 +182,6 @@ function checkAuth() {
isAuthenticated.value = authService.hasCredentials();
}
function formatDate(timestamp) {
if (!timestamp) return 'N/A';
return new Date(timestamp).toLocaleString('es-ES');
}
function handleImageError(event) {
// Si la imagen falla al cargar, reemplazar con placeholder
@@ -205,20 +202,11 @@ async function handleAddFavorite() {
isAdding.value = true;
try {
// El backend solo necesita platform e id
const favorite = {
platform: props.article.platform,
id: String(props.article.id), // Asegurar que sea string
};
await api.addFavorite(favorite);
await favoriteService.addFavorite(props.article.platform, props.article.id);
favoriteStatus.value = true;
// Emitir evento para que el componente padre pueda actualizar si es necesario
emit('added', props.article.platform, props.article.id);
} catch (error) {
console.error('Error añadiendo a favoritos:', error);
// El interceptor de API ya maneja el error 401 mostrando el modal de login
if (error.response?.status === 404) {
alert('El artículo no se encontró en la base de datos. Asegúrate de que el artículo esté en la lista de notificados.');
} else if (error.response?.status === 400) {

View File

@@ -29,6 +29,7 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { XMarkIcon } from '@heroicons/vue/24/outline';
import { useWebSocket } from '../composables/useWebSocket.js';
import ToastNotification from './ToastNotification.vue';
const toasts = ref([]);
@@ -78,10 +79,10 @@ function clearAllToasts() {
toasts.value = [];
}
const { handleMessage } = useWebSocket();
// Escuchar eventos de WebSocket para nuevos artículos
function handleWebSocketMessage(event) {
const data = event.detail;
function handleWebSocketMessage(data) {
// Manejar notificaciones de artículos nuevos
if (data.type === 'new_articles' && data.data) {
// Mostrar toasts para cada artículo nuevo
@@ -92,12 +93,17 @@ function handleWebSocketMessage(event) {
}
onMounted(() => {
window.addEventListener('ws-message', handleWebSocketMessage);
const unsubscribe = handleMessage(handleWebSocketMessage);
return () => {
unsubscribe();
// Limpiar todos los timeouts de toasts
toastTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
toastTimeouts.clear();
};
});
onUnmounted(() => {
window.removeEventListener('ws-message', handleWebSocketMessage);
// Limpiar todos los timeouts de toasts
toastTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
toastTimeouts.clear();

View File

@@ -0,0 +1,28 @@
import { ref, computed } from 'vue';
import authService from '../../application/services/AuthService.js';
export function useAuth() {
const currentUser = ref(authService.getCurrentUser());
const isAuthenticated = computed(() => authService.hasCredentials());
const isAdmin = computed(() => authService.isAdmin());
const username = computed(() => authService.getUsername());
function updateUser() {
currentUser.value = authService.getCurrentUser();
}
// Listen to auth events
if (typeof window !== 'undefined') {
window.addEventListener('auth-login', updateUser);
window.addEventListener('auth-logout', updateUser);
}
return {
currentUser,
isAuthenticated,
isAdmin,
username,
updateUser,
};
}

View File

@@ -0,0 +1,37 @@
import { ref, onMounted } from 'vue';
export function useDarkMode() {
const isDark = ref(false);
function toggle() {
isDark.value = !isDark.value;
if (isDark.value) {
document.documentElement.classList.add('dark');
localStorage.setItem('darkMode', 'true');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('darkMode', 'false');
}
}
function init() {
const saved = localStorage.getItem('darkMode');
if (saved === 'true' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
isDark.value = true;
document.documentElement.classList.add('dark');
} else {
isDark.value = false;
document.documentElement.classList.remove('dark');
}
}
onMounted(() => {
init();
});
return {
isDark,
toggle,
init,
};
}

View File

@@ -0,0 +1,43 @@
import { ref, onMounted, onUnmounted } from 'vue';
export function useWebSocket() {
const isConnected = ref(false);
function handleConnected() {
isConnected.value = true;
}
function handleDisconnected() {
isConnected.value = false;
}
function handleMessage(callback) {
const handler = (event) => {
callback(event.detail);
};
window.addEventListener('ws-message', handler);
return () => {
window.removeEventListener('ws-message', handler);
};
}
onMounted(() => {
window.addEventListener('ws-connected', handleConnected);
window.addEventListener('ws-disconnected', handleDisconnected);
// Check initial state
isConnected.value = false; // Will be updated by events
});
onUnmounted(() => {
window.removeEventListener('ws-connected', handleConnected);
window.removeEventListener('ws-disconnected', handleDisconnected);
});
return {
isConnected,
handleMessage,
};
}

View File

@@ -0,0 +1,106 @@
import { createRouter, createWebHistory } from 'vue-router';
import authService from '../../application/services/AuthService.js';
import webSocketService from '../../core/websocket/WebSocketService.js';
const routes = [
{
path: '/login',
name: 'login',
component: () => import('../views/Login.vue'),
},
{
path: '/',
name: 'dashboard',
component: () => import('../views/Dashboard.vue'),
meta: { requiresAuth: true },
},
{
path: '/articles',
name: 'articles',
component: () => import('../views/Articles.vue'),
meta: { requiresAuth: true },
},
{
path: '/articles/:platform/:id',
name: 'article-detail',
component: () => import('../views/ArticleDetail.vue'),
meta: { requiresAuth: true },
},
{
path: '/favorites',
name: 'favorites',
component: () => import('../views/Favorites.vue'),
meta: { requiresAuth: true },
},
{
path: '/workers',
name: 'workers',
component: () => import('../views/Workers.vue'),
meta: { requiresAuth: true },
},
{
path: '/users',
name: 'users',
component: () => import('../views/Users.vue'),
meta: { requiresAuth: true },
},
{
path: '/logs',
name: 'logs',
component: () => import('../views/Logs.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/rate-limiter',
name: 'rate-limiter',
component: () => import('../views/RateLimiter.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/sessions',
name: 'sessions',
component: () => import('../views/Sessions.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach(async (to, from, next) => {
if (to.path === '/login') {
if (authService.hasCredentials()) {
const isValid = await authService.validateSession();
if (isValid) {
next('/');
return;
}
}
next();
return;
}
if (to.meta.requiresAuth) {
if (!authService.hasCredentials()) {
next('/login');
return;
}
const isValid = await authService.validateSession();
if (!isValid) {
next('/login');
return;
}
if (to.meta.requiresAdmin && !authService.isAdmin()) {
next('/');
return;
}
}
next();
});
export default router;

View File

@@ -260,8 +260,9 @@
<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 articleService from '../../application/services/ArticleService.js';
import favoriteService from '../../application/services/FavoriteService.js';
import { useAuth } from '../composables/useAuth.js';
import {
ArrowLeftIcon,
ArrowTopRightOnSquareIcon,
@@ -351,8 +352,10 @@ function handleKeydown(event) {
}
}
const { isAuthenticated } = useAuth();
function checkAuth() {
isAuthenticated.value = authService.hasCredentials();
// Auth state is managed by useAuth composable
}
async function handleAddFavorite() {
@@ -368,7 +371,7 @@ async function handleAddFavorite() {
id: String(article.value.id),
};
await api.addFavorite(favorite);
await favoriteService.addFavorite(article.value.platform, article.value.id);
favoriteStatus.value = true;
} catch (error) {
console.error('Error añadiendo a favoritos:', error);
@@ -409,7 +412,7 @@ async function loadArticle() {
return;
}
article.value = await api.getArticle(platform, id);
article.value = await articleService.getArticle(platform, id);
if (!article.value) {
error.value = 'Artículo no encontrado';

View File

@@ -211,7 +211,7 @@
<div v-else class="space-y-4">
<ArticleCard
v-for="article in filteredArticles"
:key="`${article.platform}-${article.id}`"
:key="article.uniqueId || `${article.platform}-${article.id}`"
:article="article"
:is-new="newArticleIds.has(`${article.platform}-${article.id}`)"
/>
@@ -243,8 +243,9 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import api from '../services/api';
import authService from '../services/auth';
import articleService from '../../application/services/ArticleService.js';
import { useAuth } from '../composables/useAuth.js';
import { useWebSocket } from '../composables/useWebSocket.js';
import ArticleCard from '../components/ArticleCard.vue';
import {
FunnelIcon,
@@ -256,8 +257,8 @@ import {
MagnifyingGlassIcon
} from '@heroicons/vue/24/outline';
const currentUser = ref(authService.getUsername() || null);
const isAdmin = ref(false);
const { username: currentUser, isAdmin } = useAuth();
const { handleMessage } = useWebSocket();
const allArticles = ref([]);
const searchResults = ref([]);
@@ -326,7 +327,7 @@ function clearAllFilters() {
async function loadFacets() {
try {
const data = await api.getArticleFacets();
const data = await articleService.getArticleFacets();
facets.value = {
platforms: data.platforms || [],
usernames: data.usernames || [],
@@ -374,7 +375,7 @@ async function loadArticles(reset = true, silent = false) {
}
if (selectedWorker.value) params.worker_name = selectedWorker.value;
const data = await api.getArticles(limit, offset.value, params);
const data = await articleService.getArticles(limit, offset.value, params);
let filtered = data.articles;
// El filtro de plataforma se aplica en el backend ahora, pero mantenemos compatibilidad
@@ -426,14 +427,12 @@ async function loadArticles(reset = true, silent = false) {
}
function handleAuthChange() {
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) {
loadFacets(); // Recargar facets cuando cambie el usuario
loadFacets();
loadArticles();
}
}
@@ -442,10 +441,9 @@ function loadMore() {
loadArticles(false);
}
function handleWSMessage(event) {
const data = event.detail;
function handleWSMessage(data) {
if (data.type === 'articles_updated') {
loadFacets(); // Actualizar facets cuando se actualicen los artículos
loadFacets();
loadArticles();
}
}
@@ -460,7 +458,7 @@ async function searchArticles(query) {
searching.value = true;
try {
const data = await api.searchArticles(query, searchMode.value);
const data = await articleService.searchArticles(query, searchMode.value);
let filtered = data.articles || [];
@@ -505,37 +503,39 @@ watch([searchQuery, searchMode], ([newQuery]) => {
onMounted(() => {
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 = '';
}
loadFacets(); // Cargar facets primero
loadFacets();
loadArticles();
window.addEventListener('ws-message', handleWSMessage);
window.addEventListener('auth-logout', handleAuthChange);
window.addEventListener('auth-login', handleAuthChange);
const unsubscribe = handleMessage(handleWSMessage);
// Iniciar autopoll para actualizar automáticamente
autoPollInterval.value = setInterval(() => {
loadArticles(true, true); // Reset silencioso cada 30 segundos
loadFacets(); // Actualizar facets también
loadArticles(true, true);
loadFacets();
}, POLL_INTERVAL);
return () => {
unsubscribe();
if (autoPollInterval.value) {
clearInterval(autoPollInterval.value);
autoPollInterval.value = null;
}
if (searchTimeout.value) {
clearTimeout(searchTimeout.value);
searchTimeout.value = null;
}
};
});
onUnmounted(() => {
window.removeEventListener('ws-message', handleWSMessage);
window.removeEventListener('auth-logout', handleAuthChange);
window.removeEventListener('auth-login', handleAuthChange);
// Limpiar el intervalo cuando el componente se desmonte
if (autoPollInterval.value) {
clearInterval(autoPollInterval.value);
autoPollInterval.value = null;
}
// Limpiar el timeout de búsqueda
if (searchTimeout.value) {
clearTimeout(searchTimeout.value);
searchTimeout.value = null;

View File

@@ -106,11 +106,6 @@
</div>
</div>
<!-- Active Users (Solo para admin) -->
<div v-if="isAdmin">
<ActiveUsers />
</div>
<!-- Charts and Quick Actions -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Platform Distribution -->
@@ -232,9 +227,9 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import api from '../services/api';
import authService from '../services/auth';
import ActiveUsers from '../components/ActiveUsers.vue';
import statsService from '../../application/services/StatsService.js';
import { useAuth } from '../composables/useAuth.js';
import { useWebSocket } from '../composables/useWebSocket.js';
import {
Cog6ToothIcon,
HeartIcon,
@@ -251,8 +246,8 @@ const stats = ref({
platforms: {},
});
const currentUser = ref(authService.getUsername() || null);
const isAdmin = ref(false);
const { username: currentUser, isAdmin } = useAuth();
const { handleMessage } = useWebSocket();
function getPercentage(value, total) {
if (!total || total === 0) return 0;
@@ -261,25 +256,13 @@ function getPercentage(value, total) {
async function loadStats() {
try {
stats.value = await api.getStats();
// Verificar si el usuario es admin (se puede inferir de si ve todas las estadísticas)
// O podemos añadir un endpoint para verificar el rol
stats.value = await statsService.getStats();
} catch (error) {
console.error('Error cargando estadísticas:', error);
}
}
function handleAuthChange() {
currentUser.value = authService.getUsername() || null;
isAdmin.value = authService.isAdmin();
if (currentUser.value) {
loadStats();
}
}
function handleWSMessage(event) {
const data = event.detail;
function handleWSMessage(data) {
if (data.type === 'workers_updated' || data.type === 'favorites_updated') {
loadStats();
}
@@ -288,22 +271,22 @@ function handleWSMessage(event) {
let interval = null;
onMounted(() => {
currentUser.value = authService.getUsername() || null;
isAdmin.value = authService.isAdmin();
loadStats();
window.addEventListener('ws-message', handleWSMessage);
window.addEventListener('auth-logout', handleAuthChange);
window.addEventListener('auth-login', handleAuthChange);
interval = setInterval(loadStats, 10000); // Actualizar cada 10 segundos
const unsubscribe = handleMessage(handleWSMessage);
interval = setInterval(loadStats, 10000);
return () => {
if (interval) {
clearInterval(interval);
}
unsubscribe();
};
});
onUnmounted(() => {
if (interval) {
clearInterval(interval);
}
window.removeEventListener('ws-message', handleWSMessage);
window.removeEventListener('auth-logout', handleAuthChange);
window.removeEventListener('auth-login', handleAuthChange);
});
</script>

View File

@@ -48,15 +48,16 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import api from '../services/api';
import authService from '../services/auth';
import favoriteService from '../../application/services/FavoriteService.js';
import { useAuth } from '../composables/useAuth.js';
import { useWebSocket } from '../composables/useWebSocket.js';
import { HeartIcon } from '@heroicons/vue/24/outline';
import ArticleCard from '../components/ArticleCard.vue';
const favorites = ref([]);
const loading = ref(true);
const currentUser = ref(authService.getUsername() || null);
const isAdmin = ref(false);
const { username: currentUser } = useAuth();
const { handleMessage } = useWebSocket();
async function loadFavorites() {
@@ -69,13 +70,11 @@ async function loadFavorites() {
loading.value = true;
try {
favorites.value = await api.getFavorites();
favorites.value = await favoriteService.getFavorites();
} catch (error) {
console.error('Error cargando favoritos:', error);
// Si hay error de autenticación, limpiar favoritos
if (error.response?.status === 401) {
favorites.value = [];
currentUser.value = null;
}
} finally {
loading.value = false;
@@ -88,7 +87,7 @@ async function removeFavorite(platform, id) {
}
try {
await api.removeFavorite(platform, id);
await favoriteService.removeFavorite(platform, id);
await loadFavorites();
} catch (error) {
console.error('Error eliminando favorito:', error);
@@ -96,38 +95,25 @@ async function removeFavorite(platform, id) {
}
}
function handleWSMessage(event) {
const data = event.detail;
function handleWSMessage(data) {
if (data.type === 'favorites_updated') {
// Solo actualizar si es para el usuario actual
if (data.username === currentUser.value) {
favorites.value = data.data;
}
}
}
function handleAuthChange() {
currentUser.value = authService.getUsername() || null;
isAdmin.value = authService.isAdmin();
if (currentUser.value) {
loadFavorites();
}
}
onMounted(() => {
currentUser.value = authService.getUsername() || null;
isAdmin.value = authService.isAdmin();
loadFavorites();
window.addEventListener('ws-message', handleWSMessage);
window.addEventListener('auth-logout', handleAuthChange);
window.addEventListener('auth-login', handleAuthChange);
const unsubscribe = handleMessage(handleWSMessage);
return () => {
unsubscribe();
};
});
onUnmounted(() => {
window.removeEventListener('ws-message', handleWSMessage);
window.removeEventListener('auth-logout', handleAuthChange);
window.removeEventListener('auth-login', handleAuthChange);
// Cleanup handled in onMounted return
});
</script>

View File

@@ -217,12 +217,6 @@
<!-- Footer -->
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-center text-gray-600 dark:text-gray-400 mb-2">
¿No tienes cuenta?
<router-link to="/register" class="text-primary-600 dark:text-primary-400 hover:underline font-semibold">
Regístrate gratis
</router-link>
</p>
<p class="text-xs text-center text-gray-500 dark:text-gray-400">
¿Necesitas ayuda? Contacta con el administrador del sistema
</p>
@@ -236,7 +230,7 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import authService from '../services/auth';
import authService from '../../application/services/AuthService.js';
const router = useRouter();
const loginError = ref('');
@@ -271,14 +265,7 @@ async function handleLogin() {
window.dispatchEvent(new CustomEvent('auth-login'));
} catch (error) {
console.error('Error en login:', error);
// Verificar si el error es de pago pendiente
if (error.response?.data?.status === 'pending_payment') {
loginError.value = 'Tu cuenta está pendiente de pago. Por favor, completa el proceso de pago para activar tu cuenta. Si ya pagaste, espera unos minutos y vuelve a intentar.';
} else {
loginError.value = error.response?.data?.message || error.message || 'Usuario o contraseña incorrectos';
}
loginError.value = error.message || 'Usuario o contraseña incorrectos';
authService.clearSession();
} finally {
loginLoading.value = false;

View File

@@ -94,8 +94,8 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
import api from '../services/api';
import authService from '../services/auth';
import logService from '../../application/services/LogService.js';
import { useAuth } from '../composables/useAuth.js';
import { DocumentMagnifyingGlassIcon } from '@heroicons/vue/24/outline';
const logs = ref([]);
@@ -106,8 +106,7 @@ const refreshIntervalSeconds = ref(5);
const followLatestLog = ref(true);
const logsContainer = ref(null);
const lastLineNumber = ref(-1); // Número de la última línea leída
const currentUser = ref(authService.getUsername() || null);
const isAdmin = ref(false);
const { username: currentUser, isAdmin } = useAuth();
const accessDenied = ref(false);
let refreshInterval = null;
@@ -156,7 +155,7 @@ async function loadLogs(forceReload = false, shouldScroll = null) {
// Si es carga inicial o forzada, no enviar sinceLine (cargar últimas líneas)
// Si es actualización incremental, enviar lastLineNumber + 1 para obtener solo las nuevas
const sinceLine = isInitialLoad ? null : lastLineNumber.value + 1;
const data = await api.getLogs(500, sinceLine);
const data = await logService.getLogs(500, sinceLine);
const newLogs = data.logs || [];
const newLastLineNumber = data.lastLineNumber !== undefined ? data.lastLineNumber : -1;

View File

@@ -149,8 +149,8 @@
<script setup>
import { ref, onMounted } from 'vue';
import api from '../services/api';
import authService from '../services/auth';
import adminService from '../../application/services/AdminService.js';
import { useAuth } from '../composables/useAuth.js';
import { ShieldExclamationIcon } from '@heroicons/vue/24/outline';
const rateLimiterInfo = ref(null);
@@ -188,7 +188,7 @@ async function loadRateLimiterInfo(showLoading = false) {
}
try {
const data = await api.getRateLimiterInfo();
const data = await adminService.getRateLimiterInfo();
rateLimiterInfo.value = data;
} catch (error) {
console.error('Error cargando información del rate limiter:', error);

Some files were not shown because too many files have changed in this diff Show More