/** * api-client.js * Unified API client for backend communication * Handles authentication, request/response formatting, error handling * Supports incognito mode with sessionStorage fallback (lazy-initialized) */ class ApiClient { constructor(baseURL = null) { // Use relative path /api which works for both production (proxied through nginx) // and development (proxied through Vite to backend on localhost:8002) this.baseURL = baseURL || '/api'; this.accessToken = null; this.refreshToken = null; this.loadTokensFromStorage(); this.setupAuthChangeListener(); } setupAuthChangeListener() { // Listen for auth changes (e.g., from demo-auth-init.js or other components) // and reload tokens from storage document.addEventListener('auth-change', () => { console.log('[ApiClient] Auth change detected, reloading tokens...'); this.loadTokensFromStorage(); }); window.addEventListener('auth-change', () => { console.log('[ApiClient] Auth change detected, reloading tokens...'); this.loadTokensFromStorage(); }); } loadTokensFromStorage() { // Always use localStorage directly for auth tokens // This ensures compatibility with demo-auth-init.js which also uses localStorage const stored = localStorage.getItem('auth_tokens'); if (stored) { try { const parsed = JSON.parse(stored); console.log('[ApiClient] Parsed tokens object:', { hasAccessToken: !!parsed.accessToken, hasRefreshToken: !!parsed.refreshToken }); this.accessToken = parsed.accessToken; this.refreshToken = parsed.refreshToken; console.log('[ApiClient] Tokens assigned - accessToken:', this.accessToken ? `${this.accessToken.substring(0, 20)}...` : 'null'); } catch (e) { console.error('[ApiClient] Failed to parse stored tokens:', e, 'stored value:', stored); } } else { console.warn('[ApiClient] No auth_tokens found in localStorage'); } } saveTokensToStorage() { try { // Always use localStorage directly for auth tokens localStorage.setItem('auth_tokens', JSON.stringify({ accessToken: this.accessToken, refreshToken: this.refreshToken })); } catch (e) { console.warn('[ApiClient] Failed to save tokens to storage:', e); } } clearTokens() { this.accessToken = null; this.refreshToken = null; localStorage.removeItem('auth_tokens'); } async request(method, endpoint, data = null) { // Always reload tokens from storage before making a request // This handles the case where tokens were set after API client initialization (e.g., auto-login) this.loadTokensFromStorage(); const url = `${this.baseURL}${endpoint}`; const options = { method, headers: { 'Content-Type': 'application/json' } }; if (this.accessToken) { options.headers['Authorization'] = `Bearer ${this.accessToken}`; console.log(`[ApiClient] ${method} ${endpoint}: Token present (${this.accessToken.substring(0, 20)}...)`); } else { console.warn(`[ApiClient] ${method} ${endpoint}: No token provided`); } if (data) { options.body = JSON.stringify(data); } try { const response = await fetch(url, options); const result = await response.json(); if (!response.ok) { // Handle token refresh if (response.status === 403 && result.code === 'FORBIDDEN' && this.refreshToken) { await this.refreshAccessToken(); return this.request(method, endpoint, data); } throw new Error(result.message || 'API request failed'); } // Handle both response formats: // 1. Standard wrapped response: { data: {...} } // 2. Direct array response: [...] // 3. Direct object response: {...} if (Array.isArray(result)) { return result; } return result.data || result; } catch (error) { console.error(`[ApiClient] ${method} ${endpoint}:`, error); throw error; } } async refreshAccessToken() { try { const response = await fetch(`${this.baseURL}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: this.refreshToken }) }); const result = await response.json(); if (result.status === 'success') { this.accessToken = result.data.tokens.accessToken; this.refreshToken = result.data.tokens.refreshToken; this.saveTokensToStorage(); this.dispatchAuthChangeEvent('token-refresh'); return true; } this.clearTokens(); this.dispatchAuthChangeEvent('token-expired'); return false; } catch (error) { console.error('[ApiClient] Token refresh failed:', error); this.clearTokens(); this.dispatchAuthChangeEvent('token-refresh-error'); return false; } } /** * Dispatch auth-change event to notify all listeners of authentication state changes */ dispatchAuthChangeEvent(reason = 'auth-change') { const event = new CustomEvent('auth-change', { detail: { reason, hasToken: !!this.accessToken }, bubbles: true, composed: true }); document.dispatchEvent(event); window.dispatchEvent(event); } // Auth endpoints async register(email, password, name) { const result = await this.request('POST', '/auth/register', { email, password, name }); this.accessToken = result.tokens.accessToken; this.refreshToken = result.tokens.refreshToken; this.saveTokensToStorage(); this.dispatchAuthChangeEvent('user-registered'); return result.user; } async login(email, password) { const result = await this.request('POST', '/auth/login', { email, password }); this.accessToken = result.tokens.accessToken; this.refreshToken = result.tokens.refreshToken; this.saveTokensToStorage(); this.dispatchAuthChangeEvent('user-logged-in'); return result.user; } async getMe() { const result = await this.request('GET', '/auth/me'); return result.user; } async logout() { this.clearTokens(); this.dispatchAuthChangeEvent('user-logged-out'); } // Project endpoints async getProjects() { const result = await this.request('GET', '/projects'); // Handle both response formats: // 1. Wrapped response: { data: { projects: [...] } } // 2. Direct array response: [...] if (Array.isArray(result)) { return result; } return result.projects || result.data?.projects || []; } async getProject(id) { const result = await this.request('GET', `/projects/${id}`); return result.project; } async createProject(data) { const result = await this.request('POST', '/projects', data); return result.project; } async updateProject(id, data) { const result = await this.request('PUT', `/projects/${id}`, data); return result.project; } async deleteProject(id) { await this.request('DELETE', `/projects/${id}`); } // Token endpoints async getTokens(projectId, category = null) { const url = `/tokens/project/${projectId}${category ? `?category=${category}` : ''}`; const result = await this.request('GET', url); return result.tokens; } async createToken(data) { const result = await this.request('POST', '/tokens', data); return result.token; } async updateToken(id, data) { const result = await this.request('PUT', `/tokens/${id}`, data); return result.token; } async deleteToken(id) { await this.request('DELETE', `/tokens/${id}`); } // Component endpoints async getComponents(projectId) { const result = await this.request('GET', `/components/project/${projectId}`); return result.components; } async createComponent(data) { const result = await this.request('POST', '/components', data); return result.component; } async updateComponent(id, data) { const result = await this.request('PUT', `/components/${id}`, data); return result.component; } async deleteComponent(id) { await this.request('DELETE', `/components/${id}`); } } export default new ApiClient();