Send the auth token on every API call; build against the API's own domain
Seven call sites used fetch() directly instead of the api client in src/api/client.js, so they sent no Authorization header. Four of them hit endpoints the backend guards with a permission, and every one of those returned 401 for any signed-in user: AdminPage /api/admin/training/upload-dataset (upload_train_test) AdminPage /api/admin/training/allocate-discounts (allocate_discounts) UserPage /api/user/products/add (add_product) UserPage /api/user/products/upload-file (upload_batch_products) The remaining three hit public GETs and worked, but bypassed VITE_API_BASE_URL and the central 401 handler just the same. Route all seven through the client and add the endpoints it was missing. Multipart uploads still need a raw fetch, because the browser has to set its own Content-Type to carry the boundary, so that is now one upload() helper that attaches the auth header and routes 401s to the logout handler rather than three copies that did neither. handleAllocateDiscounts only acted on res.ok, so a 401 or 403 left the panel looking idle with no indication that nothing had been allocated. It surfaces the error now. Take VITE_API_BASE_URL as a Docker build arg. The app is served from catalogue.nearle.ai.in and the API from mcp.catalogue.nearle.ai.in, and Vite inlines env vars at build time, so setting this on the running container does nothing - it has to reach npm run build. Defaults to empty, which keeps requests relative for the same-origin nginx proxy. Also revoke the object URL after the sample-CSV download. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
28
.env.example
28
.env.example
@@ -1,6 +1,24 @@
|
||||
# Leave this empty/unset to use Vite's dev proxy (see vite.config.js),
|
||||
# which forwards /api/* to http://localhost:8000 with no CORS setup needed.
|
||||
# Base URL of the FastAPI backend.
|
||||
#
|
||||
# Set it only if your backend runs somewhere else (a different host/port,
|
||||
# or in production where the frontend is served separately from the API).
|
||||
# VITE_API_BASE_URL=http://localhost:8000
|
||||
# Leave it unset for local development: Vite's dev proxy (see vite.config.js)
|
||||
# forwards /api/* to http://127.0.0.1:8000, so relative requests just work and
|
||||
# no CORS setup is needed.
|
||||
#
|
||||
# Set it whenever the API is served from a different origin than the app. In
|
||||
# production the React app is served from catalogue.nearle.ai.in and the API
|
||||
# lives on its own subdomain, so the deployed build needs:
|
||||
#
|
||||
# VITE_API_BASE_URL=https://mcp.catalogue.nearle.ai.in
|
||||
#
|
||||
# No trailing slash - src/api/client.js concatenates paths that already start
|
||||
# with "/api".
|
||||
#
|
||||
# IMPORTANT: Vite inlines this at BUILD time, not at run time. Setting it on
|
||||
# the running container does nothing; it has to be passed to `npm run build`.
|
||||
# The Dockerfile exposes it as a build arg for exactly this reason:
|
||||
#
|
||||
# docker build --build-arg VITE_API_BASE_URL=https://mcp.catalogue.nearle.ai.in .
|
||||
#
|
||||
# Whatever origin you set here must also appear in the backend's
|
||||
# API_CORS_ORIGINS, or the browser will block every response.
|
||||
# VITE_API_BASE_URL=https://mcp.catalogue.nearle.ai.in
|
||||
|
||||
19
Dockerfile
19
Dockerfile
@@ -6,9 +6,22 @@ COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
# VITE_API_BASE_URL is intentionally left unset: nginx.conf proxies /api/*
|
||||
# to the backend container, so the built app can keep using relative
|
||||
# same-origin requests (see src/api/client.js).
|
||||
|
||||
# Which origin the built app sends its API calls to.
|
||||
#
|
||||
# Vite inlines env vars at build time, so this MUST be a build arg - setting it
|
||||
# on the running container has no effect, the value is already baked into the
|
||||
# JS bundle.
|
||||
#
|
||||
# docker build --build-arg VITE_API_BASE_URL=https://mcp.catalogue.nearle.ai.in .
|
||||
#
|
||||
# Left empty by default, which keeps requests relative and same-origin so the
|
||||
# nginx /api/* proxy below serves them. Set it when the API is on its own
|
||||
# domain - and add this app's origin to the backend's API_CORS_ORIGINS, or the
|
||||
# browser will block the responses.
|
||||
ARG VITE_API_BASE_URL=""
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ---- Serve stage ----
|
||||
|
||||
@@ -88,6 +88,49 @@ async function request(path, options = {}) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a file as multipart/form-data.
|
||||
*
|
||||
* Deliberately not routed through request(): the browser must set its own
|
||||
* Content-Type so it can append the multipart boundary, and request()'s JSON
|
||||
* default would corrupt the body. Everything else request() does still has to
|
||||
* happen by hand here - most importantly the Authorization header, whose
|
||||
* absence is exactly what used to make every upload in the app 401.
|
||||
*/
|
||||
async function upload(path, file, { fieldName = 'file' } = {}) {
|
||||
const formData = new FormData();
|
||||
formData.append(fieldName, file);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
`Could not reach the API at ${BASE || '(same origin)'}${path}. Is the backend running ` +
|
||||
`and reachable?`,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = `Upload failed (${res.status})`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
detail = body.detail || JSON.stringify(body);
|
||||
} catch {
|
||||
/* ignore parse errors, keep generic message */
|
||||
}
|
||||
if (res.status === 401 && onUnauthorized) onUnauthorized(detail);
|
||||
throw new ApiError(detail, res.status);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function qs(params = {}) {
|
||||
const usp = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
@@ -219,30 +262,33 @@ 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) {
|
||||
let detail = `Upload failed (${res.status})`;
|
||||
try {
|
||||
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();
|
||||
},
|
||||
uploadFile: (tabType, file) => upload(`/api/upload/${encodeURIComponent(tabType)}`, file),
|
||||
|
||||
getTemplateUrl: (tabType) => `${BASE}/api/upload/template/${encodeURIComponent(tabType)}`,
|
||||
|
||||
// --- Admin: training datasets & discount allocation ---
|
||||
// Guarded by `upload_train_test` / `allocate_discounts` on the server, so
|
||||
// these must carry the bearer token - see app/api/routers/admin_train.py.
|
||||
getProjectDetails: () => request('/api/admin/training/project-details'),
|
||||
|
||||
uploadTrainingDataset: (file) => upload('/api/admin/training/upload-dataset', file),
|
||||
|
||||
allocateDiscounts: ({ rules, store_id = null }) =>
|
||||
request('/api/admin/training/allocate-discounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ store_id, rules }),
|
||||
}),
|
||||
|
||||
// --- User workspace: single + batch product entry ---
|
||||
// Guarded by `add_product` / `upload_batch_products` - see
|
||||
// app/api/routers/user_products.py.
|
||||
addUserProduct: (payload) =>
|
||||
request('/api/user/products/add', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
uploadUserProductsFile: (file) => upload('/api/user/products/upload-file', file),
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
|
||||
@@ -37,14 +37,11 @@ export function AdminPage() {
|
||||
const [discountPct, setDiscountPct] = useState(25);
|
||||
const [allocating, setAllocating] = useState(false);
|
||||
const [allocationResult, setAllocationResult] = useState(null);
|
||||
const [allocationError, setAllocationError] = useState(null);
|
||||
|
||||
const fetchProjectDetails = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/training/project-details');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setProjectDetails(data);
|
||||
}
|
||||
setProjectDetails(await api.getProjectDetails());
|
||||
} catch (e) {
|
||||
console.warn('Could not fetch project details', e);
|
||||
} finally {
|
||||
@@ -95,54 +92,32 @@ export function AdminPage() {
|
||||
setUploadError(null);
|
||||
setDatasetResult(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', uploadFile);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/training/upload-dataset', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json();
|
||||
throw new Error(errData.detail || 'Upload failed');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setDatasetResult(data);
|
||||
setUploadingDataset(false);
|
||||
setDatasetResult(await api.uploadTrainingDataset(uploadFile));
|
||||
} catch (err) {
|
||||
setUploadingDataset(false);
|
||||
setUploadError(err.message);
|
||||
} finally {
|
||||
setUploadingDataset(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAllocateDiscounts = async (e) => {
|
||||
e.preventDefault();
|
||||
setAllocating(true);
|
||||
setAllocationError(null);
|
||||
setAllocationResult(null);
|
||||
try {
|
||||
const payload = {
|
||||
rules: [
|
||||
{ min_stock: Number(minStock), max_stock: Number(maxStock), discount_pct: Number(discountPct) },
|
||||
{ min_stock: 21, max_stock: 50, discount_pct: 15.0 },
|
||||
{ min_stock: 51, max_stock: 100, discount_pct: 10.0 },
|
||||
{ min_stock: 101, max_stock: 10000, discount_pct: 5.0 },
|
||||
],
|
||||
};
|
||||
|
||||
const res = await fetch('/api/admin/training/allocate-discounts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAllocationResult(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to allocate discounts', e);
|
||||
const rules = [
|
||||
{ min_stock: Number(minStock), max_stock: Number(maxStock), discount_pct: Number(discountPct) },
|
||||
{ min_stock: 21, max_stock: 50, discount_pct: 15.0 },
|
||||
{ min_stock: 51, max_stock: 100, discount_pct: 10.0 },
|
||||
{ min_stock: 101, max_stock: 10000, discount_pct: 5.0 },
|
||||
];
|
||||
setAllocationResult(await api.allocateDiscounts({ rules }));
|
||||
} catch (err) {
|
||||
// Previously this only acted on res.ok, so a 401 or 403 left the panel
|
||||
// looking idle and gave no clue that nothing had been allocated.
|
||||
setAllocationError(err.message);
|
||||
} finally {
|
||||
setAllocating(false);
|
||||
}
|
||||
@@ -380,6 +355,12 @@ export function AdminPage() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{allocationError && (
|
||||
<div className="rounded-xl bg-rose-500/10 border border-rose-500/30 p-3.5 text-xs text-rose-700">
|
||||
{allocationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allocationResult && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-xs text-slate-600">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
import { ProductCard } from '../components/ProductCard';
|
||||
import { ProductModal } from '../components/ProductModal';
|
||||
@@ -165,18 +166,13 @@ export function UserPage() {
|
||||
const fetchStoreProductsCards = async (storeId) => {
|
||||
setLoadingCards(true);
|
||||
try {
|
||||
const res = await fetch(`/api/stores/${storeId}/products?limit=100`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const itemsList = Array.isArray(data)
|
||||
? data
|
||||
: Array.isArray(data?.products)
|
||||
? data.products
|
||||
: [];
|
||||
setStoreProductsRaw(itemsList);
|
||||
} else {
|
||||
setStoreProductsRaw([]);
|
||||
}
|
||||
const data = await api.getStoreProducts(storeId, { limit: 100 });
|
||||
const itemsList = Array.isArray(data)
|
||||
? data
|
||||
: Array.isArray(data?.products)
|
||||
? data.products
|
||||
: [];
|
||||
setStoreProductsRaw(itemsList);
|
||||
} catch (e) {
|
||||
console.warn('Failed to load store products for cards:', e);
|
||||
setStoreProductsRaw([]);
|
||||
@@ -193,18 +189,13 @@ export function UserPage() {
|
||||
if (!bName) return;
|
||||
setLoadingBrand(true);
|
||||
try {
|
||||
const res = await fetch(`/api/brands/${encodeURIComponent(bName)}/products?limit=50`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const itemsList = Array.isArray(data)
|
||||
? data
|
||||
: Array.isArray(data?.products)
|
||||
? data.products
|
||||
: [];
|
||||
setBrandProducts(itemsList);
|
||||
} else {
|
||||
setBrandProducts([]);
|
||||
}
|
||||
const data = await api.getBrandProducts(bName, { limit: 50 });
|
||||
const itemsList = Array.isArray(data)
|
||||
? data
|
||||
: Array.isArray(data?.products)
|
||||
? data.products
|
||||
: [];
|
||||
setBrandProducts(itemsList);
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch brand products', e);
|
||||
setBrandProducts([]);
|
||||
@@ -250,18 +241,7 @@ export function UserPage() {
|
||||
image_url: imageUrl || undefined,
|
||||
};
|
||||
|
||||
const res = await fetch('/api/user/products/add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json();
|
||||
throw new Error(errData.detail || 'Failed to add product');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const data = await api.addUserProduct(payload);
|
||||
setSingleResult(data);
|
||||
setSubmittingSingle(false);
|
||||
|
||||
@@ -307,21 +287,8 @@ export function UserPage() {
|
||||
setBatchResult(null);
|
||||
setUploadingBatch(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', batchFile);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/user/products/upload-file', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json();
|
||||
throw new Error(errData.detail || 'Batch file upload failed');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const data = await api.uploadUserProductsFile(batchFile);
|
||||
setBatchResult(data);
|
||||
setUploadingBatch(false);
|
||||
|
||||
@@ -367,6 +334,7 @@ export function UserPage() {
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Convert Store products into rich Card objects with 100% type-safe conversions and title-specific images
|
||||
|
||||
Reference in New Issue
Block a user