feat: implement user authentication and login modal, refactor backend

This commit is contained in:
Omar Sánchez Pizarro
2026-01-20 00:39:28 +01:00
parent 9a61f16959
commit e99424c9ba
29 changed files with 3061 additions and 855 deletions

View File

@@ -45,6 +45,19 @@
<SunIcon v-if="isDark" class="w-5 h-5" />
<MoonIcon v-else class="w-5 h-5" />
</button>
<button
@click="isAuthenticated ? handleLogout() : showLoginModal = true"
:class="[
'p-2 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500',
isAuthenticated
? 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
: 'text-primary-600 dark:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20'
]"
:title="isAuthenticated ? 'Desconectar / Cerrar sesión' : 'Iniciar sesión'"
>
<ArrowRightOnRectangleIcon v-if="isAuthenticated" class="w-5 h-5" />
<ArrowLeftOnRectangleIcon v-else class="w-5 h-5" />
</button>
<div class="hidden sm:flex items-center space-x-2">
<div
class="w-3 h-3 rounded-full"
@@ -82,7 +95,7 @@
{{ item.name }}
</router-link>
</div>
<div class="pt-4 pb-3 border-t border-gray-200 dark:border-gray-700 px-4">
<div class="pt-4 pb-3 border-t border-gray-200 dark:border-gray-700 px-4 space-y-3">
<div class="flex items-center space-x-2">
<div
class="w-3 h-3 rounded-full"
@@ -92,6 +105,19 @@
{{ wsConnected ? 'Conectado' : 'Desconectado' }}
</span>
</div>
<button
@click="isAuthenticated ? handleLogout() : showLoginModal = true"
:class="[
'w-full flex items-center px-3 py-2 text-base font-medium rounded-md transition-colors',
isAuthenticated
? 'text-gray-900 dark:text-gray-200 hover:text-red-600 dark:hover:text-red-400 hover:bg-gray-50 dark:hover:bg-gray-700'
: 'text-gray-900 dark:text-gray-200 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-700'
]"
>
<ArrowRightOnRectangleIcon v-if="isAuthenticated" class="w-5 h-5 mr-3" />
<ArrowLeftOnRectangleIcon v-else class="w-5 h-5 mr-3" />
{{ isAuthenticated ? 'Desconectar' : 'Iniciar Sesión' }}
</button>
</div>
</div>
</nav>
@@ -159,6 +185,99 @@
</div>
</div>
</div>
<!-- Modal de Login Global -->
<div
v-if="showLoginModal"
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
@click.self="closeLoginModal"
>
<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">🔐 Iniciar Sesión</h2>
<button
@click="closeLoginModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Cerrar"
>
</button>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Ingresa tus credenciales para acceder a las funciones de administrador.
</p>
<form @submit.prevent="handleGlobalLogin" class="space-y-4">
<div v-if="globalLoginError" 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">
{{ globalLoginError }}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Usuario
</label>
<input
v-model="globalLoginForm.username"
type="text"
class="input"
placeholder="admin"
required
autocomplete="username"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Contraseña
</label>
<input
v-model="globalLoginForm.password"
type="password"
class="input"
placeholder="••••••••"
required
autocomplete="current-password"
/>
</div>
<div class="flex items-center">
<input
v-model="globalLoginForm.remember"
type="checkbox"
id="remember-global"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label for="remember-global" class="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Recordar credenciales
</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="closeLoginModal"
class="btn btn-secondary text-sm sm:text-base"
>
Cancelar
</button>
<button
type="submit"
class="btn btn-primary text-sm sm:text-base"
:disabled="globalLoginLoading"
>
{{ globalLoginLoading ? 'Iniciando...' : 'Iniciar Sesión' }}
</button>
</div>
</form>
<div 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">
💡 Por defecto: usuario <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">admin</code> / contraseña <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">admin</code>
</p>
</div>
</div>
</div>
</div>
</template>
@@ -169,6 +288,7 @@ import {
DocumentTextIcon,
HeartIcon,
Cog6ToothIcon,
UserGroupIcon,
DocumentMagnifyingGlassIcon,
Bars3Icon,
XMarkIcon,
@@ -176,26 +296,42 @@ import {
MoonIcon,
BellIcon,
BellSlashIcon,
ArrowRightOnRectangleIcon,
ArrowLeftOnRectangleIcon,
} from '@heroicons/vue/24/outline';
import pushNotificationService from './services/pushNotifications';
import authService from './services/auth';
import { useRouter } from 'vue-router';
import api from './services/api';
const navItems = [
{ path: '/', name: 'Dashboard', icon: HomeIcon },
{ path: '/articles', name: 'Artículos', icon: DocumentTextIcon },
{ path: '/favorites', name: 'Favoritos', icon: HeartIcon },
{ path: '/workers', name: 'Workers', icon: Cog6ToothIcon },
{ path: '/users', name: 'Usuarios', icon: UserGroupIcon },
{ path: '/logs', name: 'Logs', icon: DocumentMagnifyingGlassIcon },
];
const router = useRouter();
const wsConnected = ref(false);
const mobileMenuOpen = ref(false);
const darkMode = ref(false);
const toasts = ref([]);
const pushEnabled = ref(false);
const showLoginModal = ref(false);
const globalLoginError = ref('');
const globalLoginLoading = ref(false);
const globalLoginForm = ref({
username: '',
password: '',
remember: true,
});
let ws = null;
let toastIdCounter = 0;
const isDark = computed(() => darkMode.value);
const isAuthenticated = computed(() => authService.hasCredentials());
function addToast(article) {
const id = ++toastIdCounter;
@@ -281,13 +417,102 @@ async function checkPushStatus() {
pushEnabled.value = hasSubscription;
}
async function handleGlobalLogin() {
globalLoginError.value = '';
globalLoginLoading.value = true;
if (!globalLoginForm.value.username || !globalLoginForm.value.password) {
globalLoginError.value = 'Usuario y contraseña son requeridos';
globalLoginLoading.value = false;
return;
}
try {
if (globalLoginForm.value.remember) {
authService.saveCredentials(
globalLoginForm.value.username,
globalLoginForm.value.password
);
}
// Intentar hacer una petición autenticada para validar credenciales
// Usamos stats que no requiere auth, pero validará las credenciales
try {
await api.getStats();
} catch (error) {
// Si hay error 401, las credenciales son inválidas
if (error.response?.status === 401) {
throw error;
}
}
// Si llegamos aquí, las credenciales son válidas
closeLoginModal();
// Recargar página para actualizar datos después del login
window.location.reload();
} catch (error) {
console.error('Error en login:', error);
if (error.response?.status === 401) {
globalLoginError.value = 'Usuario o contraseña incorrectos';
authService.clearCredentials();
} else {
globalLoginError.value = 'Error de conexión. Intenta de nuevo.';
}
} finally {
globalLoginLoading.value = false;
}
}
function closeLoginModal() {
showLoginModal.value = false;
globalLoginError.value = '';
globalLoginForm.value = {
username: '',
password: '',
remember: true,
};
}
function handleAuthRequired(event) {
showLoginModal.value = true;
if (event.detail?.message) {
globalLoginError.value = event.detail.message;
}
}
function handleLogout() {
// Limpiar credenciales
authService.clearCredentials();
// Redirigir al dashboard después del logout
router.push('/');
// 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();
connectWebSocket();
await checkPushStatus();
// Cargar credenciales guardadas si existen
if (authService.hasCredentials()) {
const creds = authService.getCredentials();
globalLoginForm.value.username = creds.username;
globalLoginForm.value.password = creds.password;
}
// Escuchar eventos de autenticación requerida
window.addEventListener('auth-required', handleAuthRequired);
});
onUnmounted(() => {
window.removeEventListener('auth-required', handleAuthRequired);
if (ws) {
ws.close();
}

View File

@@ -5,6 +5,7 @@ import Dashboard from './views/Dashboard.vue';
import Articles from './views/Articles.vue';
import Favorites from './views/Favorites.vue';
import Workers from './views/Workers.vue';
import Users from './views/Users.vue';
import Logs from './views/Logs.vue';
import './style.css';
@@ -13,6 +14,7 @@ const routes = [
{ path: '/articles', component: Articles },
{ path: '/favorites', component: Favorites },
{ path: '/workers', component: Workers },
{ path: '/users', component: Users },
{ path: '/logs', component: Logs },
];

View File

@@ -1,4 +1,5 @@
import axios from 'axios';
import authService from './auth';
const api = axios.create({
baseURL: '/api',
@@ -7,6 +8,37 @@ const api = axios.create({
},
});
// 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() {
@@ -83,5 +115,26 @@ export default {
const response = await api.delete('/cache');
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;
},
};

View File

@@ -0,0 +1,95 @@
// Servicio de autenticación para gestionar credenciales
const AUTH_STORAGE_KEY = 'wallabicher_auth';
class AuthService {
constructor() {
this.credentials = this.loadCredentials();
}
// Cargar credenciales desde localStorage
loadCredentials() {
try {
const stored = localStorage.getItem(AUTH_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return {
username: parsed.username || '',
password: parsed.password || '',
};
}
} catch (error) {
console.error('Error cargando credenciales:', error);
}
return { username: '', password: '' };
}
// Guardar credenciales en localStorage
saveCredentials(username, password) {
try {
this.credentials = { username, password };
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(this.credentials));
return true;
} catch (error) {
console.error('Error guardando credenciales:', error);
return false;
}
}
// Eliminar credenciales
clearCredentials() {
try {
this.credentials = { username: '', password: '' };
localStorage.removeItem(AUTH_STORAGE_KEY);
return true;
} catch (error) {
console.error('Error eliminando credenciales:', error);
return false;
}
}
// Obtener credenciales actuales
getCredentials() {
return { ...this.credentials };
}
// Verificar si hay credenciales guardadas
hasCredentials() {
return !!(this.credentials.username && this.credentials.password);
}
// Generar header de autenticación Basic
getAuthHeader() {
if (!this.hasCredentials()) {
return null;
}
const { username, password } = this.credentials;
const encoded = btoa(`${username}:${password}`);
return `Basic ${encoded}`;
}
// Validar credenciales (test básico)
async validateCredentials(username, password) {
try {
// Intentar hacer una petición simple para validar las credenciales
const encoded = btoa(`${username}:${password}`);
const response = await fetch('/api/stats', {
method: 'GET',
headers: {
'Authorization': `Basic ${encoded}`,
},
});
// Si la petición funciona, las credenciales son válidas
// Nota: stats no requiere auth, pero podemos usar cualquier endpoint
return response.ok || response.status !== 401;
} catch (error) {
return false;
}
}
}
// Exportar instancia singleton
const authService = new AuthService();
export default authService;

View File

@@ -0,0 +1,554 @@
<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="isAuthenticated"
@click="showChangePasswordModal = true"
class="btn btn-secondary text-xs sm:text-sm"
>
🔑 Cambiar Mi Contraseña
</button>
<button @click="showAddModal = true" class="btn btn-primary text-xs sm:text-sm whitespace-nowrap">
+ Crear Usuario
</button>
</div>
</div>
<div v-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>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600 dark:text-gray-400">
<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>
</div>
<div class="flex gap-2">
<button
v-if="user.username === currentUser"
@click="showChangePasswordModal = true"
class="btn btn-secondary text-xs sm:text-sm"
title="Cambiar contraseña"
>
🔑 Cambiar Contraseña
</button>
<button
v-if="user.username !== currentUser"
@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 cambiar contraseña -->
<div
v-if="showChangePasswordModal"
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
@click.self="closeChangePasswordModal"
>
<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">Cambiar Contraseña</h2>
<button
@click="closeChangePasswordModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Cerrar"
>
</button>
</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 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="closeChangePasswordModal"
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 ? 'Cambiando...' : 'Cambiar Contraseña' }}
</button>
</div>
</form>
</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 } 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 showChangePasswordModal = ref(false);
const userToDelete = ref(null);
const addError = ref('');
const passwordError = ref('');
const passwordSuccess = ref('');
const userForm = ref({
username: '',
password: '',
passwordConfirm: '',
});
const passwordForm = ref({
currentPassword: '',
newPassword: '',
newPasswordConfirm: '',
});
const isAuthenticated = computed(() => authService.hasCredentials());
const currentUser = computed(() => {
const creds = authService.getCredentials();
return creds.username || '';
});
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 || [];
} 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 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 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';
// Actualizar credenciales guardadas si la nueva contraseña es para el usuario actual
const creds = authService.getCredentials();
if (creds.username === currentUser.value) {
authService.saveCredentials(currentUser.value, passwordForm.value.newPassword);
}
// Limpiar formulario después de 2 segundos
setTimeout(() => {
closeChangePasswordModal();
}, 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 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 closeChangePasswordModal() {
showChangePasswordModal.value = false;
passwordError.value = '';
passwordSuccess.value = '';
passwordForm.value = {
currentPassword: '',
newPassword: '',
newPasswordConfirm: '',
};
}
function handleAuthLogout() {
// Cuando el usuario se desconecta globalmente, limpiar datos
users.value = [];
showAddModal.value = false;
showChangePasswordModal.value = false;
userToDelete.value = null;
addError.value = '';
passwordError.value = '';
passwordSuccess.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>