916 lines
33 KiB
Vue
916 lines
33 KiB
Vue
<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"
|
||
>
|
||
Tú
|
||
</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>
|
||
|