feat: frontend JWT handling, rate limiting [H-02], CORS config

Frontend:
- Creiez frontend/lib/auth.ts cu saveToken, getToken, getAuthHeader, clearAuth
- Modific api.ts: axiosInstance cu interceptor Bearer token + 401 → /login redirect
- Modific login page: salveaza JWT token din response

Backend:
- [H-02] Integrez slowapi rate limiting: 10 req/minute pe /items/extract-label
- [M-01] CORS: ALLOWED_ORIGINS din env (dev fallback: localhost:3000, localhost:3002)
- [C-01] JWT_SECRET_KEY din env (dev fallback: ephemeral key)

docker-compose.yml:
- Adaug ALLOWED_ORIGINS env var (dev: localhost)
- Adaug JWT_SECRET_KEY env var cu fallback warning

Status:
-  JWT backend: complet
-  JWT frontend: token save + attach + 401 handling
-  Rate limiting: 10/min pe extract-label
-  CORS: configurable via env

Gata pentru dev + testing local.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Bedeleanu
2026-04-11 13:42:09 +03:00
parent e9ada00497
commit e6ca33f2f0
6 changed files with 181 additions and 55 deletions

View File

@@ -3,6 +3,7 @@
import { useState, useEffect, useRef, memo } from 'react';
import { User, Shield, X, Lock, ChevronRight } from 'lucide-react';
import { inventoryApi } from '@/lib/api';
import { saveToken } from '@/lib/auth';
import { toast, Toaster } from 'react-hot-toast';
import { useRouter } from 'next/navigation';
@@ -20,9 +21,9 @@ export default function LoginPage() {
useEffect(() => {
setMounted(true);
inventoryApi.getUsers().then(setUsers).catch(() => {});
// If already logged in, go home
if (localStorage.getItem('inventory_user')) {
if (localStorage.getItem('inventory_token')) {
router.push('/');
}
}, [router]);
@@ -45,33 +46,29 @@ export default function LoginPage() {
}
try {
const user = await inventoryApi.login({
// [C-01] Login vrânceanu token JWT
const tokenResponse = await inventoryApi.login({
username,
password
});
localStorage.setItem('inventory_user', JSON.stringify(user));
toast.success(`Welcome back, ${user.username}`);
// Save JWT token și user info
saveToken(tokenResponse);
toast.success(`Welcome back, ${tokenResponse.username}`);
// Delay slightly to show toast then redirect
setTimeout(() => {
router.push('/');
}, 500);
} catch (error) {
toast.error(isEnterprise ? "Login failed. Check credentials or group membership." : "Invalid password");
}
};
const handleSelectUser = async (user: any) => {
if (user.username === 'Admin' || user.id > 1) {
setSelectedUserForLogin(user);
return;
}
// Auto-login for passwordless users (if any exist beyond Admin)
localStorage.setItem('inventory_user', JSON.stringify(user));
router.push('/');
// [C-01] Toți userii necesită parolă pentru JWT
setSelectedUserForLogin(user);
};
if (!mounted) return null;

View File

@@ -1,10 +1,11 @@
import axios from 'axios';
import { getToken, clearAuth } from './auth';
export const getBackendUrl = () => {
if (typeof window === 'undefined') return 'http://localhost:8000';
const host = window.location.hostname;
// If we are on HTTPS (Proxy/Mobile mode), we use port 3002 for the backend
if (window.location.protocol === 'https:') {
if (host.includes('.loca.lt')) {
@@ -12,126 +13,154 @@ export const getBackendUrl = () => {
}
return `https://${host}:3002`;
}
return `http://${host}:8000`;
};
/**
* [C-01] Axios instance cu JWT Bearer token în header
* și interceptor pentru 401 Unauthorized (token expired)
*/
const axiosInstance = axios.create({
baseURL: getBackendUrl()
});
axiosInstance.interceptors.request.use((config) => {
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
}, (error) => Promise.reject(error));
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
// [L-01] Handle 401 Unauthorized — token expired
if (error.response?.status === 401) {
clearAuth();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
export const inventoryApi = {
getItems: async () => {
const res = await axios.get(`${getBackendUrl()}/items/`);
const res = await axiosInstance.get('/items/');
return res.data;
},
getStats: async () => {
const res = await axios.get(`${getBackendUrl()}/items/stats`);
const res = await axiosInstance.get('/items/stats');
return res.data;
},
syncBulkOperations: async (userId: number, operations: any[]) => {
const url = `${getBackendUrl()}/operations/bulk-sync`;
try {
const res = await axios.post(url, {
const res = await axiosInstance.post('/operations/bulk-sync', {
user_id: userId,
operations: operations
});
return res.data;
} catch (err: any) {
console.error("Sync API Error at:", url, err);
console.error("Sync API Error:", err);
if (err.response?.status === 404) {
throw new Error(`404: Endpoint not found at ${url}`);
throw new Error(`404: Endpoint not found`);
}
throw err;
}
},
analyzeLabel: async (formData: FormData) => {
const res = await axios.post(`${getBackendUrl()}/items/extract-label`, formData, {
const res = await axiosInstance.post('/items/extract-label', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
return res.data;
},
createItem: async (userId: number, itemData: any) => {
const res = await axios.post(`${getBackendUrl()}/items/`, itemData, {
params: { user_id: userId }
});
const res = await axiosInstance.post('/items/', itemData);
return res.data;
},
updateItem: async (itemId: number, itemData: any) => {
const res = await axios.put(`${getBackendUrl()}/items/${itemId}`, itemData);
const res = await axiosInstance.put(`/items/${itemId}`, itemData);
return res.data;
},
adjustStock: async (endpoint: string, data: any) => {
const res = await axios.post(`${getBackendUrl()}/operations/${endpoint}`, data);
const res = await axiosInstance.post(`/operations/${endpoint}`, data);
return res.data;
},
deleteItem: async (itemId: number) => {
const res = await axios.delete(`${getBackendUrl()}/items/${itemId}`);
const res = await axiosInstance.delete(`/items/${itemId}`);
return res.data;
},
getAuditLogs: async (limit: number = 50) => {
const res = await axios.get(`${getBackendUrl()}/operations/logs`, { params: { limit } });
const res = await axiosInstance.get('/operations/logs', { params: { limit } });
return res.data;
},
// Users
getUsers: async () => {
const res = await axios.get(`${getBackendUrl()}/users/`);
const res = await axiosInstance.get('/users/');
return res.data;
},
createUser: async (userData: any) => {
const res = await axios.post(`${getBackendUrl()}/users/`, userData);
const res = await axiosInstance.post('/users/', userData);
return res.data;
},
login: async (credentials: any) => {
// [C-01] Login endpoint — NU adaug token header (login e public)
const res = await axios.post(`${getBackendUrl()}/users/login`, credentials);
return res.data;
},
deleteUser: async (userId: number) => {
const res = await axios.delete(`${getBackendUrl()}/users/${userId}`);
const res = await axiosInstance.delete(`/users/${userId}`);
return res.data;
},
updateUser: async (userId: number, data: any) => {
const res = await axios.put(`${getBackendUrl()}/users/${userId}`, data);
const res = await axiosInstance.put(`/users/${userId}`, data);
return res.data;
},
getLdapConfig: async () => {
const res = await axios.get(`${getBackendUrl()}/users/ldap-config`);
const res = await axiosInstance.get('/users/ldap-config');
return res.data;
},
updateLdapConfig: async (config: any) => {
const res = await axios.post(`${getBackendUrl()}/users/ldap-config`, config);
const res = await axiosInstance.post('/users/ldap-config', config);
return res.data;
},
testLdapConnection: async (config: any) => {
const res = await axios.post(`${getBackendUrl()}/users/test-ldap`, config);
const res = await axiosInstance.post('/users/test-ldap', config);
return res.data;
},
// Categories
getCategories: async () => {
const res = await axios.get(`${getBackendUrl()}/categories/`);
const res = await axiosInstance.get('/categories/');
return res.data;
},
createCategory: async (data: any) => {
const res = await axios.post(`${getBackendUrl()}/categories/`, data);
const res = await axiosInstance.post('/categories/', data);
return res.data;
},
updateCategory: async (id: number, data: any) => {
const res = await axios.put(`${getBackendUrl()}/categories/${id}`, data);
const res = await axiosInstance.put(`/categories/${id}`, data);
return res.data;
},
deleteCategory: async (id: number) => {
const res = await axios.delete(`${getBackendUrl()}/categories/${id}`);
const res = await axiosInstance.delete(`/categories/${id}`);
return res.data;
}
};

79
frontend/lib/auth.ts Normal file
View File

@@ -0,0 +1,79 @@
/**
* [C-01] JWT Authentication Utilities
* Handle token storage, retrieval, and expiration
*/
const TOKEN_KEY = 'inventory_token';
const USER_KEY = 'inventory_user';
export interface AuthToken {
access_token: string;
token_type: string;
user_id: number;
username: string;
role: string;
}
export interface AuthUser {
id: number;
username: string;
role: string;
}
/**
* Save JWT token to localStorage
*/
export const saveToken = (token: AuthToken): void => {
localStorage.setItem(TOKEN_KEY, token.access_token);
localStorage.setItem(USER_KEY, JSON.stringify({
id: token.user_id,
username: token.username,
role: token.role
}));
};
/**
* Get JWT token from localStorage
*/
export const getToken = (): string | null => {
if (typeof window === 'undefined') return null;
return localStorage.getItem(TOKEN_KEY);
};
/**
* Get current user info from localStorage
*/
export const getCurrentUser = (): AuthUser | null => {
if (typeof window === 'undefined') return null;
const userJson = localStorage.getItem(USER_KEY);
if (!userJson) return null;
try {
return JSON.parse(userJson);
} catch {
return null;
}
};
/**
* Check if user is authenticated (token exists)
*/
export const isAuthenticated = (): boolean => {
return !!getToken();
};
/**
* Clear token and user data (logout)
*/
export const clearAuth = (): void => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
};
/**
* Get Bearer token for API requests
*/
export const getAuthHeader = (): { Authorization: string } | {} => {
const token = getToken();
if (!token) return {};
return { Authorization: `Bearer ${token}` };
};