Files
orion/static/shared/js/api-client.js
Samir Boulahtit 4423f0a5ed
All checks were successful
CI / ruff (push) Successful in 18s
CI / docs (push) Successful in 56s
CI / pytest (push) Successful in 2h48m6s
CI / validate (push) Successful in 33s
CI / dependency-scanning (push) Successful in 37s
CI / deploy (push) Successful in 1m14s
fix(api-client): generalize 401 redirect from /account/* to all 4 personas
Yesterday's redirectIfCustomerAreaUnauthorized was scoped to /account/*
only. Admin, store, and merchant pages still hit the same UX gap when
an AJAX call returned 401 on token expiry: apiClient cleared tokens
and threw, leaving the page in a broken state with whatever generic
error UI the caller had wired up — no redirect, no `?next=` round-trip,
identical bug to the customer flicker we fixed in `b04b36a2` /
`6564f138`.

Rename and dispatch by path:
  - /account/*     (not /account/login)         → /account/login?next=…
  - /admin/*       (not /admin/login)           → /admin/login?next=…
  - /merchants/*   (not /merchants/login)       → /merchants/login?next=…
  - /store/{code}/* (not /store/{code}/login)   → /store/{code}/login?next=…
  - anything else                               → return false (caller throws)

Store paths include the per-store code, so the helper does a small regex
to extract `{code}` from the current pathname and builds the persona's
login URL with the right prefix.

All three 401 handlers in apiClient (request, requestFormData, getBlob)
already wrap this with the `return new Promise(() => {})` pattern from
6564f138, so the caller's `.finally(() => loading = false)` doesn't fire
before navigation completes — kills the wrong-state UI flash on every
persona, not just customer.

Login pages updated to honour `?next=` precedence over the existing
`*_last_visited_page` localStorage fallback, with persona-specific
safety checks (must start with /admin/, /merchants/, /store/{code}/
respectively; must not be a login or onboarding URL). The store login
also normalises the basePath because the store-code path prefix can
flip between subdomain (/store/{code}/...) and dev/path-based
(/platforms/{platform}/store/{code}/...) modes.

Customer login already honoured `?next=` from bbb481aa; left unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 13:02:59 +02:00

666 lines
22 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// static/shared/js/api-client.js
// noqa: js-001 - Core infrastructure, uses its own logger (apiLog)
/**
* API Client for Multi-Tenant Ecommerce Platform
*
* Provides utilities for:
* - Making authenticated API calls
* - Token management
* - Error handling
* - Request/response interceptors
*/
// Log levels: 0 = None, 1 = Error, 2 = Warning, 3 = Info, 4 = Debug
const API_LOG_LEVEL = 3; // Set to 3 for production, 4 for full debugging
const apiLog = {
error: (...args) => API_LOG_LEVEL >= 1 && console.error('❌ [API ERROR]', ...args),
warn: (...args) => API_LOG_LEVEL >= 2 && console.warn('⚠️ [API WARN]', ...args),
info: (...args) => API_LOG_LEVEL >= 3 && console.info(' [API INFO]', ...args),
debug: (...args) => API_LOG_LEVEL >= 4 && console.log('🔍 [API DEBUG]', ...args)
};
const API_BASE_URL = '/api/v1';
/**
* API Client Class
*/
class APIClient {
constructor(baseURL = API_BASE_URL) {
this.baseURL = baseURL;
apiLog.info('API Client initialized with base URL:', baseURL);
}
/**
* Get stored authentication token
*
* Uses path-based detection to return the correct token:
* - /admin/* routes use admin_token
* - /store/* routes use store_token
* - /account/* or /api/v1/storefront/* routes use customer_token
* - /merchants/* routes use merchant_token
* - Other routes fall back to admin_token || store_token || customer_token
*/
getToken() {
const adminToken = localStorage.getItem('admin_token');
const storeToken = localStorage.getItem('store_token');
const customerToken = localStorage.getItem('customer_token');
const currentPath = window.location.pathname;
let token;
let source;
// Path-based token selection
if (currentPath.startsWith('/store/') || currentPath.startsWith('/api/v1/store/')) {
token = storeToken;
source = 'store (path-based)';
} else if (currentPath.startsWith('/admin/') || currentPath.startsWith('/api/v1/admin/')) {
token = adminToken;
source = 'admin (path-based)';
} else if (
currentPath.startsWith('/account/') ||
currentPath.startsWith('/api/v1/storefront/')
) {
token = customerToken;
source = 'customer (path-based)';
} else if (currentPath.startsWith('/merchants/') || currentPath.startsWith('/api/v1/merchants/')) {
token = localStorage.getItem('merchant_token');
source = 'merchant (path-based)';
} else {
// Default fallback for other paths
token = adminToken || storeToken || customerToken;
source = token === adminToken ? 'admin (fallback)' :
token === storeToken ? 'store (fallback)' : 'customer (fallback)';
}
apiLog.debug('Getting token:', {
hasAdminToken: !!adminToken,
hasStoreToken: !!storeToken,
hasCustomerToken: !!customerToken,
currentPath,
source,
usingToken: token ? source : 'none'
});
return token;
}
/**
* Get default headers with authentication
*/
getHeaders(additionalHeaders = {}) {
const headers = {
'Content-Type': 'application/json',
...additionalHeaders
};
const token = this.getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
apiLog.debug('Authorization header added');
} else {
apiLog.debug('No token available, request will be unauthenticated');
}
return headers;
}
/**
* Make API request
*/
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
const method = options.method || 'GET';
apiLog.info(`${method} ${url}`);
apiLog.debug('Request options:', {
method,
hasBody: !!options.body,
customHeaders: Object.keys(options.headers || {})
});
const config = {
...options,
headers: this.getHeaders(options.headers)
};
try {
const startTime = Date.now();
const response = await fetch(url, config);
const duration = Date.now() - startTime;
apiLog.info(`Response: ${response.status} ${response.statusText} (${duration}ms)`);
// Parse response (handle 204 No Content gracefully)
let data;
if (response.status === 204) {
data = null;
} else {
try {
data = await response.json();
apiLog.debug('Response data received:', {
hasData: !!data,
dataType: typeof data,
keys: data ? Object.keys(data) : []
});
} catch (parseError) {
apiLog.error('Failed to parse JSON response:', parseError);
throw new Error('Invalid JSON response from server');
}
}
// Handle 401 Unauthorized
if (response.status === 401) {
apiLog.warn('401 Unauthorized - Authentication failed');
apiLog.debug('Error details:', data);
apiLog.info('Clearing authentication tokens');
this.clearTokens();
if (this.redirectIfUnauthorized()) {
// Page is navigating away to /account/login. Return a
// never-resolving promise so the caller's await never
// returns and any `.finally(() => loading = false)`
// never fires — prevents a wrong-state UI flash
// between the redirect being scheduled and the browser
// actually navigating away.
return new Promise(() => {});
}
const errorMessage = data.message || data.detail || 'Unauthorized - please login again';
apiLog.error('Throwing authentication error:', errorMessage);
const authError = new Error(errorMessage);
authError.status = response.status;
authError.errorCode = data.error_code;
throw authError;
}
// Handle non-OK responses
if (!response.ok) {
const errorMessage = data.detail || data.message || `Request failed with status ${response.status}`;
apiLog.error('Request failed:', {
status: response.status,
message: errorMessage,
errorCode: data.error_code
});
const apiError = new Error(errorMessage);
apiError.status = response.status;
apiError.errorCode = data.error_code;
// Propagate the details payload so callers can localise the
// toast (e.g. "cooldown_ends" / "cooldown_minutes" for
// POINTS_COOLDOWN / STAMP_COOLDOWN).
apiError.details = data.details || null;
throw apiError;
}
apiLog.info('Request completed successfully');
return data;
} catch (error) {
// Log error details
if (error.name === 'TypeError' && error.message.includes('fetch')) {
apiLog.error('Network error - Failed to connect to server');
} else {
apiLog.error('Request error:', error.message);
}
apiLog.debug('Full error:', error);
throw error;
}
}
/**
* GET request
*/
async get(endpoint, params = {}) {
const queryString = new URLSearchParams(params).toString();
const url = queryString ? `${endpoint}?${queryString}` : endpoint;
apiLog.debug('GET request params:', params);
return this.request(url, {
method: 'GET'
});
}
/**
* POST request
*/
async post(endpoint, data = null) {
apiLog.debug('POST request data:', {
hasData: !!data,
dataKeys: data ? Object.keys(data) : []
});
var opts = { method: 'POST' };
if (data != null) opts.body = JSON.stringify(data);
return this.request(endpoint, opts);
}
/**
* PUT request
*/
async put(endpoint, data = {}) {
apiLog.debug('PUT request data:', {
hasData: !!data,
dataKeys: Object.keys(data)
});
return this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
});
}
/**
* PATCH request
*/
async patch(endpoint, data = {}) {
apiLog.debug('PATCH request data:', {
hasData: !!data,
dataKeys: Object.keys(data)
});
return this.request(endpoint, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
/**
* DELETE request
*/
async delete(endpoint) {
apiLog.debug('DELETE request');
return this.request(endpoint, {
method: 'DELETE'
});
}
/**
* POST with FormData (for file uploads)
* Does not set Content-Type header - browser sets it with boundary
*/
async postFormData(endpoint, formData) {
const url = `${this.baseURL}${endpoint}`;
apiLog.info(`POST (FormData) ${url}`);
const token = this.getToken();
const headers = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
try {
const startTime = Date.now();
const response = await fetch(url, {
method: 'POST',
headers,
body: formData
});
const duration = Date.now() - startTime;
apiLog.info(`Response: ${response.status} ${response.statusText} (${duration}ms)`);
let data;
try {
data = await response.json();
} catch (parseError) {
apiLog.error('Failed to parse JSON response:', parseError);
throw new Error('Invalid JSON response from server');
}
if (response.status === 401) {
apiLog.warn('401 Unauthorized - Authentication failed');
this.clearTokens();
if (this.redirectIfUnauthorized()) {
return new Promise(() => {});
}
throw new Error(data.message || data.detail || 'Unauthorized');
}
if (!response.ok) {
throw new Error(data.detail || data.message || `Request failed with status ${response.status}`);
}
return data;
} catch (error) {
apiLog.error('FormData request error:', error.message);
throw error;
}
}
/**
* GET request that returns a Blob (for file downloads)
*/
async getBlob(endpoint) {
const url = `${this.baseURL}${endpoint}`;
apiLog.info(`GET (Blob) ${url}`);
const token = this.getToken();
const headers = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
try {
const startTime = Date.now();
const response = await fetch(url, {
method: 'GET',
headers
});
const duration = Date.now() - startTime;
apiLog.info(`Response: ${response.status} ${response.statusText} (${duration}ms)`);
if (response.status === 401) {
apiLog.warn('401 Unauthorized - Authentication failed');
this.clearTokens();
if (this.redirectIfUnauthorized()) {
return new Promise(() => {});
}
throw new Error('Unauthorized');
}
if (!response.ok) {
let errorMessage = `Request failed with status ${response.status}`;
try {
const errorData = await response.json();
errorMessage = errorData.detail || errorData.message || errorMessage;
} catch (e) {
// Response wasn't JSON
}
throw new Error(errorMessage);
}
return response.blob();
} catch (error) {
apiLog.error('Blob request error:', error.message);
throw error;
}
}
/**
* Clear authentication tokens for current context only.
*
* Uses path-based detection to clear only the relevant token:
* - /admin/* paths clear admin_token
* - /store/* paths clear store_token
* - /account/* or /api/v1/storefront/* paths clear customer_token
* - /merchants/* paths clear merchant_token
* - Other paths clear all tokens (fallback)
*/
clearTokens() {
const currentPath = window.location.pathname;
apiLog.info('Clearing authentication tokens for path:', currentPath);
const tokensBefore = {
admin_token: !!localStorage.getItem('admin_token'),
admin_user: !!localStorage.getItem('admin_user'),
store_token: !!localStorage.getItem('store_token'),
store_user: !!localStorage.getItem('store_user'),
customer_token: !!localStorage.getItem('customer_token'),
token: !!localStorage.getItem('token')
};
apiLog.debug('Tokens before clear:', tokensBefore);
// Context-aware token clearing to prevent cross-context interference
if (currentPath.startsWith('/admin/') || currentPath.startsWith('/api/v1/admin/')) {
apiLog.info('Clearing admin tokens only');
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
} else if (currentPath.startsWith('/store/') || currentPath.startsWith('/api/v1/store/')) {
apiLog.info('Clearing store tokens only');
localStorage.removeItem('store_token');
localStorage.removeItem('store_user');
localStorage.removeItem('currentUser');
localStorage.removeItem('storeCode');
} else if (
currentPath.startsWith('/account/') ||
currentPath.startsWith('/api/v1/storefront/')
) {
apiLog.info('Clearing customer tokens only');
localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user');
} else if (currentPath.startsWith('/merchants/') || currentPath.startsWith('/api/v1/merchants/')) {
apiLog.info('Clearing merchant tokens only');
localStorage.removeItem('merchant_token');
} else {
// Fallback: clear all tokens for unknown paths
apiLog.info('Unknown path context, clearing all tokens');
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_user');
localStorage.removeItem('store_token');
localStorage.removeItem('store_user');
localStorage.removeItem('customer_token');
localStorage.removeItem('currentUser');
localStorage.removeItem('storeCode');
localStorage.removeItem('token');
}
const tokensAfter = {
admin_token: !!localStorage.getItem('admin_token'),
admin_user: !!localStorage.getItem('admin_user'),
store_token: !!localStorage.getItem('store_token'),
store_user: !!localStorage.getItem('store_user'),
customer_token: !!localStorage.getItem('customer_token'),
token: !!localStorage.getItem('token')
};
apiLog.debug('Tokens after clear:', tokensAfter);
apiLog.info('Context-specific tokens cleared');
}
/**
* If the user is on a protected page (customer / admin / store /
* merchant area) and gets a 401, send them to the persona's login
* page with a `?next=` return URL so they land back here after
* re-authenticating.
*
* Dispatches by path:
* /account/* (not /account/login) → /account/login?next=...
* /admin/* (not /admin/login) → /admin/login?next=...
* /store/{code}/* (not /store/{code}/login) → /store/{code}/login?next=...
* /merchants/* (not /merchants/login) → /merchants/login?next=...
*
* Returns true if a redirect was scheduled (caller should return a
* never-resolving promise so its `.finally(() => loading = false)`
* doesn't fire mid-redirect and flash a wrong UI state).
* Returns false for unknown paths or login pages — caller throws as
* usual.
*/
redirectIfUnauthorized() {
const path = window.location.pathname;
let loginUrl = null;
if (path.startsWith('/account/') && path !== '/account/login') {
loginUrl = '/account/login';
} else if (path.startsWith('/admin/') && path !== '/admin/login') {
loginUrl = '/admin/login';
} else if (path.startsWith('/merchants/') && path !== '/merchants/login') {
loginUrl = '/merchants/login';
} else if (path.startsWith('/store/')) {
// Store paths include the store code: /store/{code}/<rest>.
// Login URL is /store/{code}/login. Skip if already on it.
const m = path.match(/^\/store\/([^/]+)\//);
if (m) {
const candidate = `/store/${m[1]}/login`;
if (path !== candidate) loginUrl = candidate;
}
}
if (!loginUrl) return false;
const next = encodeURIComponent(path + window.location.search);
apiLog.info(`Redirecting to ${loginUrl} (session expired), next=${next}`);
window.location.href = `${loginUrl}?next=${next}`;
return true;
}
/**
* Handle unauthorized access
* DEPRECATED - Now just clears tokens, doesn't redirect
* Server handles redirects via exception handler
*/
handleUnauthorized() {
apiLog.warn('handleUnauthorized called (DEPRECATED) - use clearTokens instead');
this.clearTokens();
}
}
// Create global API client instance
const apiClient = new APIClient();
apiLog.info('Global API client created');
/**
* Authentication helpers
*/
const Auth = {
/**
* Check if user is authenticated
*/
isAuthenticated() {
const token = localStorage.getItem('admin_token') || localStorage.getItem('store_token');
const isAuth = !!token;
apiLog.debug('Auth check:', isAuth ? 'authenticated' : 'not authenticated');
return isAuth;
},
/**
* Get current user
*/
getCurrentUser() {
const userStr = localStorage.getItem('admin_user') || localStorage.getItem('store_user');
if (!userStr) {
apiLog.debug('No user found in storage');
return null;
}
try {
const user = JSON.parse(userStr);
apiLog.debug('Current user:', {
username: user.username,
role: user.role,
id: user.id
});
return user;
} catch (e) {
apiLog.error('Failed to parse user data:', e);
return null;
}
},
/**
* Check if user is admin
*/
isAdmin() {
const user = this.getCurrentUser();
const isAdmin = user && ['super_admin', 'platform_admin'].includes(user.role);
apiLog.debug('Admin check:', isAdmin ? 'is admin' : 'not admin');
return isAdmin;
},
/**
* Login
*/
async login(username, password) {
apiLog.info('Auth.login called');
const response = await apiClient.post('/auth/login', {
username,
password
});
// Store token and user
if (['super_admin', 'platform_admin'].includes(response.user.role)) {
apiLog.info('Storing admin credentials');
localStorage.setItem('admin_token', response.access_token);
localStorage.setItem('admin_user', JSON.stringify(response.user));
} else {
apiLog.info('Storing store credentials');
localStorage.setItem('store_token', response.access_token);
localStorage.setItem('store_user', JSON.stringify(response.user));
}
return response;
},
/**
* Logout
*/
logout() {
apiLog.info('Auth.logout called');
apiClient.clearTokens();
apiLog.info('User logged out');
}
};
// Add animation styles
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
`;
document.head.appendChild(style);
// Export for use in other scripts
if (typeof module !== 'undefined' && module.exports) {
module.exports = { APIClient, apiClient, Auth, Utils };
}
// Table scroll detection helper
function initTableScrollDetection() {
const observer = new MutationObserver(() => {
const tables = document.querySelectorAll('.table-responsive');
tables.forEach(table => {
if (!table.hasAttribute('data-scroll-initialized')) {
table.setAttribute('data-scroll-initialized', 'true');
table.addEventListener('scroll', function() {
if (this.scrollLeft > 0) {
this.classList.add('is-scrolled');
} else {
this.classList.remove('is-scrolled');
}
});
// Check initial state
if (table.scrollLeft > 0) {
table.classList.add('is-scrolled');
}
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTableScrollDetection);
} else {
initTableScrollDetection();
}
apiLog.info('API Client module loaded');