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); this.status = status; } } async function request(path, options = {}) { let res; try { res = await fetch(`${BASE}${path}`, { headers: { 'Content-Type': 'application/json', ...authHeaders(), ...(options.headers || {}), }, ...options, }); } catch { throw new ApiError( `Could not reach the API at ${BASE || '(same origin)'}${path}. Is the backend running ` + `(uvicorn app.main:app) and reachable?`, 0 ); } if (!res.ok) { let detail = `Request failed (${res.status})`; try { const body = await res.json(); detail = body.detail || JSON.stringify(body); } 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); } if (res.status === 204) return null; return res.json(); } function qs(params = {}) { const usp = new URLSearchParams(); Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '' && v !== false) usp.set(k, v); }); const s = usp.toString(); return s ? `?${s}` : ''; } 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'), // --- Electronics catalogue (verified products only) --- getCategories: () => request('/api/elec/categories'), getBrands: (category) => request(`/api/elec/brands${qs({ category })}`), getProducts: (params) => request(`/api/elec/products${qs(params)}`), getProduct: (id) => request(`/api/elec/products/${id}`), getPriceHistory: (id) => request(`/api/elec/products/${id}/price-history`), getSites: () => request('/api/elec/sites'), // --- Admin --- startRun: (body) => request('/api/elec/admin/runs', { method: 'POST', body: JSON.stringify(body) }), getRunJob: (jobId) => request(`/api/elec/admin/runs/${jobId}`), getRuns: () => request('/api/elec/admin/runs'), probeSite: (domain) => request(`/api/elec/admin/sites/${encodeURIComponent(domain)}/probe`, { method: 'POST' }), getReviewQueue: () => request('/api/elec/admin/review'), reviewMatch: (listingId, approve) => request(`/api/elec/admin/review/${listingId}`, { method: 'POST', body: JSON.stringify({ approve }) }), }; export { ApiError };