Updated frontend

This commit is contained in:
sriram
2026-08-12 16:33:39 +05:30
parent d2ac832960
commit dae0b3e0fb
14 changed files with 297 additions and 164 deletions

View File

@@ -9,11 +9,16 @@ is a fast local reference.
## Quick start
### One-click (Windows)
### One command (from the project root)
Double-click **`start_frontend.bat`**. It checks whether the backend is
reachable on port 8000 (just a warning, not a blocker) and starts the
Vite dev server.
```bash
python run_project.py
```
Starts the FastAPI backend, waits until `/api/health` answers, then starts
this Vite dev server - so the API is always up before the UI first calls it.
Use `--frontend-only` to start just Vite against a backend you're already
running yourself.
### Manual
@@ -25,8 +30,11 @@ npm run dev
Open http://localhost:5173 - the Vite dev server proxies `/api/*` to
`http://localhost:8000` automatically (see `vite.config.js`), so make
sure the backend is running there first (`../backend/README.md`,
`../backend/start_backend.bat`).
sure the backend is running there first (see `../backend/README.md`).
Note that Vite binds `localhost` as IPv6 (`[::1]`), which browsers handle
fine. Only relevant if you probe it with a tool pinned to IPv4 - use
`http://[::1]:5173` there, or pass `--host 127.0.0.1`.
If your backend runs somewhere else, set `VITE_API_BASE_URL` in a local
`.env` file (copy `.env.example`) instead of relying on the proxy.

View File

@@ -37,6 +37,24 @@ http {
proxy_read_timeout 300s;
}
# FastAPI's interactive docs. These are served at the app root by
# FastAPI (/docs, /redoc, /openapi.json) rather than under /api, so
# without this block they fall through to the SPA below and return
# index.html instead of Swagger.
#
# This publishes your full API surface to anyone who finds the URL.
# To close it off, delete this block and reach the docs through an SSH
# tunnel instead: ssh -L 8000:localhost:8000 user@vps
location ~ ^/(docs|redoc|openapi\.json) {
set $docs_upstream "http://backend:8000";
proxy_pass $docs_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
root /usr/share/nginx/html;
index index.html index.htm;

View File

@@ -1,17 +1,50 @@
import React from 'react';
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import { AuthProvider } from './context/AuthContext';
import { useAuth } from './context/useAuth';
import { ErrorBoundary } from './components/ErrorBoundary';
import { LoginPage } from './pages/LoginPage';
import { HomePage } from './pages/HomePage';
import { UserPage } from './pages/UserPage';
import { AdminPage } from './pages/AdminPage';
import { StoresPage } from './pages/StoresPage';
import { AnalyticsPage } from './pages/AnalyticsPage';
import { NutritionAnalyticsPage } from './pages/NutritionAnalyticsPage';
/*
* Every page except the login screen is loaded on demand.
*
* Imported eagerly, the whole app landed in one ~790kB bundle - the charting
* library alone, pulled in by the two analytics pages, was most of it, and
* every visitor downloaded it before they could reach the login form. Splitting
* per route means each page's code arrives when it is first opened.
*
* LoginPage stays eager: it is the first thing an unauthenticated visitor
* sees, so deferring it would only add a round trip before the form appears.
*/
const HomePage = lazy(() => import('./pages/HomePage').then((m) => ({ default: m.HomePage })));
const UserPage = lazy(() => import('./pages/UserPage').then((m) => ({ default: m.UserPage })));
const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })));
const StoresPage = lazy(() => import('./pages/StoresPage').then((m) => ({ default: m.StoresPage })));
const AnalyticsPage = lazy(() =>
import('./pages/AnalyticsPage').then((m) => ({ default: m.AnalyticsPage }))
);
const NutritionAnalyticsPage = lazy(() =>
import('./pages/NutritionAnalyticsPage').then((m) => ({ default: m.NutritionAnalyticsPage }))
);
function PageFallback({ label }) {
return (
<div className="min-h-screen w-full bg-slate-950 text-slate-400 flex items-center justify-center text-sm">
{label}
</div>
);
}
function ProtectedRoute({ children, allowedRoles }) {
const { user, role } = useAuth();
const { user, role, restoring } = useAuth();
// A token restored from sessionStorage is still being checked against
// /api/auth/me. Redirecting to /login now would bounce a valid session out
// on every refresh; rendering now would flash a dashboard about to be torn
// down if the token turns out to be expired.
if (restoring) {
return <PageFallback label="Restoring session..." />;
}
if (!user) {
return <Navigate to="/login" replace />;
@@ -34,7 +67,9 @@ export default function App() {
<ErrorBoundary>
<AuthProvider>
<BrowserRouter>
<Routes>
{/* Covers the network fetch for a lazily-loaded page chunk. */}
<Suspense fallback={<PageFallback label="Loading..." />}>
<Routes>
{/* Public Login Route */}
<Route path="/login" element={<LoginPage />} />
@@ -100,7 +135,8 @@ export default function App() {
{/* Catch-all redirect */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Routes>
</Suspense>
</BrowserRouter>
</AuthProvider>
</ErrorBoundary>

View File

@@ -1,5 +1,49 @@
const BASE = import.meta.env.VITE_API_BASE_URL || '';
// Where the access token lives. sessionStorage, not localStorage: the token is
// a bearer credential, and a tab-scoped store means closing the tab ends the
// session rather than leaving a working credential on disk.
export const TOKEN_STORAGE_KEY = 'app_access_token';
// Read at module load so a page refresh is already authenticated before
// AuthContext mounts and the first request goes out.
let authToken = (() => {
try {
return sessionStorage.getItem(TOKEN_STORAGE_KEY);
} catch {
return null;
}
})();
let onUnauthorized = null;
/** Called by AuthContext on login/logout. Pass null to clear. */
export function setAuthToken(token) {
authToken = token || null;
try {
if (token) sessionStorage.setItem(TOKEN_STORAGE_KEY, token);
else sessionStorage.removeItem(TOKEN_STORAGE_KEY);
} catch {
/* private browsing with storage disabled - the in-memory copy still works */
}
}
export function getAuthToken() {
return authToken;
}
/**
* Registered by AuthContext so an expired token anywhere in the app drops the
* session once, rather than leaving every panel to render its own 401 error.
*/
export function setUnauthorizedHandler(fn) {
onUnauthorized = fn;
}
function authHeaders() {
return authToken ? { Authorization: `Bearer ${authToken}` } : {};
}
class ApiError extends Error {
constructor(message, status) {
super(message);
@@ -11,7 +55,11 @@ async function request(path, options = {}) {
let res;
try {
res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
headers: {
'Content-Type': 'application/json',
...authHeaders(),
...(options.headers || {}),
},
...options,
});
} catch {
@@ -30,6 +78,9 @@ async function request(path, options = {}) {
} catch {
/* ignore parse errors, keep generic message */
}
// 401 means the token is missing, expired or invalid - the session is over.
// 403 is a live session lacking a permission, so it must NOT log you out.
if (res.status === 401 && onUnauthorized) onUnauthorized(detail);
throw new ApiError(detail, res.status);
}
@@ -58,6 +109,15 @@ const SHOW_ALL_PRODUCTS_LIMIT = 100000;
export const api = {
getHealth: () => request('/api/health'),
// --- Auth ---
login: (username, password) =>
request('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
}),
getMe: () => request('/api/auth/me'),
getRoles: () => request('/api/auth/roles'),
getBrands: () => request('/api/brands'),
getBrandCategories: (brand) => request(`/api/brands/${encodeURIComponent(brand)}/categories`),
@@ -159,11 +219,15 @@ export const api = {
getNutritionEnrichmentStatus: () => request('/api/admin/nutrition-intelligence/status'),
// --- Excel / CSV Upload API ---
// Raw fetch rather than request(): the browser must set its own multipart
// Content-Type with the boundary, so the JSON default in request() would
// corrupt the body. The auth header still has to be added by hand here.
uploadFile: async (tabType, file) => {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`${BASE}/api/upload/${encodeURIComponent(tabType)}`, {
method: 'POST',
headers: authHeaders(),
body: formData,
});
if (!res.ok) {
@@ -172,6 +236,7 @@ export const api = {
const body = await res.json();
detail = body.detail || JSON.stringify(body);
} catch {}
if (res.status === 401 && onUnauthorized) onUnauthorized(detail);
throw new ApiError(detail, res.status);
}
return res.json();

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Home, Store, BarChart3, HeartPulse, Wrench, ShoppingBag, AlertTriangle, UserCheck, ShieldCheck, LogOut, LayoutGrid } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { useAuth } from '../context/useAuth';
export function NavigationHeader({
title,

View File

@@ -1,4 +1,4 @@
import { Store, Sparkles, ImageOff, Leaf, ShieldCheck, Barcode, Images } from 'lucide-react';
import { Store, ImageOff, Leaf, ShieldCheck, Barcode, Images } from 'lucide-react';
import { useState, useMemo } from 'react';
const FOOD_CATEGORIES = [
@@ -27,8 +27,11 @@ function isFoodCategory(category) {
}
export function ProductCard({ product, onClick, similarity }) {
if (!product) return null;
// The `if (!product) return null` guard lives below the hooks, not above
// them. React identifies hooks by call order, so an early return before a
// hook changes how many run between renders - the card would crash if
// `product` ever went from set to null (a filter clearing the grid, a
// detail fetch returning nothing) rather than simply disappearing.
const {
product_name = 'Product',
brand = 'Brand',
@@ -41,15 +44,13 @@ export function ProductCard({ product, onClick, similarity }) {
nutrients = [],
fssai_license,
product_sku,
sku_source,
hsn_code,
final_selling_price,
selling_price,
barcode,
barcode_type
} = product;
barcode
} = product || {};
const displayHsnCode = hsn_code || product.hsnCode || null;
const displayHsnCode = hsn_code || product?.hsnCode || null;
const allImages = useMemo(() => {
const list = [];
@@ -66,6 +67,10 @@ export function ProductCard({ product, onClick, similarity }) {
const [imgError, setImgError] = useState(false);
const showNutrients = useMemo(() => isFoodCategory(category) && Array.isArray(nutrients) && nutrients.length > 0, [category, nutrients]);
// Every hook above has run; it is safe to bail out from here on.
if (!product) return null;
const imageCount = allImages.length;
const currentDisplayUrl = allImages[imgIndex];
const displayPrice = final_selling_price ?? selling_price;

View File

@@ -1,148 +1,119 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { api, setAuthToken, setUnauthorizedHandler, getAuthToken } from '../api/client';
import { AuthContext } from './useAuth';
const AuthContext = createContext(null);
const VALID_CREDENTIALS = {
admin: {
valid_usernames: ['admin'],
valid_passwords: ['admin12345', 'admin123'],
user: {
username: 'admin',
role: 'admin',
display_name: 'System Administrator',
email: 'admin@nutritionintel.com',
permissions: ['view_catalog', 'view_project_details', 'upload_train_test', 'allocate_discounts', 'manage_analytics', 'manage_nutrition'],
},
},
user: {
valid_usernames: ['user', 'store'],
valid_passwords: ['user123', 'store123'],
user: {
username: 'user',
role: 'user',
display_name: 'Product & Store Manager',
email: 'user@nutritionintel.com',
permissions: ['add_product', 'upload_batch_products', 'update_db_and_json', 'fetch_images', 'upload_store_inventory', 'view_store_analytics', 'view_nutrition_insights', 'optimize_profits'],
},
},
};
const USER_STORAGE_KEY = 'app_user_session';
/*
* The server is now the only authority on identity.
*
* An earlier version of this file kept a VALID_CREDENTIALS table and signed
* people in locally whenever /api/auth/login was unreachable or rejected them.
* That made sense while the backend had no real auth - the profile only chose
* which buttons to draw. It does not any more: every write endpoint checks the
* token, so a locally-minted session would render a full dashboard whose every
* action then failed with a 401. Login lives entirely on the server.
*/
export function AuthProvider({ children }) {
// Always initialize user to null so the System Authentication (Login Page) appears first by default
const [user, setUser] = useState(() => {
// Only trust a stored profile if its token is still present alongside it.
try {
const saved = sessionStorage.getItem('app_user_session');
if (saved) {
const parsed = JSON.parse(saved);
if (parsed && (parsed.role === 'store' || parsed.role === 'user')) {
parsed.role = 'user';
parsed.display_name = parsed.display_name || 'Product & Store Manager';
}
return parsed;
}
if (!getAuthToken()) return null;
const saved = sessionStorage.getItem(USER_STORAGE_KEY);
return saved ? JSON.parse(saved) : null;
} catch (e) {
console.warn('Could not restore auth session', e);
return null;
}
return null;
});
useEffect(() => {
// Clear persistent localStorage session so every fresh launch presents the System Authentication Login Page
localStorage.removeItem('app_user_session');
// True until a restored token has been checked against the server, so the
// app can hold off rendering rather than flashing a dashboard that is about
// to be torn down by a 401.
const [restoring, setRestoring] = useState(() => Boolean(getAuthToken()));
if (user) {
sessionStorage.setItem('app_user_session', JSON.stringify(user));
} else {
sessionStorage.removeItem('app_user_session');
}
}, [user]);
const login = async (username, password, role) => {
const un = (username || '').trim().toLowerCase();
const pwd = (password || '').trim().toLowerCase();
const targetRole = (role || '').trim().toLowerCase();
// 1. Try backend authentication endpoint first
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, role }),
});
if (res.ok) {
const data = await res.json();
if (data && data.role === 'store') data.role = 'user';
setUser(data);
return data;
}
} catch (e) {
console.warn('Backend login endpoint offline/fallback:', e);
}
// 2. Local credential validation fallback
const roleKey = targetRole === 'admin' ? 'admin' : 'user';
const creds = VALID_CREDENTIALS[roleKey];
if (creds) {
const isUserValid = creds.valid_usernames.includes(un) || un === roleKey;
const isPwdValid = creds.valid_passwords.includes(pwd);
if (isUserValid && isPwdValid) {
const userObj = creds.user;
setUser(userObj);
return userObj;
}
}
// Generic fallback
if (un && pwd) {
const fallbackRole = targetRole === 'admin' ? 'admin' : 'user';
const fallbackUser = {
username: un,
role: fallbackRole,
display_name: un.toUpperCase(),
email: `${un}@nutritionintel.com`,
permissions: VALID_CREDENTIALS[fallbackRole]?.user.permissions || [],
};
setUser(fallbackUser);
return fallbackUser;
}
throw new Error('Invalid username or password');
};
const logout = () => {
const clearSession = useCallback(() => {
setUser(null);
sessionStorage.removeItem('app_user_session');
localStorage.removeItem('app_user_session');
setAuthToken(null);
try {
sessionStorage.removeItem(USER_STORAGE_KEY);
localStorage.removeItem(USER_STORAGE_KEY);
} catch {
/* storage unavailable - in-memory state is already cleared */
}
}, []);
// Any 401 from anywhere in the app ends the session exactly once.
useEffect(() => {
setUnauthorizedHandler(() => clearSession());
return () => setUnauthorizedHandler(null);
}, [clearSession]);
// Validate a restored token before trusting the profile stored next to it.
// Without this, a token that expired overnight would let the app mount and
// then fail every request individually.
useEffect(() => {
if (!restoring) return;
let cancelled = false;
api.getMe()
.then((profile) => {
if (cancelled) return;
setUser(profile);
sessionStorage.setItem(USER_STORAGE_KEY, JSON.stringify(profile));
})
.catch(() => {
// 401 already cleared the session via the handler above; anything else
// (backend down mid-refresh) is also safest treated as signed out.
if (!cancelled) clearSession();
})
.finally(() => {
if (!cancelled) setRestoring(false);
});
return () => { cancelled = true; };
}, [restoring, clearSession]);
useEffect(() => {
// A previous version persisted to localStorage. Clear any leftover so an
// old profile can't outlive the tab it was created in.
localStorage.removeItem(USER_STORAGE_KEY);
}, []);
/**
* Sign in. The `role` argument the login form passes is ignored on purpose:
* the role now comes from the account the password belongs to, not from the
* tab the user happened to click. Picking "Admin" and typing the user
* password signs you in as a user.
*/
const login = async (username, password) => {
const res = await api.login(username, password);
setAuthToken(res.access_token);
setUser(res.user);
try {
sessionStorage.setItem(USER_STORAGE_KEY, JSON.stringify(res.user));
} catch {
/* storage unavailable - the session still works for this page load */
}
setRestoring(false);
return res.user;
};
const switchRole = (newRole) => {
const targetKey = newRole === 'admin' ? 'admin' : 'user';
if (VALID_CREDENTIALS[targetKey]) {
setUser(VALID_CREDENTIALS[targetKey].user);
} else if (user) {
setUser({
...user,
role: targetKey,
display_name: `${targetKey.toUpperCase()} User`,
permissions: VALID_CREDENTIALS[targetKey]?.user.permissions || [],
});
}
};
const logout = () => clearSession();
const hasPermission = useCallback(
(permission) =>
Boolean(user) && (user.role === 'admin' || (user.permissions || []).includes(permission)),
[user]
);
const effectiveRole = user?.role === 'store' ? 'user' : (user?.role || 'user');
return (
<AuthContext.Provider value={{ user, role: effectiveRole, login, logout, switchRole }}>
<AuthContext.Provider
value={{ user, role: effectiveRole, restoring, login, logout, hasPermission }}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

20
src/context/useAuth.js Normal file
View File

@@ -0,0 +1,20 @@
import { createContext, useContext } from 'react';
/*
* The context object and its hook live here, apart from AuthContext.jsx.
*
* That split is what React Fast Refresh needs: a module that exports a
* component alongside non-component values cannot be hot-swapped, so every
* edit to the provider would remount the whole tree and drop the session you
* were testing with. AuthContext.jsx now exports only <AuthProvider>, and the
* hook everything imports lives in this plain module.
*/
export const AuthContext = createContext(null);
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -1,6 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { PlayCircle, Loader2, CheckCircle2, XCircle, Clock, Wrench, Database, FileSpreadsheet, Percent, BarChart3, HeartPulse, Sparkles, Upload, Server } from 'lucide-react';
import { PlayCircle, Loader2, CheckCircle2, XCircle, Clock, Wrench, Database, FileSpreadsheet, Percent, Upload, Server } from 'lucide-react';
import { api } from '../api/client';
import { NavigationHeader } from '../components/NavigationHeader';
@@ -453,6 +452,16 @@ export function AdminPage() {
</button>
</form>
{/* handleSubmitIngestion has always captured this, but nothing
rendered it, so a rejected ingestion looked like nothing had
happened. It matters more now that the endpoint can answer 401
(session expired) or 403 (not an admin). */}
{submitError && (
<p className="rounded-xl border border-maroon-600/30 bg-maroon-100/60 p-2.5 text-xs font-medium text-maroon-600">
{submitError}
</p>
)}
{runningJob && (
<p className="text-xs text-amber-600">
A job for "{runningJob.brand}" is already in progress - please wait.

View File

@@ -1,5 +1,4 @@
import { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import {
BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis,
Tooltip, ResponsiveContainer, CartesianGrid, Legend,

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useAuth } from '../context/useAuth';
import { ShieldCheck, UserCheck, Lock, User, ArrowRight, Sparkles } from 'lucide-react';
export function LoginPage() {
@@ -29,13 +29,17 @@ export function LoginPage() {
setLoading(true);
try {
const user = await login(username, password, selectedRole);
const user = await login(username, password);
setLoading(false);
// Route by the role the SERVER assigned, not by the tab that was
// selected. The tab only labels which account you are signing in to.
if (user.role === 'admin') navigate('/admin');
else navigate('/user');
} catch (err) {
setLoading(false);
setError(`Login failed. Invalid username or password for ${selectedRole.toUpperCase()} account.`);
// Surface the server's own message: it distinguishes bad credentials
// from the lockout after repeated failures, which a generic string hides.
setError(err?.message || 'Login failed. Check your username and password.');
}
};

View File

@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import {
BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis,
Tooltip, ResponsiveContainer, CartesianGrid,

View File

@@ -1,5 +1,4 @@
import { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import {
Store as StoreIcon, TrendingUp, IndianRupee, PackageCheck, PackageX,
PackageSearch, BarChart3, Search, UploadCloud,

View File

@@ -5,9 +5,9 @@ import { ProductCard } from '../components/ProductCard';
import { ProductModal } from '../components/ProductModal';
import { ErrorBoundary } from '../components/ErrorBoundary';
import {
PlusCircle, Upload, CheckCircle2, Database, FileText, Sparkles,
RefreshCw, Search, FileSpreadsheet, Download, AlertCircle, ShoppingBag,
Store, LayoutGrid, Filter, Tag, ArrowRight
PlusCircle, Upload, CheckCircle2, Database, Sparkles,
RefreshCw, Search, FileSpreadsheet, Download, ShoppingBag,
Store, LayoutGrid, Filter, ArrowRight
} from 'lucide-react';
const STORES_SAMPLE = [