The shared apiClient unconditionally called response.json() on every response, including 204 No Content (returned by DELETE endpoints). This caused "Invalid JSON response from server" errors on all delete operations across all modules and personas. Now returns null for 204 responses without attempting JSON parse. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
593 lines
19 KiB
JavaScript
593 lines
19 KiB
JavaScript
// 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 = {}) {
|
||
apiLog.debug('POST request data:', {
|
||
hasData: !!data,
|
||
dataKeys: Object.keys(data)
|
||
});
|
||
|
||
return this.request(endpoint, {
|
||
method: 'POST',
|
||
body: JSON.stringify(data)
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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');
|