Updated frontend
This commit is contained in:
58
src/App.jsx
58
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 (
|
||||
<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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
20
src/context/useAuth.js
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user