Enhance web start script to support landing page and add user management view

This commit is contained in:
Omar Sánchez Pizarro
2026-01-20 23:57:47 +01:00
parent 6ec8855c00
commit 447ff6a4d6
2 changed files with 730 additions and 4 deletions

View File

@@ -0,0 +1,706 @@
<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"
>
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{{ user.username }}</h3>
<span
v-if="user.username === currentUser"
class="px-2 py-1 text-xs font-semibold rounded 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-1 text-xs font-semibold rounded bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200"
>
Admin
</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600 dark:text-gray-400 mb-3">
<div v-if="user.createdAt">
<span class="font-medium">Creado:</span>
<span class="ml-2">{{ formatDate(user.createdAt) }}</span>
</div>
<div v-if="user.createdBy">
<span class="font-medium">Por:</span>
<span class="ml-2">{{ user.createdBy }}</span>
</div>
<div v-if="user.updatedAt">
<span class="font-medium">Actualizado:</span>
<span class="ml-2">{{ formatDate(user.updatedAt) }}</span>
</div>
</div>
<!-- Información de suscripción -->
<div v-if="userSubscriptions[user.username]" class="mt-3 p-3 bg-gradient-to-r 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-800">
<div class="flex items-center justify-between">
<div>
<span class="text-xs font-semibold text-gray-700 dark:text-gray-300">Plan:</span>
<span class="ml-2 text-sm font-bold text-primary-700 dark:text-primary-400">
{{ userSubscriptions[user.username].plan?.name || 'Gratis' }}
</span>
</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="mt-2 text-xs text-gray-600 dark:text-gray-400">
<span>Uso: {{ userSubscriptions[user.username].usage.workers }} / {{ userSubscriptions[user.username].usage.maxWorkers === 'Ilimitado' ? '∞' : userSubscriptions[user.username].usage.maxWorkers }} búsquedas</span>
</div>
</div>
</div>
<div class="flex gap-2 flex-wrap">
<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>
</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 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;
}
}
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 loadAllUserSubscriptions();
}
} 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 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();
userSubscriptions.value[user.username] = data;
}
} catch (error) {
console.error(`Error cargando suscripción de ${user.username}:`, error);
}
});
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 = '';
}
onMounted(() => {
loadUsers();
window.addEventListener('auth-logout', handleAuthLogout);
// Escuchar evento de login exitoso para recargar usuarios
window.addEventListener('auth-login', () => {
loadUsers();
});
});
onUnmounted(() => {
window.removeEventListener('auth-logout', handleAuthLogout);
window.removeEventListener('auth-login', loadUsers);
});
</script>

View File

@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# Script para iniciar el servidor web de Wallabicher # Script para iniciar el servidor web de Wallabicher (con landing)
echo "🚀 Iniciando Wallabicher Web Interface..." echo "🚀 Iniciando Wallabicher Web Interface..."
echo "" echo ""
@@ -33,6 +33,14 @@ if [ ! -d "dashboard/node_modules" ]; then
cd .. cd ..
fi fi
# Instalar dependencias del landing si no existen
if [ -d "landing" ] && [ ! -d "landing/node_modules" ]; then
echo "📦 Instalando dependencias del landing..."
cd landing
npm install
cd ..
fi
# Iniciar backend en background # Iniciar backend en background
echo "🔧 Iniciando backend..." echo "🔧 Iniciando backend..."
cd backend cd backend
@@ -43,21 +51,33 @@ cd ..
# Esperar un poco para que el backend se inicie # Esperar un poco para que el backend se inicie
sleep 2 sleep 2
# Iniciar dashboard # Iniciar dashboard en background
echo "🎨 Iniciando dashboard..." echo "🎨 Iniciando dashboard..."
cd dashboard cd dashboard
npm run dev & npm run dev &
DASHBOARD_PID=$! DASHBOARD_PID=$!
cd .. cd ..
# Iniciar landing si existe
if [ -d "landing" ]; then
echo "🌐 Iniciando landing..."
cd landing
npm run dev &
LANDING_PID=$!
cd ..
fi
echo "" echo ""
echo "✅ Servidores iniciados!" echo "✅ Servidores iniciados!"
echo "📡 Backend: http://localhost:3001" echo "📡 Backend: http://localhost:3001"
echo "🎨 Dashboard: http://localhost:3000" echo "🎨 Dashboard: http://localhost:3000"
if [ -d "landing" ]; then
echo "🌐 Landing: http://localhost:3002"
fi
echo "" echo ""
echo "Presiona Ctrl+C para detener los servidores" echo "Presiona Ctrl+C para detener los servidores"
# Esperar a que se presione Ctrl+C # Esperar a que se presione Ctrl+C y matar todos los procesos
trap "kill $BACKEND_PID $DASHBOARD_PID 2>/dev/null; exit" INT TERM trap "kill $BACKEND_PID $DASHBOARD_PID ${LANDING_PID:-} 2>/dev/null; exit" INT TERM
wait wait