Implementación de un frontend en React para el sistema de inventario con autenticación JWT
Este manual cubre el desarrollo completo de una aplicación React que consume la API FastAPI del manual anterior, implementando:
inventory-frontend/
├── public/ # Archivos estáticos
├── src/
│ ├── api/ # Configuración de Axios y endpoints
│ │ ├── auth.js # Endpoints de autenticación
│ │ └── products.js # Endpoints de productos
│ ├── components/ # Componentes reutilizables
│ │ ├── Layout.js # Layout principal
│ │ ├── Navbar.js # Barra de navegación
│ │ └── PrivateRoute.js # Ruta protegida
│ ├── context/ # Contextos de React
│ │ └── AuthContext.js # Contexto de autenticación
│ ├── pages/ # Páginas/views
│ │ ├── auth/
│ │ │ ├── Login.js # Página de login
│ │ │ └── Register.js # Página de registro
│ │ ├── products/
│ │ │ ├── List.js # Lista de productos
│ │ │ ├── Create.js # Crear producto
│ │ │ └── Edit.js # Editar producto
│ │ └── Home.js # Página de inicio
│ ├── App.js # Componente principal
│ ├── index.js # Punto de entrada
│ └── styles/ # Estilos globales
├── package.json # Dependencias
└── .env # Variables de entorno
# Crear aplicación React con Vite (recomendado)
npm create vite@latest inventory-frontend --template react
cd inventory-frontend
# Instalar dependencias básicas
npm install axios react-router-dom formik yup jwt-decode
# Opcional: Instalar Material-UI
npm install @mui/material @emotion/react @emotion/styled @mui/icons-material
VITE_API_BASE_URL=http://localhost:8000
VITE_API_TIMEOUT=5000
En Vite, las variables de entorno para el cliente deben comenzar con VITE_
para ser accesibles en el código.
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: import.meta.env.VITE_API_TIMEOUT,
});
// Interceptor para añadir el token JWT a las peticiones
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
}, (error) => {
return Promise.reject(error);
});
export default api;
import api from './axios';
export const login = async (credentials) => {
try {
const response = await api.post('/token',
`username=${credentials.username}&password=${credentials.password}`,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
return response.data;
} catch (error) {
throw error.response.data;
}
};
export const register = async (userData) => {
try {
const response = await api.post('/register', userData);
return response.data;
} catch (error) {
throw error.response.data;
}
};
export const getCurrentUser = () => {
const token = localStorage.getItem('token');
if (!token) return null;
// Aquí podrías decodificar el token para obtener información del usuario
// usando jwt-decode si lo necesitas
return { username: 'Usuario' }; // Ejemplo simplificado
};
import api from './axios';
export const getProducts = async () => {
try {
const response = await api.get('/products');
return response.data;
} catch (error) {
throw error.response.data;
}
};
export const getProduct = async (id) => {
try {
const response = await api.get(`/products/${id}`);
return response.data;
} catch (error) {
throw error.response.data;
}
};
export const createProduct = async (productData) => {
try {
const response = await api.post('/products', productData);
return response.data;
} catch (error) {
throw error.response.data;
}
};
export const updateProduct = async (id, productData) => {
try {
const response = await api.put(`/products/${id}`, productData);
return response.data;
} catch (error) {
throw error.response.data;
}
};
export const deleteProduct = async (id) => {
try {
const response = await api.delete(`/products/${id}`);
return response.data;
} catch (error) {
throw error.response.data;
}
};
import { createContext, useState, useEffect } from 'react';
import { login as apiLogin, register as apiRegister, getCurrentUser } from '../api/auth';
export const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
// Verificar autenticación al cargar
useEffect(() => {
const checkAuth = async () => {
try {
const currentUser = getCurrentUser();
if (currentUser) {
setUser(currentUser);
setIsAuthenticated(true);
}
} catch (error) {
console.error('Error checking auth:', error);
} finally {
setLoading(false);
}
};
checkAuth();
}, []);
const login = async (credentials) => {
try {
const data = await apiLogin(credentials);
localStorage.setItem('token', data.access_token);
const currentUser = getCurrentUser();
setUser(currentUser);
setIsAuthenticated(true);
return true;
} catch (error) {
console.error('Login error:', error);
return false;
}
};
const register = async (userData) => {
try {
await apiRegister(userData);
return true;
} catch (error) {
console.error('Register error:', error);
return false;
}
};
const logout = () => {
localStorage.removeItem('token');
setUser(null);
setIsAuthenticated(false);
};
return (
<AuthContext.Provider
value={{
user,
isAuthenticated,
loading,
login,
register,
logout
}}
>
{children}
</AuthContext.Provider>
);
};
import { useContext } from 'react';
import { Navigate } from 'react-router-dom';
import { AuthContext } from '../context/AuthContext';
const PrivateRoute = ({ children }) => {
const { isAuthenticated, loading } = useContext(AuthContext);
if (loading) {
return <div>Loading...</div>; // O un spinner
}
return isAuthenticated ? children : <Navigate to="/login" />;
};
export default PrivateRoute;
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import PrivateRoute from './components/PrivateRoute';
import Login from './pages/auth/Login';
import Register from './pages/auth/Register';
import Home from './pages/Home';
import ProductList from './pages/products/List';
import ProductCreate from './pages/products/Create';
import ProductEdit from './pages/products/Edit';
import Layout from './components/Layout';
function App() {
return (
<AuthProvider>
<Router>
<Layout>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/" element={<Home />} />
{/* Rutas protegidas */}
<Route path="/products" element={
<PrivateRoute>
<ProductList />
</PrivateRoute>
} />
<Route path="/products/create" element={
<PrivateRoute>
<ProductCreate />
</PrivateRoute>
} />
<Route path="/products/:id/edit" element={
<PrivateRoute>
<ProductEdit />
</PrivateRoute>
} />
</Routes>
</Layout>
</Router>
</AuthProvider>
);
}
export default App;
import { useState, useEffect, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import { getProducts, deleteProduct } from '../../api/products';
import { AuthContext } from '../../context/AuthContext';
const ProductList = () => {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const { user } = useContext(AuthContext);
const navigate = useNavigate();
useEffect(() => {
const fetchProducts = async () => {
try {
const data = await getProducts();
setProducts(data);
} catch (err) {
setError(err.message || 'Error al cargar productos');
} finally {
setLoading(false);
}
};
fetchProducts();
}, []);
const handleDelete = async (id) => {
if (window.confirm('¿Estás seguro de eliminar este producto?')) {
try {
await deleteProduct(id);
setProducts(products.filter(product => product.id !== id));
} catch (err) {
setError(err.message || 'Error al eliminar producto');
}
}
};
if (loading) return <div>Cargando productos...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<h1>Lista de Productos</h1>
<button onClick={() => navigate('/products/create')}>
Crear Nuevo Producto
</button>
<table>
<thead>
<tr>
<th>Nombre</th>
<th>Precio</th>
<th>Stock</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{products.map(product => (
<tr key={product.id}>
<td>{product.name}</td>
<td>${product.price}</td>
<td>{product.stock}</td>
<td>
<button onClick={() => navigate(`/products/${product.id}/edit`)}>
Editar
</button>
<button onClick={() => handleDelete(product.id)}>
Eliminar
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default ProductList;
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { createProduct } from '../../api/products';
const productSchema = Yup.object().shape({
name: Yup.string().required('El nombre es requerido'),
price: Yup.number()
.required('El precio es requerido')
.positive('El precio debe ser positivo'),
stock: Yup.number()
.integer('El stock debe ser un número entero')
.min(0, 'El stock no puede ser negativo'),
description: Yup.string(),
category: Yup.string(),
});
const ProductCreate = () => {
const [error, setError] = useState(null);
const navigate = useNavigate();
const formik = useFormik({
initialValues: {
name: '',
description: '',
price: 0,
stock: 0,
category: '',
},
validationSchema: productSchema,
onSubmit: async (values) => {
try {
await createProduct(values);
navigate('/products');
} catch (err) {
setError(err.message || 'Error al crear producto');
}
},
});
return (
<div>
<h1>Crear Producto</h1>
{error && <div style={{ color: 'red' }}>{error}</div>}
<form onSubmit={formik.handleSubmit}>
<div>
<label>Nombre</label>
<input
type="text"
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.name && formik.errors.name && (
<div style={{ color: 'red' }}>{formik.errors.name}</div>
)}
</div>
<div>
<label>Descripción</label>
<textarea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
/>
</div>
<div>
<label>Precio</label>
<input
type="number"
name="price"
value={formik.values.price}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.price && formik.errors.price && (
<div style={{ color: 'red' }}>{formik.errors.price}</div>
)}
</div>
<div>
<label>Stock</label>
<input
type="number"
name="stock"
value={formik.values.stock}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.stock && formik.errors.stock && (
<div style={{ color: 'red' }}>{formik.errors.stock}</div>
)}
</div>
<div>
<label>Categoría</label>
<input
type="text"
name="category"
value={formik.values.category}
onChange={formik.handleChange}
/>
</div>
<button type="submit">Guardar</button>
<button type="button" onClick={() => navigate('/products')}>
Cancelar
</button>
</form>
</div>
);
};
export default ProductCreate;
import { useState, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { AuthContext } from '../../context/AuthContext';
const loginSchema = Yup.object().shape({
username: Yup.string().required('Usuario es requerido'),
password: Yup.string().required('Contraseña es requerida'),
});
const Login = () => {
const [error, setError] = useState(null);
const { login } = useContext(AuthContext);
const navigate = useNavigate();
const formik = useFormik({
initialValues: {
username: '',
password: '',
},
validationSchema: loginSchema,
onSubmit: async (values) => {
setError(null);
const success = await login(values);
if (success) {
navigate('/products');
} else {
setError('Usuario o contraseña incorrectos');
}
},
});
return (
<div>
<h1>Iniciar Sesión</h1>
{error && <div style={{ color: 'red' }}>{error}</div>}
<form onSubmit={formik.handleSubmit}>
<div>
<label>Usuario</label>
<input
type="text"
name="username"
value={formik.values.username}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.username && formik.errors.username && (
<div style={{ color: 'red' }}>{formik.errors.username}</div>
)}
</div>
<div>
<label>Contraseña</label>
<input
type="password"
name="password"
value={formik.values.password}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.password && formik.errors.password && (
<div style={{ color: 'red' }}>{formik.errors.password}</div>
)}
</div>
<button type="submit">Ingresar</button>
</form>
<p>
¿No tienes cuenta? <a href="/register">Regístrate aquí</a>
</p>
</div>
);
};
export default Login;
import { useState, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { AuthContext } from '../../context/AuthContext';
const registerSchema = Yup.object().shape({
username: Yup.string()
.required('Usuario es requerido')
.min(3, 'Usuario debe tener al menos 3 caracteres'),
email: Yup.string()
.email('Email inválido')
.required('Email es requerido'),
password: Yup.string()
.required('Contraseña es requerida')
.min(6, 'Contraseña debe tener al menos 6 caracteres'),
});
const Register = () => {
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
const { register } = useContext(AuthContext);
const navigate = useNavigate();
const formik = useFormik({
initialValues: {
username: '',
email: '',
password: '',
},
validationSchema: registerSchema,
onSubmit: async (values) => {
setError(null);
const success = await register(values);
if (success) {
setSuccess(true);
setTimeout(() => navigate('/login'), 2000);
} else {
setError('Error al registrar usuario');
}
},
});
if (success) {
return (
<div>
<h1>Registro Exitoso</h1>
<p>Redirigiendo a la página de login...</p>
</div>
);
}
return (
<div>
<h1>Registro</h1>
{error && <div style={{ color: 'red' }}>{error}</div>}
<form onSubmit={formik.handleSubmit}>
<div>
<label>Usuario</label>
<input
type="text"
name="username"
value={formik.values.username}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.username && formik.errors.username && (
<div style={{ color: 'red' }}>{formik.errors.username}</div>
)}
</div>
<div>
<label>Email</label>
<input
type="email"
name="email"
value={formik.values.email}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.email && formik.errors.email && (
<div style={{ color: 'red' }}>{formik.errors.email}</div>
)}
</div>
<div>
<label>Contraseña</label>
<input
type="password"
name="password"
value={formik.values.password}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.password && formik.errors.password && (
<div style={{ color: 'red' }}>{formik.errors.password}</div>
)}
</div>
<button type="submit">Registrarse</button>
</form>
<p>
¿Ya tienes cuenta? <a href="/login">Inicia sesión aquí</a>
</p>
</div>
);
};
export default Register;
import { useContext } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { AuthContext } from '../context/AuthContext';
const Layout = ({ children }) => {
const { user, isAuthenticated, logout } = useContext(AuthContext);
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<div>
<header>
<nav>
<Link to="/">Inicio</Link>
{isAuthenticated ? (
<>
<Link to="/products">Productos</Link>
<span>Bienvenido, {user?.username}</span>
<button onClick={handleLogout}>Cerrar sesión</button>
</>
) : (
<>
<Link to="/login">Iniciar sesión</Link>
<Link to="/register">Registrarse</Link>
</>
)}
</nav>
</header>
<main>
{children}
</main>
<footer>
<p>Sistema de Inventario © {new Date().getFullYear()}</p>
</footer>
</div>
);
};
export default Layout;
import { useContext } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
AppBar,
Toolbar,
Typography,
Button,
Box,
Avatar
} from '@mui/material';
import { AuthContext } from '../context/AuthContext';
const Navbar = () => {
const { user, isAuthenticated, logout } = useContext(AuthContext);
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<AppBar position="static">
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
<Link to="/" style={{ color: 'white', textDecoration: 'none' }}>
Inventario App
</Link>
</Typography>
{isAuthenticated ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
color="inherit"
component={Link}
to="/products"
>
Productos
</Button>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar sx={{ width: 32, height: 32 }}>
{user?.username?.charAt(0).toUpperCase()}
</Avatar>
<Typography variant="body1">
{user?.username}
</Typography>
</Box>
<Button
color="inherit"
onClick={handleLogout}
>
Cerrar sesión
</Button>
</Box>
) : (
<Box>
<Button
color="inherit"
component={Link}
to="/login"
>
Iniciar sesión
</Button>
<Button
color="inherit"
component={Link}
to="/register"
>
Registrarse
</Button>
</Box>
)}
</Toolbar>
</AppBar>
);
};
export default Navbar;
# Ejecutar el frontend
npm run dev
# Ejecutar el backend (desde el directorio del proyecto FastAPI)
uvicorn app.main:app --reload
1. Usuario visita la aplicación
2. Si no está autenticado, es redirigido a /login
3. Después de login exitoso:
- Token JWT se almacena en localStorage
- Estado de autenticación se actualiza
- Usuario es redirigido a /products
4. En /products:
- Se hace petición GET a /products
- Axios añade automáticamente el token en el header
- Se muestra la lista de productos
5. Para operaciones CRUD:
- Cada acción dispara la petición correspondiente
- El estado de la UI se actualiza según la respuesta
import { render, screen, fireEvent } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import Login from '../pages/auth/Login';
describe('Login Component', () => {
it('should render login form', () => {
render(
<BrowserRouter>
<Login />
</BrowserRouter>
);
expect(screen.getByLabelText('Usuario')).toBeInTheDocument();
expect(screen.getByLabelText('Contraseña')).toBeInTheDocument();
expect(screen.getByText('Ingresar')).toBeInTheDocument();
});
it('should show validation errors', async () => {
render(
<BrowserRouter>
<Login />
</BrowserRouter>
);
fireEvent.click(screen.getByText('Ingresar'));
expect(await screen.findByText('Usuario es requerido')).toBeInTheDocument();
expect(await screen.findByText('Contraseña es requerida')).toBeInTheDocument();
});
});