Files
orion/static/shared/js/api-client.js
Samir Boulahtit a0ae638821
Some checks failed
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
fix(storefront-auth): apiClient redirects to login on 401 from /account/*
When the customer's JWT (30-min TTL via JWT_EXPIRE_MINUTES) expires in
localStorage, subsequent API calls from a customer-area page returned
401 → callers showed an unrelated error UI (loyalty dashboard rendered
the "join now" CTA because card came back null on the catch path).

Three changes in static/shared/js/api-client.js:

1. Path detection in getToken() + clearTokens() now recognises
   /account/* and /api/v1/storefront/* as customer-area routes (the
   only existing checks were for /shop/* which was never used in this
   codebase). Also clears customer_user alongside customer_token.

2. New redirectIfCustomerAreaUnauthorized() helper: on a /account/*
   page, sends the browser to /account/login?next=<current path>
   (with a guard to skip the redirect when already on the login page,
   avoiding loops). Called from all three 401 paths (request,
   requestFormData, getBlob).

3. login.html now honours the ?next= query param (in addition to the
   legacy ?return=), so the redirect lands the user back where their
   session expired.

Other personas (admin/store/merchant) are unaffected — the helper is
a no-op outside /account/*.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 21:28:34 +02:00

632 lines
21 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
* - /shop/* routes use customer_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/') ||
currentPath.includes('/shop/') ||
currentPath.startsWith('/api/v1/shop/')
) {
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();
this.redirectIfCustomerAreaUnauthorized();
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();
this.redirectIfCustomerAreaUnauthorized();
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();
this.redirectIfCustomerAreaUnauthorized();
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
* - /shop/* paths clear customer_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/') ||
currentPath.includes('/shop/') ||
currentPath.startsWith('/api/v1/shop/')
) {
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 customer-area page (/account/*) and gets 401,
* send them to the login page with a return URL so they land back
* here after re-authenticating.
*
* No-op for admin/store/merchant areas — those callers handle 401
* their own way. Also no-op if already on the login page (avoids
* a redirect loop).
*
* Returns true if a redirect was scheduled (caller should suppress
* its own error UI since the page is about to navigate away).
*/
redirectIfCustomerAreaUnauthorized() {
const path = window.location.pathname;
const onCustomerArea = path.startsWith('/account/') && path !== '/account/login';
if (!onCustomerArea) return false;
const next = encodeURIComponent(path + window.location.search);
apiLog.info('Redirecting to /account/login (session expired), next=' + next);
window.location.href = `/account/login?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');