feat: Sistema de autenticación por JWT
ste commit introduce un sistema completo de autenticación basado en JSON Web Tokens (JWT) para proteger los endpoints de la API y gestionar el acceso de los usuarios a la aplicación.
**Cambios en el Backend (ASP.NET Core):**
- Se ha creado un nuevo `AuthController` con un endpoint `POST /api/auth/login` para validar las credenciales del usuario.
- Implementada la generación de tokens JWT con una clave secreta y emisor/audiencia configurables desde `appsettings.json`.
- Se ha añadido una lógica de expiración dinámica para los tokens:
- **6 horas** para sesiones temporales (si el usuario no marca "Mantener sesión").
- **1 año** para sesiones persistentes.
- Se han protegido todos los controladores existentes (`EquiposController`, `SectoresController`, etc.) con el atributo `[Authorize]`, requiriendo un token válido para su acceso.
- Actualizada la configuración de Swagger para incluir un campo de autorización "Bearer Token", facilitando las pruebas de los endpoints protegidos desde la UI.
**Cambios en el Frontend (React):**
- Se ha creado un componente `Login.tsx` que actúa como la puerta de entrada a la aplicación.
- Implementado un `AuthContext` para gestionar el estado global de autenticación (`isAuthenticated`, `token`, `isLoading`).
- Añadida la funcionalidad "Mantener sesión iniciada" a través de un checkbox en el formulario de login.
- Si está marcado, el token se guarda en `localStorage`.
- Si está desmarcado, el token se guarda en `sessionStorage` (la sesión se cierra al cerrar el navegador/pestaña).
- La función `request` en `apiService.ts` ha sido refactorizada para inyectar automáticamente el `Authorization: Bearer <token>` en todas las peticiones a la API.
- Se ha añadido un botón de "Cerrar Sesión" en la barra de navegación que limpia el token y redirige al login.
- Corregido un bug que provocaba un bucle de recarga infinito después de un inicio de sesión exitoso debido a una condición de carrera.
This commit is contained in:
@@ -4,12 +4,24 @@ import GestionSectores from "./components/GestionSectores";
|
||||
import GestionComponentes from './components/GestionComponentes';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import Navbar from './components/Navbar';
|
||||
import { useAuth } from './context/AuthContext';
|
||||
import Login from './components/Login';
|
||||
import './App.css';
|
||||
|
||||
export type View = 'equipos' | 'sectores' | 'admin' | 'dashboard';
|
||||
|
||||
function App() {
|
||||
const [currentView, setCurrentView] = useState<View>('equipos');
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
// Muestra un loader mientras se verifica la sesión
|
||||
if (isLoading) {
|
||||
return <div>Cargando...</div>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
75
frontend/src/components/Login.tsx
Normal file
75
frontend/src/components/Login.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// frontend/src/components/Login.tsx
|
||||
import React, { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { authService } from '../services/apiService';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import styles from './SimpleTable.module.css';
|
||||
|
||||
const Login = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 2. Pasar el estado del checkbox al servicio
|
||||
const data = await authService.login(username, password, rememberMe);
|
||||
// 3. Pasar el token Y el estado del checkbox al contexto
|
||||
login(data.token, rememberMe);
|
||||
toast.success('¡Bienvenido!');
|
||||
} catch (error) {
|
||||
toast.error('Usuario o contraseña incorrectos.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.modalOverlay} style={{ animation: 'none' }}>
|
||||
<div className={styles.modal} style={{ animation: 'none' }}>
|
||||
<h3>Iniciar Sesión - Inventario IT</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label>Usuario</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className={styles.modalInput}
|
||||
required
|
||||
/>
|
||||
<label style={{ marginTop: '1rem' }}>Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={styles.modalInput}
|
||||
required
|
||||
/>
|
||||
<div style={{ marginTop: '1rem', display: 'flex', alignItems: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="rememberMe"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
style={{ marginRight: '0.5rem' }}
|
||||
/>
|
||||
<label htmlFor="rememberMe" style={{ marginBottom: 0, fontWeight: 'normal' }}>
|
||||
Mantener sesión iniciada
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.modalActions} style={{ marginTop: '1.5rem' }}>
|
||||
<button type="submit" className={`${styles.btn} ${styles.btnPrimary}`} disabled={isLoading || !username || !password}>
|
||||
{isLoading ? 'Ingresando...' : 'Ingresar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
@@ -2,6 +2,8 @@
|
||||
import React from 'react';
|
||||
import type { View } from '../App';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import '../App.css';
|
||||
|
||||
interface NavbarProps {
|
||||
@@ -10,12 +12,13 @@ interface NavbarProps {
|
||||
}
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({ currentView, setCurrentView }) => {
|
||||
const { logout } = useAuth();
|
||||
return (
|
||||
<header className="navbar">
|
||||
<div className="app-title">
|
||||
Inventario IT
|
||||
</div>
|
||||
<nav className="nav-links">
|
||||
<nav className="nav-links">
|
||||
<button
|
||||
className={`nav-link ${currentView === 'equipos' ? 'nav-link-active' : ''}`}
|
||||
onClick={() => setCurrentView('equipos')}
|
||||
@@ -40,11 +43,17 @@ const Navbar: React.FC<NavbarProps> = ({ currentView, setCurrentView }) => {
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<div style={{ padding: '0.25rem' }}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: '1rem' }}>
|
||||
<ThemeToggle />
|
||||
<button
|
||||
onClick={logout}
|
||||
className="theme-toggle-button"
|
||||
title="Cerrar sesión"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
64
frontend/src/context/AuthContext.tsx
Normal file
64
frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// frontend/src/context/AuthContext.tsx
|
||||
import React, { createContext, useState, useContext, useMemo, useEffect } from 'react';
|
||||
|
||||
interface AuthContextType {
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
// 1. Modificar la firma de la función login
|
||||
login: (token: string, rememberMe: boolean) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// 2. Al cargar, buscar el token en localStorage primero, y luego en sessionStorage
|
||||
const storedToken = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
|
||||
setToken(storedToken);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
// 3. Implementar la nueva lógica de login
|
||||
const login = (newToken: string, rememberMe: boolean) => {
|
||||
if (rememberMe) {
|
||||
// Si el usuario quiere ser recordado, usamos localStorage
|
||||
localStorage.setItem('authToken', newToken);
|
||||
} else {
|
||||
// Si no, usamos sessionStorage
|
||||
sessionStorage.setItem('authToken', newToken);
|
||||
}
|
||||
setToken(newToken);
|
||||
};
|
||||
|
||||
// 4. Asegurarnos de que el logout limpie ambos almacenamientos
|
||||
const logout = () => {
|
||||
// Asegurarse de limpiar ambos almacenamientos
|
||||
localStorage.removeItem('authToken');
|
||||
sessionStorage.removeItem('authToken');
|
||||
setToken(null);
|
||||
};
|
||||
|
||||
const isAuthenticated = !!token;
|
||||
|
||||
// 5. Actualizar el valor del contexto
|
||||
const value = useMemo(() => ({ token, isAuthenticated, isLoading, login, logout }), [token, isLoading]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth debe ser usado dentro de un AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -5,28 +5,31 @@ import App from './App.tsx'
|
||||
import './index.css'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
toastOptions={{
|
||||
success: {
|
||||
style: {
|
||||
background: '#28a745',
|
||||
color: 'white',
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
toastOptions={{
|
||||
success: {
|
||||
style: {
|
||||
background: '#28a745',
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
},
|
||||
error: {
|
||||
style: {
|
||||
background: '#dc3545',
|
||||
color: 'white',
|
||||
error: {
|
||||
style: {
|
||||
background: '#dc3545',
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
}}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -4,10 +4,37 @@ import type { Equipo, Sector, HistorialEquipo, Usuario, MemoriaRam, DashboardSta
|
||||
|
||||
const BASE_URL = '/api';
|
||||
|
||||
async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
// --- FUNCIÓN 'request' ---
|
||||
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
// 1. Intentar obtener el token de localStorage primero, si no existe, buscar en sessionStorage.
|
||||
const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
|
||||
|
||||
// 2. Añadir el token al encabezado de autorización si existe
|
||||
const headers = new Headers(options.headers);
|
||||
if (token) {
|
||||
headers.append('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
options.headers = headers;
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
// 3. Manejar errores de autenticación
|
||||
if (response.status === 401) {
|
||||
// SOLO recargamos si el error 401 NO viene del endpoint de login.
|
||||
// Esto es para el caso de un token expirado en una petición a una ruta protegida.
|
||||
if (!url.includes('/auth/login')) {
|
||||
localStorage.removeItem('authToken');
|
||||
sessionStorage.removeItem('authToken');
|
||||
window.location.reload();
|
||||
// La recarga previene que el resto del código se ejecute.
|
||||
// Lanzamos un error para detener la ejecución de esta promesa.
|
||||
throw new Error('Sesión expirada. Por favor, inicie sesión de nuevo.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Para el login, el 401 llegará hasta aquí y lanzará el error
|
||||
// que será capturado por el componente Login.tsx.
|
||||
const errorData = await response.json().catch(() => ({ message: 'Error en la respuesta del servidor' }));
|
||||
throw new Error(errorData.message || 'Ocurrió un error desconocido');
|
||||
}
|
||||
@@ -19,6 +46,17 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// --- SERVICIO PARA AUTENTICACIÓN ---
|
||||
export const authService = {
|
||||
// Añadimos el parámetro 'rememberMe'
|
||||
login: (username: string, password: string, rememberMe: boolean) =>
|
||||
request<{ token: string }>(`${BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, rememberMe }),
|
||||
}),
|
||||
};
|
||||
|
||||
// --- Servicio para la gestión de Sectores ---
|
||||
export const sectorService = {
|
||||
getAll: () => request<Sector[]>(`${BASE_URL}/sectores`),
|
||||
|
||||
Reference in New Issue
Block a user