feat: implement user authentication and login modal, refactor backend
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
95
web/frontend/src/services/auth.js
Normal file
95
web/frontend/src/services/auth.js
Normal 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;
|
||||
|
||||
554
web/frontend/src/views/Users.vue
Normal file
554
web/frontend/src/views/Users.vue
Normal 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"
|
||||
>
|
||||
Tú
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user