import { Preferences } from '@capacitor/preferences' import { API_URL } from '../Config' import { networkManager } from '../hooks/NetworkManager' import { recordApiFailure, recordServerVersionFromResponse, } from '../service/DiagnosticsSession' import { logout, RefreshToken } from './Fetcher' import { isOAuthExchangeInProgress } from './OAuthExchangeState' import { offlineDB } from './OfflineDB' import { clearAllTokens, isRefreshTokenExpired, saveTokens, } from './TokenStorage' const OAUTH_EXCHANGE_IN_PROGRESS = 'OAuth exchange in progress' class ApiClient { constructor() { this.customServerURL = `${API_URL}/api/v1` this.isRefreshing = false this.failedQueue = [] this.lastRefreshTime = 0 this.refreshCooldown = 3 * 1000 // 3 seconds in milliseconds } async init(force = false) { if (this.initPromise && !force) { return this.initPromise } if (this.initialized && !force) { return Promise.resolve() } this.initPromise = this._doInit().finally(() => { this.initPromise = null }) return this.initPromise } async _doInit() { const { value: serverURL } = await Preferences.get({ key: 'customServerUrl', }) this.customServerURL = `${serverURL || API_URL}/api/v1` this.initialized = true } getApiURL() { return this.customServerURL } async refreshToken() { // No session exists yet while an OAuth code exchange is in flight, so there // is nothing to refresh. Callers must treat this as a non-fatal failure // (see request()) rather than an expired session. if (isOAuthExchangeInProgress()) { return { success: false, error: OAUTH_EXCHANGE_IN_PROGRESS } } // Check if refresh token is expired BEFORE attempting refresh const refreshExpired = await isRefreshTokenExpired() if (refreshExpired) { console.log('Refresh token expired, forcing logout') await this.handleLogout() return { success: false, error: 'Refresh token expired' } } if (this.isRefreshing) { return { success: false, error: 'Already refreshing' } } // Check cooldown const now = Date.now() if (now - this.lastRefreshTime < this.refreshCooldown) { return { success: false, error: 'Refresh cooldown active' } } this.isRefreshing = true try { const refreshReq = await RefreshToken() if (refreshReq.ok) { const data = await refreshReq.json() const newToken = data.token || data.access_token // Save all tokens including rotated refresh token await saveTokens({ accessToken: newToken, accessTokenExpiry: data.expire || data.access_token_expiry, refreshToken: data.refresh_token, refreshTokenExpiry: data.refresh_token_expiry, }) // Update last refresh time this.lastRefreshTime = Date.now() return { success: true, token: newToken } } else { return { success: false, error: 'Refresh failed' } } } catch (error) { return { success: false, error: error.message } } finally { this.isRefreshing = false } } getToken() { return localStorage.getItem('token') } getHeaders(customHeaders = {}) { const headers = { ...customHeaders, } const token = this.getToken() if (token) { headers.Authorization = `Bearer ${token}` } const impersonateUserId = localStorage.getItem('impersonatedUserId') if (impersonateUserId) { headers['X-Impersonate-User-ID'] = impersonateUserId } return headers } // Process queued requests after refresh attempt processQueue(error, token = null) { this.failedQueue.forEach(({ reject, resolve }) => { if (error) { reject(error) } else { resolve(token) } }) this.failedQueue = [] } // Helper to avoid repeating cleanup code async handleLogout() { // Backstop for every forced-logout path: never tear down the session while // an OAuth exchange is running, or we clear the tokens it just saved and // reload the page out from under it. if (isOAuthExchangeInProgress()) { console.log('Skipping forced logout: OAuth exchange in progress') return } // An expired session on an invite link would otherwise drop the code on the // way to /login. Stash it first so sign-in returns to the join. try { const { pathname, search } = window.location if (pathname === '/circle/join') { const code = new URLSearchParams(search).get('code') if (code) { const { setPendingInvite } = await import('./PendingInvite') setPendingInvite(code) } } } catch (e) { console.error('Error preserving pending invite on logout', e) } await clearAllTokens() try { await offlineDB.clearAll() } catch (e) { console.error('Error clearing offline data on logout', e) } try { // Dynamic import sidesteps the ApiClient <-> ImageCache module cycle const { clearImageCache } = await import('./ImageCache') await clearImageCache() } catch (e) { console.error('Error clearing image cache on logout', e) } try { // Dynamic import sidesteps the ApiClient <-> WidgetService module cycle const { clearWidgetData } = await import('../service/WidgetService') await clearWidgetData() } catch (e) { console.error('Error clearing widget data on logout', e) } try { await logout() } catch (e) { console.error('Error during logout', e) } if (window.location.pathname !== '/login') window.location.href = '/login' // fire and forget } async request(endpoint, options = {}) { await this.init() const url = `${this.customServerURL}${endpoint}` // Abort after 10s so a dead/unreachable server doesn't hang the UI const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 10_000) const config = { // credentials: 'include', ...options, headers: this.getHeaders(options.headers), signal: options.signal ?? controller.signal, } try { // 1. Initial Request let response = await fetch(url, config) clearTimeout(timeoutId) // Passive diagnostics: learn the server build from whatever it already // answers, and keep the last few refusals for crash reports. recordServerVersionFromResponse(response) if (!response.ok) { recordApiFailure({ endpoint, method: config.method, status: response.status, }) } // 2. Check for 401 (Unauthorized) if (response.status === 401) { // Always queue this request first const queuedPromise = new Promise((resolve, reject) => { this.failedQueue.push({ resolve: async token => { if (!token) { reject(new Error('Token refresh failed')) return } try { const newHeaders = this.getHeaders(options?.headers) const retryConfig = { ...config, headers: newHeaders, } const retryResponse = await fetch(url, retryConfig) resolve(retryResponse) } catch (error) { reject(error) } }, reject, }) }) // If already refreshing, just return the queued promise if (this.isRefreshing) { console.log('Token refresh already in progress, queueing request') return queuedPromise } // Attempt to refresh the token const refreshResult = await this.refreshToken() if (refreshResult.success) { // Process queue with success - this will retry all queued requests this.processQueue(null, refreshResult.token) } else if (refreshResult.error === 'Refresh cooldown active') { // We're in cooldown - token was just refreshed, retry with current token console.log('Refresh cooldown - retrying with current token') const currentToken = this.getToken() if (currentToken) { this.processQueue(null, currentToken) } else { this.processQueue(new Error('No token available'), null) this.handleLogout() return null } } else if (refreshResult.error === OAUTH_EXCHANGE_IN_PROGRESS) { // Expected 401: the code exchange hasn't produced tokens yet. Fail // just this request — logging out here would wipe storage and hard // navigate to /login, aborting the exchange fetch mid-flight. queuedPromise.catch(() => {}) // not returned below; keep it handled this.processQueue(new Error(refreshResult.error), null) return response } else if (refreshResult.error === 'Already refreshing') { // This shouldn't happen since we check isRefreshing above, but handle it anyway console.log('Already refreshing - waiting for refresh to complete') return queuedPromise } else { // Actual refresh failure - logout this.processQueue(new Error(refreshResult.error), null) this.handleLogout() return null } // Return the queued promise for this request return queuedPromise } return response } catch (error) { clearTimeout(timeoutId) // fetch() threw = network-level failure or timeout — mark server // unreachable. Caller-initiated aborts (component unmount, query // cancellation) say nothing about server health, so skip those. const externalAbort = error?.name === 'AbortError' && options.signal?.aborted if (!externalAbort) { networkManager.setServerUnreachable() recordApiFailure({ endpoint, method: config.method, status: 'network' }) } console.error('Request failed', error) throw error } } async get(endpoint, options = {}) { return this.request(endpoint, { ...options, method: 'GET' }) } async post(endpoint, data, options = {}) { options.headers = options.headers || {} if (!options.headers['Content-Type']) { options.headers['Content-Type'] = 'application/json' } return this.request(endpoint, { ...options, method: 'POST', body: data ? data : undefined, }) } async put(endpoint, data, options = {}) { options.headers = options.headers || {} if (!options.headers['Content-Type']) { options.headers['Content-Type'] = 'application/json' } return this.request(endpoint, { ...options, method: 'PUT', body: data ? data : undefined, }) } async delete(endpoint, options = {}) { options.headers = options.headers || {} if (!options.headers['Content-Type']) { options.headers['Content-Type'] = 'application/json' } return this.request(endpoint, { ...options, method: 'DELETE' }) } async upload(endpoint, formData, options = {}) { const headers = options.headers || {} delete headers['Content-Type'] return this.request(endpoint, { ...options, method: 'POST', body: formData, headers, }) } getAssetURL(path) { return `${this.customServerURL}/assets/${path}` } } export const apiClient = new ApiClient()