Implementación de un frontend en Vue 3 para el sistema de inventario con autenticación JWT
Este manual cubre el desarrollo completo de una aplicación Vue 3 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.vue # Layout principal
│ │ ├── Navbar.vue # Barra de navegación
│ │ └── PrivateRoute.vue # Ruta protegida
│ ├── stores/ # Stores de Pinia
│ │ └── auth.js # Store de autenticación
│ ├── views/ # Vistas/páginas
│ │ ├── auth/
│ │ │ ├── Login.vue # Vista de login
│ │ │ └── Register.vue # Vista de registro
│ │ ├── products/
│ │ │ ├── List.vue # Lista de productos
│ │ │ ├── Create.vue # Crear producto
│ │ │ └── Edit.vue # Editar producto
│ │ └── Home.vue # Vista de inicio
│ ├── App.vue # Componente principal
│ ├── main.js # Punto de entrada
│ ├── router.js # Configuración de rutas
│ └── styles/ # Estilos globales
├── package.json # Dependencias
└── .env # Variables de entorno
# Crear aplicación Vue con Vite
npm create vue@latest inventory-frontend
cd inventory-frontend
# Instalar dependencias básicas
npm install axios pinia vue-router vee-validate @vee-validate/rules jwt-decode
# Opcional: Instalar PrimeVue
npm install primevue primeicons primeflex
# Instalar dependencias de desarrollo para Vee-Validate
npm install @vee-validate/rules --save-dev
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;
// Decodificar el token para obtener información del usuario
try {
const decoded = jwtDecode(token);
return { username: decoded.sub };
} catch (error) {
return null;
}
};
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 { defineStore } from 'pinia';
import { login as apiLogin, register as apiRegister, getCurrentUser } from '@/api/auth';
import { useRouter } from 'vue-router';
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
isAuthenticated: false,
loading: false,
error: null
}),
actions: {
async initialize() {
this.loading = true;
try {
const user = getCurrentUser();
if (user) {
this.user = user;
this.isAuthenticated = true;
}
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
}
},
async login(credentials) {
this.loading = true;
this.error = null;
try {
const data = await apiLogin(credentials);
localStorage.setItem('token', data.access_token);
this.user = getCurrentUser();
this.isAuthenticated = true;
return true;
} catch (error) {
this.error = error.detail || 'Error al iniciar sesión';
return false;
} finally {
this.loading = false;
}
},
async register(userData) {
this.loading = true;
this.error = null;
try {
await apiRegister(userData);
return true;
} catch (error) {
this.error = error.detail || 'Error al registrar usuario';
return false;
} finally {
this.loading = false;
}
},
logout() {
localStorage.removeItem('token');
this.user = null;
this.isAuthenticated = false;
const router = useRouter();
router.push('/login');
}
}
});
<script setup>
import { computed } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { useRouter } from 'vue-router';
const authStore = useAuthStore();
const router = useRouter();
const isAuthenticated = computed(() => authStore.isAuthenticated);
if (!isAuthenticated.value) {
router.replace('/login');
}
</script>
<template>
<slot v-if="isAuthenticated" />
<div v-else class="text-center p-8">
Cargando...
</div>
</template>
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import Home from '@/views/Home.vue';
import Login from '@/views/auth/Login.vue';
import Register from '@/views/auth/Register.vue';
import ProductList from '@/views/products/List.vue';
import ProductCreate from '@/views/products/Create.vue';
import ProductEdit from '@/views/products/Edit.vue';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: Home
},
{
path: '/login',
name: 'login',
component: Login,
meta: { requiresGuest: true }
},
{
path: '/register',
name: 'register',
component: Register,
meta: { requiresGuest: true }
},
{
path: '/products',
name: 'products',
component: ProductList,
meta: { requiresAuth: true }
},
{
path: '/products/create',
name: 'products.create',
component: ProductCreate,
meta: { requiresAuth: true }
},
{
path: '/products/:id/edit',
name: 'products.edit',
component: ProductEdit,
meta: { requiresAuth: true }
}
]
});
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
if (!authStore.isAuthenticated && localStorage.getItem('token')) {
await authStore.initialize();
}
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({ name: 'login' });
} else if (to.meta.requiresGuest && authStore.isAuthenticated) {
next({ name: 'home' });
} else {
next();
}
});
export default router;
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { getProducts, deleteProduct } from '@/api/products';
const products = ref([]);
const loading = ref(true);
const error = ref(null);
const router = useRouter();
const fetchProducts = async () => {
try {
products.value = await getProducts();
} catch (err) {
error.value = err.detail || 'Error al cargar productos';
} finally {
loading.value = false;
}
};
const handleDelete = async (id) => {
if (confirm('¿Estás seguro de eliminar este producto?')) {
try {
await deleteProduct(id);
products.value = products.value.filter(p => p.id !== id);
} catch (err) {
error.value = err.detail || 'Error al eliminar producto';
}
}
};
onMounted(() => {
fetchProducts();
});
</script>
<template>
<div>
<h1>Lista de Productos</h1>
<div v-if="loading" class="text-center p-8">
Cargando productos...
</div>
<div v-else-if="error" class="text-red-500 p-4">
{{ error }}
</div>
<div v-else>
<button @click="router.push('/products/create')">
Crear Nuevo Producto
</button>
<table>
<thead>
<tr>
<th>Nombre</th>
<th>Precio</th>
<th>Stock</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
<tr v-for="product in products" :key="product.id">
<td>{{ product.name }}</td>
<td>${{ product.price }}</td>
<td>{{ product.stock }}</td>
<td>
<button @click="router.push(`/products/${product.id}/edit`)">
Editar
</button>
<button @click="handleDelete(product.id)">
Eliminar
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useForm, useField } from 'vee-validate';
import * as yup from 'yup';
import { createProduct } from '@/api/products';
const router = useRouter();
const error = ref(null);
// Esquema de validación
const schema = yup.object({
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(),
});
// Configuración del formulario
const { handleSubmit } = useForm({
validationSchema: schema,
});
// Campos del formulario
const { value: name, errorMessage: nameError } = useField('name');
const { value: description } = useField('description');
const { value: price, errorMessage: priceError } = useField('price');
const { value: stock, errorMessage: stockError } = useField('stock');
const { value: category } = useField('category');
// Envío del formulario
const onSubmit = handleSubmit(async (values) => {
try {
await createProduct(values);
router.push('/products');
} catch (err) {
error.value = err.detail || 'Error al crear producto';
}
});
</script>
<template>
<div>
<h1>Crear Producto</h1>
<form @submit="onSubmit">
<div>
<label>Nombre</label>
<input v-model="name" type="text" />
<span v-if="nameError" class="text-red-500">{{ nameError }}</span>
</div>
<div>
<label>Descripción</label>
<textarea v-model="description"></textarea>
</div>
<div>
<label>Precio</label>
<input v-model="price" type="number" step="0.01" />
<span v-if="priceError" class="text-red-500">{{ priceError }}</span>
</div>
<div>
<label>Stock</label>
<input v-model="stock" type="number" />
<span v-if="stockError" class="text-red-500">{{ stockError }}</span>
</div>
<div>
<label>Categoría</label>
<input v-model="category" type="text" />
</div>
<button type="submit">Guardar</button>
<button type="button" @click="router.push('/products')">
Cancelar
</button>
<div v-if="error" class="text-red-500 mt-4">
{{ error }}
</div>
</form>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useForm, useField } from 'vee-validate';
import * as yup from 'yup';
import { useAuthStore } from '@/stores/auth';
const router = useRouter();
const authStore = useAuthStore();
// Esquema de validación
const schema = yup.object({
username: yup.string().required('Usuario es requerido'),
password: yup.string().required('Contraseña es requerida'),
});
// Configuración del formulario
const { handleSubmit } = useForm({
validationSchema: schema,
});
// Campos del formulario
const { value: username, errorMessage: usernameError } = useField('username');
const { value: password, errorMessage: passwordError } = useField('password');
// Envío del formulario
const onSubmit = handleSubmit(async (values) => {
const success = await authStore.login(values);
if (success) {
router.push('/products');
}
});
</script>
<template>
<div>
<h1>Iniciar Sesión</h1>
<form @submit="onSubmit">
<div>
<label>Usuario</label>
<input v-model="username" type="text" />
<span v-if="usernameError" class="text-red-500">{{ usernameError }}</span>
</div>
<div>
<label>Contraseña</label>
<input v-model="password" type="password" />
<span v-if="passwordError" class="text-red-500">{{ passwordError }}</span>
</div>
<button type="submit">Ingresar</button>
<div v-if="authStore.error" class="text-red-500 mt-4">
{{ authStore.error }}
</div>
</form>
<p>
¿No tienes cuenta? <router-link to="/register">Regístrate aquí</router-link>
</p>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useForm, useField } from 'vee-validate';
import * as yup from 'yup';
import { useAuthStore } from '@/stores/auth';
const router = useRouter();
const authStore = useAuthStore();
const success = ref(false);
// Esquema de validación
const schema = yup.object({
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'),
});
// Configuración del formulario
const { handleSubmit } = useForm({
validationSchema: schema,
});
// Campos del formulario
const { value: username, errorMessage: usernameError } = useField('username');
const { value: email, errorMessage: emailError } = useField('email');
const { value: password, errorMessage: passwordError } = useField('password');
// Envío del formulario
const onSubmit = handleSubmit(async (values) => {
const registered = await authStore.register(values);
if (registered) {
success.value = true;
setTimeout(() => router.push('/login'), 2000);
}
});
</script>
<template>
<div v-if="!success">
<h1>Registro</h1>
<form @submit="onSubmit">
<div>
<label>Usuario</label>
<input v-model="username" type="text" />
<span v-if="usernameError" class="text-red-500">{{ usernameError }}</span>
</div>
<div>
<label>Email</label>
<input v-model="email" type="email" />
<span v-if="emailError" class="text-red-500">{{ emailError }}</span>
</div>
<div>
<label>Contraseña</label>
<input v-model="password" type="password" />
<span v-if="passwordError" class="text-red-500">{{ passwordError }}</span>
</div>
<button type="submit">Registrarse</button>
<div v-if="authStore.error" class="text-red-500 mt-4">
{{ authStore.error }}
</div>
</form>
<p>
¿Ya tienes cuenta? <router-link to="/login">Inicia sesión aquí</router-link>
</p>
</div>
<div v-else class="text-center p-8">
<h2>Registro Exitoso</h2>
<p>Redirigiendo a la página de login...</p>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth';
import { useRouter } from 'vue-router';
const authStore = useAuthStore();
const router = useRouter();
const handleLogout = () => {
authStore.logout();
};
</script>
<template>
<div>
<header>
<nav>
<router-link to="/">Inicio</router-link>
<template v-if="authStore.isAuthenticated">
<router-link to="/products">Productos</router-link>
<span>Bienvenido, {{ authStore.user?.username }}</span>
<button @click="handleLogout">Cerrar sesión</button>
</template>
<template v-else>
<router-link to="/login">Iniciar sesión</router-link>
<router-link to="/register">Registrarse</router-link>
</template>
</nav>
</header>
<main>
<slot />
</main>
<footer>
<p>Sistema de Inventario © {{ new Date().getFullYear() }}</p>
</footer>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth';
import { useRouter } from 'vue-router';
import Button from 'primevue/button';
import Avatar from 'primevue/avatar';
const authStore = useAuthStore();
const router = useRouter();
const handleLogout = () => {
authStore.logout();
};
</script>
<template>
<div class="card">
<Menubar :model="items">
<template #start>
<router-link to="/">
<span class="font-bold">Inventario App</span>
</router-link>
</template>
<template #end>
<template v-if="authStore.isAuthenticated">
<Button
label="Productos"
text
@click="router.push('/products')"
/>
<Avatar
:label="authStore.user?.username?.charAt(0).toUpperCase()"
class="mr-2"
size="normal"
shape="circle"
/>
<Button
label="Cerrar sesión"
text
@click="handleLogout"
/>
</template>
<template v-else>
<Button
label="Iniciar sesión"
text
@click="router.push('/login')"
/>
<Button
label="Registrarse"
text
@click="router.push('/register')"
/>
</template>
</template>
</Menubar>
</div>
</template>
# 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 en Pinia
- 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 { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import Login from '@/views/auth/Login.vue';
import { useAuthStore } from '@/stores/auth';
describe('Login Component', () => {
it('renders login form', () => {
const wrapper = mount(Login, {
global: {
plugins: [createTestingPinia()],
},
});
expect(wrapper.find('input[type="text"]').exists()).toBe(true);
expect(wrapper.find('input[type="password"]').exists()).toBe(true);
expect(wrapper.find('button[type="submit"]').text()).toBe('Ingresar');
});
it('calls login action on submit', async () => {
const wrapper = mount(Login, {
global: {
plugins: [createTestingPinia({
stubActions: false,
})],
},
});
const authStore = useAuthStore();
authStore.login = vi.fn();
await wrapper.find('input[type="text"]').setValue('testuser');
await wrapper.find('input[type="password"]').setValue('password');
await wrapper.find('form').trigger('submit');
expect(authStore.login).toHaveBeenCalledWith({
username: 'testuser',
password: 'password'
});
});
});