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:
2025-10-13 10:40:20 -03:00
parent acf2f9a35c
commit bc9a9906c3
21 changed files with 504 additions and 99 deletions

View File

@@ -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 (
<>

View 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;

View File

@@ -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>
);
};

View 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;
};

View File

@@ -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>,
)

View File

@@ -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`),