diff --git a/README.md b/README.md index 3c8382d..e055b66 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/nginx.conf b/nginx.conf index 0b43a0c..98f33ad 100644 --- a/nginx.conf +++ b/nginx.conf @@ -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; diff --git a/src/App.jsx b/src/App.jsx index 6601a3f..c0091fb 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 ( +
+ {label} +
+ ); +} 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 ; + } if (!user) { return ; @@ -34,7 +67,9 @@ export default function App() { - + {/* Covers the network fetch for a lazily-loaded page chunk. */} + }> + {/* Public Login Route */} } /> @@ -100,7 +135,8 @@ export default function App() { {/* Catch-all redirect */} } /> - + + diff --git a/src/api/client.js b/src/api/client.js index 2c94faa..222238b 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -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(); diff --git a/src/components/NavigationHeader.jsx b/src/components/NavigationHeader.jsx index 778e627..6ac6c37 100644 --- a/src/components/NavigationHeader.jsx +++ b/src/components/NavigationHeader.jsx @@ -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, diff --git a/src/components/ProductCard.jsx b/src/components/ProductCard.jsx index ef21c13..7a83786 100644 --- a/src/components/ProductCard.jsx +++ b/src/components/ProductCard.jsx @@ -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; diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx index 7f83a40..b8e1807 100644 --- a/src/context/AuthContext.jsx +++ b/src/context/AuthContext.jsx @@ -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 ( - + {children} ); } - -export function useAuth() { - const context = useContext(AuthContext); - if (!context) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return context; -} diff --git a/src/context/useAuth.js b/src/context/useAuth.js new file mode 100644 index 0000000..aa18451 --- /dev/null +++ b/src/context/useAuth.js @@ -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 , 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; +} diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx index 1694f70..fd3bd60 100644 --- a/src/pages/AdminPage.jsx +++ b/src/pages/AdminPage.jsx @@ -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() { + {/* 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 && ( +

+ {submitError} +

+ )} + {runningJob && (

A job for "{runningJob.brand}" is already in progress - please wait. diff --git a/src/pages/AnalyticsPage.jsx b/src/pages/AnalyticsPage.jsx index 41f556d..1b5d65f 100644 --- a/src/pages/AnalyticsPage.jsx +++ b/src/pages/AnalyticsPage.jsx @@ -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, diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx index aaa1700..e9caef3 100644 --- a/src/pages/LoginPage.jsx +++ b/src/pages/LoginPage.jsx @@ -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.'); } }; diff --git a/src/pages/NutritionAnalyticsPage.jsx b/src/pages/NutritionAnalyticsPage.jsx index 5ddc2a0..aa7ab4e 100644 --- a/src/pages/NutritionAnalyticsPage.jsx +++ b/src/pages/NutritionAnalyticsPage.jsx @@ -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, diff --git a/src/pages/StoresPage.jsx b/src/pages/StoresPage.jsx index dfc1bf5..713f1c3 100644 --- a/src/pages/StoresPage.jsx +++ b/src/pages/StoresPage.jsx @@ -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, diff --git a/src/pages/UserPage.jsx b/src/pages/UserPage.jsx index d8d3d74..8ef990c 100644 --- a/src/pages/UserPage.jsx +++ b/src/pages/UserPage.jsx @@ -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 = [