// 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.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 - Just clear tokens, DON'T redirect if (response.status === 401) { apiLog.warn('401 Unauthorized - Authentication failed'); apiLog.debug('Error details:', data); apiLog.info('Clearing authentication tokens'); this.clearTokens(); 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; 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(); 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(); 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.includes('/shop/') || currentPath.startsWith('/api/v1/shop/')) { apiLog.info('Clearing customer tokens only'); localStorage.removeItem('customer_token'); } 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'); } /** * 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');