Extended path-based token selection to include customer tokens: - /shop/* routes now use customer_token - Fallback order: admin_token || vendor_token || customer_token This ensures the correct token is used when logged into multiple portals (admin, vendor, customer) simultaneously. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
433 lines
13 KiB
JavaScript
433 lines
13 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
|
||
* - /vendor/* routes use vendor_token
|
||
* - /shop/* routes use customer_token
|
||
* - Other routes fall back to admin_token || vendor_token || customer_token
|
||
*/
|
||
getToken() {
|
||
const adminToken = localStorage.getItem('admin_token');
|
||
const vendorToken = localStorage.getItem('vendor_token');
|
||
const customerToken = localStorage.getItem('customer_token');
|
||
const currentPath = window.location.pathname;
|
||
|
||
let token;
|
||
let source;
|
||
|
||
// Path-based token selection
|
||
if (currentPath.startsWith('/vendor/') || currentPath.startsWith('/api/v1/vendor/')) {
|
||
token = vendorToken;
|
||
source = 'vendor (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 {
|
||
// Default fallback for other paths
|
||
token = adminToken || vendorToken || customerToken;
|
||
source = token === adminToken ? 'admin (fallback)' :
|
||
token === vendorToken ? 'vendor (fallback)' : 'customer (fallback)';
|
||
}
|
||
|
||
apiLog.debug('Getting token:', {
|
||
hasAdminToken: !!adminToken,
|
||
hasVendorToken: !!vendorToken,
|
||
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
|
||
let data;
|
||
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);
|
||
throw new Error(errorMessage);
|
||
}
|
||
|
||
// 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
|
||
});
|
||
throw new Error(errorMessage);
|
||
}
|
||
|
||
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)
|
||
});
|
||
}
|
||
|
||
/**
|
||
* DELETE request
|
||
*/
|
||
async delete(endpoint) {
|
||
apiLog.debug('DELETE request');
|
||
|
||
return this.request(endpoint, {
|
||
method: 'DELETE'
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Clear authentication tokens
|
||
*/
|
||
clearTokens() {
|
||
apiLog.info('Clearing all authentication tokens...');
|
||
|
||
const tokensBefore = {
|
||
admin_token: !!localStorage.getItem('admin_token'),
|
||
admin_user: !!localStorage.getItem('admin_user'),
|
||
vendor_token: !!localStorage.getItem('vendor_token'),
|
||
vendor_user: !!localStorage.getItem('vendor_user'),
|
||
token: !!localStorage.getItem('token')
|
||
};
|
||
apiLog.debug('Tokens before clear:', tokensBefore);
|
||
|
||
localStorage.removeItem('admin_token');
|
||
localStorage.removeItem('admin_user');
|
||
localStorage.removeItem('vendor_token');
|
||
localStorage.removeItem('vendor_user');
|
||
localStorage.removeItem('token');
|
||
|
||
const tokensAfter = {
|
||
admin_token: !!localStorage.getItem('admin_token'),
|
||
admin_user: !!localStorage.getItem('admin_user'),
|
||
vendor_token: !!localStorage.getItem('vendor_token'),
|
||
vendor_user: !!localStorage.getItem('vendor_user'),
|
||
token: !!localStorage.getItem('token')
|
||
};
|
||
apiLog.debug('Tokens after clear:', tokensAfter);
|
||
apiLog.info('All 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('vendor_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('vendor_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 && user.role === 'admin';
|
||
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 (response.user.role === 'admin') {
|
||
apiLog.info('Storing admin credentials');
|
||
localStorage.setItem('admin_token', response.access_token);
|
||
localStorage.setItem('admin_user', JSON.stringify(response.user));
|
||
} else {
|
||
apiLog.info('Storing vendor credentials');
|
||
localStorage.setItem('vendor_token', response.access_token);
|
||
localStorage.setItem('vendor_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'); |