@@ -1,706 +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"
>
< 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"
>
Tú
< / 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 >