/** * Main JavaScript for Delphi Consulting Group Database System */ // Global application state const app = { token: localStorage.getItem('auth_token'), refreshToken: localStorage.getItem('refresh_token'), user: null, initialized: false, refreshTimerId: null, refreshInProgress: false }; // Initialize application document.addEventListener('DOMContentLoaded', function() { try { setupGlobalErrorHandlers(); } catch (_) {} initializeApp(); try { initializeBatchProgressUI(); } catch (_) {} }); // Theme Management (centralized) function applyTheme(theme) { const html = document.documentElement; const isDark = theme === 'dark'; html.classList.toggle('dark', isDark); html.setAttribute('data-theme', isDark ? 'dark' : 'light'); } function toggleTheme() { const html = document.documentElement; const nextTheme = html.classList.contains('dark') ? 'light' : 'dark'; applyTheme(nextTheme); try { localStorage.setItem('theme-preference', nextTheme); } catch (_) {} saveThemePreference(nextTheme); } function initializeTheme() { // Check for saved theme preference let savedTheme = null; try { savedTheme = localStorage.getItem('theme-preference'); } catch (_) {} const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; const theme = savedTheme || (prefersDark ? 'dark' : 'light'); applyTheme(theme); // Load from server if available loadUserThemePreference(); // Listen for OS theme changes if no explicit preference is set attachSystemThemeListener(); } async function saveThemePreference(theme) { const token = localStorage.getItem('auth_token'); if (!token || isLoginPage()) return; try { await window.http.wrappedFetch('/api/auth/theme-preference', { method: 'POST', body: JSON.stringify({ theme_preference: theme }) }); } catch (error) { // Silently fail - theme preference is not critical } } function attachSystemThemeListener() { if (!('matchMedia' in window)) return; const media = window.matchMedia('(prefers-color-scheme: dark)'); const handleChange = (e) => { let savedTheme = null; try { savedTheme = localStorage.getItem('theme-preference'); } catch (_) {} if (!savedTheme || savedTheme === 'system') { applyTheme(e.matches ? 'dark' : 'light'); } }; if (typeof media.addEventListener === 'function') { media.addEventListener('change', handleChange); } else if (typeof media.addListener === 'function') { media.addListener(handleChange); // Safari fallback } } async function loadUserThemePreference() { const token = localStorage.getItem('auth_token'); if (!token || isLoginPage()) return; try { const response = await window.http.wrappedFetch('/api/auth/me'); if (response.ok) { const user = await response.json(); if (user.theme_preference) { applyTheme(user.theme_preference); try { localStorage.setItem('theme-preference', user.theme_preference); } catch (_) {} } } } catch (error) { // Silently fail - theme preference is not critical } } // Apply theme immediately on script load try { initializeTheme(); } catch (_) {} async function initializeApp() { // Initialize keyboard shortcuts if (window.keyboardShortcuts) { window.keyboardShortcuts.initialize(); } // Tooltips/popovers handled via native browser features as needed // Add form validation classes initializeFormValidation(); // Initialize API helpers setupAPIHelpers(); // Initialize authentication manager (centralized) if (typeof initializeAuthManager === 'function') { initializeAuthManager(); } app.initialized = true; } // Live Batch Progress (Admin Overview) function initializeBatchProgressUI() { const listEl = document.getElementById('batchProgressList'); const emptyEl = document.getElementById('batchProgressEmpty'); const refreshBtn = document.getElementById('refreshBatchesBtn'); if (!listEl || !emptyEl) return; const subscriptions = new Map(); function percent(progress) { if (!progress || !progress.total_files) return 0; const done = Number(progress.processed_files || 0); const total = Number(progress.total_files || 0); return Math.max(0, Math.min(100, Math.round((done / total) * 100))); } function renderRow(progress) { const pid = progress.batch_id; const pct = percent(progress); const status = String(progress.status || '').toUpperCase(); const current = progress.current_file || ''; const success = progress.successful_files || 0; const failed = progress.failed_files || 0; const total = progress.total_files || 0; return ( `
${pid} ${status}
${success}/${total} ✓ • ${failed} ✕
${pct}% ${current ? 'Current: '+current : ''}
` ); } async function fetchActiveBatches() { const resp = await window.http.wrappedFetch('/api/billing/statements/batch-list'); if (!resp.ok) return []; return await resp.json(); } function updateEmptyState() { const hasRows = listEl.children.length > 0; emptyEl.style.display = hasRows ? 'none' : ''; } function upsertRow(data) { const pid = data && data.batch_id ? data.batch_id : null; if (!pid) return; let row = listEl.querySelector(`[data-batch="${pid}"]`); const html = renderRow(data); if (row) { row.outerHTML = html; } else { const container = document.createElement('div'); container.innerHTML = html; listEl.prepend(container.firstChild); } updateEmptyState(); } async function cancelBatch(batchId) { try { const resp = await window.http.wrappedFetch(`/api/billing/statements/batch-progress/${encodeURIComponent(batchId)}`, { method: 'DELETE' }); if (!resp.ok) { throw await window.http.toError(resp, 'Failed to cancel batch'); } // Let stream update the row; no-op here } catch (e) { console.warn('Cancel failed', e); try { alert(window.http.formatAlert(e, 'Cancel failed')); } catch (_) {} } } function attachRowHandlers() { listEl.addEventListener('click', function(ev){ const btn = ev.target.closest('[data-action="cancel"]'); if (!btn) return; const row = ev.target.closest('[data-batch]'); if (!row) return; const pid = row.getAttribute('data-batch'); cancelBatch(pid); }); } async function subscribeTo(pid) { if (!window.progress || typeof window.progress.subscribe !== 'function') return; if (subscriptions.has(pid)) return; const unsub = window.progress.subscribe(pid, function(progress){ if (!progress) return; upsertRow(progress); const status = String(progress.status || '').toUpperCase(); if (status === 'COMPLETED' || status === 'FAILED' || status === 'CANCELLED') { // Auto-unsubscribe once terminal const fn = subscriptions.get(pid); if (fn) { try { fn(); } catch (_) {} } subscriptions.delete(pid); } }, function(err){ // Non-fatal; polling fallback is handled inside subscribe() // Silently handle stream errors as polling fallback is available }); subscriptions.set(pid, unsub); } async function refresh() { const batches = await fetchActiveBatches(); if (!Array.isArray(batches)) return; if (batches.length === 0) updateEmptyState(); for (const pid of batches) { subscribeTo(pid); } } if (refreshBtn) { refreshBtn.addEventListener('click', function(){ refresh(); }); } attachRowHandlers(); refresh(); } // Form validation function initializeFormValidation() { // Native validation handling const forms = document.querySelectorAll('form'); forms.forEach(form => { form.addEventListener('submit', function(event) { if (!form.checkValidity()) { event.preventDefault(); event.stopPropagation(); form.reportValidity(); } }); }); // Real-time validation for required fields (Tailwind styles) const requiredFields = document.querySelectorAll('input[required], select[required], textarea[required]'); requiredFields.forEach(field => { field.addEventListener('blur', function() { validateField(field); }); }); } function validateField(field) { const isValid = field.checkValidity(); field.setAttribute('aria-invalid', String(!isValid)); field.classList.toggle('border-danger-500', !isValid); } // API helpers function setupAPIHelpers() { // Set up default headers for all API calls window.apiHeaders = { 'Accept': 'application/json' }; // Start proactive refresh scheduling when a token is present if (app.token) { scheduleTokenRefresh(); } } // API utility functions async function apiCall(url, options = {}) { // Proactively refresh token if close to expiry if (app.token) { const msLeft = getMsUntilExpiry(app.token); if (msLeft !== null && msLeft < 5 * 60 * 1000) { // < 5 minutes try { await refreshToken(); } catch (e) { logout(); throw new Error('Authentication required'); } } } const config = { headers: { ...window.apiHeaders, ...options.headers }, ...options }; try { let response = await window.http.wrappedFetch(url, config); const updateCorrelationFromResponse = (resp) => { try { const cid = resp && resp.headers ? resp.headers.get('X-Correlation-ID') : null; if (cid) { window.app.lastCorrelationId = cid; } return cid; } catch (_) { return null; } }; let lastCorrelationId = updateCorrelationFromResponse(response); if (response.status === 401 && app.token) { // Attempt one refresh then retry once try { await refreshToken(); const retryConfig = { headers: { ...window.apiHeaders, ...options.headers }, ...options }; response = await window.http.wrappedFetch(url, retryConfig); lastCorrelationId = updateCorrelationFromResponse(response); } catch (_) { // fall through to logout below } } if (response.status === 401) { logout(); throw new Error('Authentication required'); } if (!response.ok) { const errorData = await response.json().catch(() => ({ detail: 'Request failed' })); const err = new Error(errorData.detail || `HTTP ${response.status}`); err.status = response.status; err.correlationId = lastCorrelationId || null; throw err; } return await response.json(); } catch (error) { console.error('API call failed:', error); showNotification(`Error: ${error.message}`, 'error'); throw error; } } async function apiGet(url) { return apiCall(url, { method: 'GET' }); } async function apiPost(url, data) { return apiCall(url, { method: 'POST', body: JSON.stringify(data) }); } async function apiPut(url, data) { return apiCall(url, { method: 'PUT', body: JSON.stringify(data) }); } async function apiDelete(url) { return apiCall(url, { method: 'DELETE' }); } // Authentication functions function setAuthTokens(accessToken, newRefreshToken = null) { if (accessToken) { app.token = accessToken; localStorage.setItem('auth_token', accessToken); } if (newRefreshToken) { app.refreshToken = newRefreshToken; localStorage.setItem('refresh_token', newRefreshToken); } // Reschedule refresh on token update if (accessToken) { scheduleTokenRefresh(); } } function setAuthToken(token) { // Backwards compatibility setAuthTokens(token, null); } // Page helpers function isLoginPage() { const path = window.location.pathname; return path === '/login'; } // Verify the current access token by hitting /api/auth/me async function checkTokenValidity() { const token = localStorage.getItem('auth_token'); if (!token) return false; try { const response = await window.http.wrappedFetch('/api/auth/me'); if (!response.ok) { // Invalid token return false; } // Cache user for later UI updates try { app.user = await response.json(); } catch (_) {} return true; } catch (error) { console.error('Error checking token validity:', error); return false; } } // Try to refresh token if refresh token is present; fallback to validity check+logout async function refreshTokenIfNeeded() { const refreshTokenValue = localStorage.getItem('refresh_token'); if (!refreshTokenValue) return; app.refreshToken = refreshTokenValue; try { await refreshToken(); console.log('Token refreshed successfully'); } catch (error) { const stillValid = await checkTokenValidity(); if (!stillValid) { await logout('Session expired or invalid token'); } } } // Update UI elements that are permission/user dependent async function checkUserPermissions() { const token = localStorage.getItem('auth_token'); if (!token || isLoginPage()) return; try { const response = await window.http.wrappedFetch('/api/auth/me'); if (response.ok) { const user = await response.json(); app.user = user; if (user.is_admin) { const adminItem = document.getElementById('admin-menu-item'); const adminDivider = document.getElementById('admin-menu-divider'); if (adminItem) adminItem.classList.remove('hidden'); if (adminDivider) adminDivider.classList.remove('hidden'); const importDesktop = document.getElementById('nav-import-desktop'); const importMobile = document.getElementById('nav-import-mobile'); if (importDesktop) importDesktop.classList.remove('hidden'); if (importMobile) importMobile.classList.remove('hidden'); const flexibleDesktop = document.getElementById('nav-flexible-desktop'); const flexibleMobile = document.getElementById('nav-flexible-mobile'); if (flexibleDesktop) flexibleDesktop.classList.remove('hidden'); if (flexibleMobile) flexibleMobile.classList.remove('hidden'); } const userDropdownName = document.querySelector('#userDropdown button span'); if (user.full_name && userDropdownName) { userDropdownName.textContent = user.full_name; } } } catch (error) { console.error('Error checking user permissions:', error); } } // Inactivity monitoring & session extension UI async function getInactivityWarningMinutes() { const token = localStorage.getItem('auth_token'); if (!token) return 240; try { const resp = await window.http.wrappedFetch('/api/settings/inactivity_warning_minutes'); if (!resp.ok) return 240; const data = await resp.json(); if (typeof data.minutes === 'number') return data.minutes; const parsed = parseInt(data.setting_value || data.minutes, 10); return Number.isFinite(parsed) ? parsed : 240; } catch (_) { return 240; } } function showSessionExtendedNotification() { if (window.alerts && typeof window.alerts.success === 'function') { window.alerts.success('Your session has been refreshed successfully.', { title: 'Session Extended', duration: 3000 }); return; } // Fallback const notification = document.createElement('div'); notification.className = 'fixed top-4 right-4 bg-green-100 border-l-4 border-green-500 text-green-700 p-4 rounded-lg shadow-lg z-50 max-w-sm'; notification.innerHTML = `

Session Extended

Your session has been refreshed successfully.

`; document.body.appendChild(notification); setTimeout(() => notification.remove(), 3000); } function setupActivityMonitoring() { let lastActivity = Date.now(); let warningShown = false; let inactivityWarningMinutes = 240; // default 4 hours const inactivityGraceMinutes = 5; // auto-logout after warning + 5 minutes // Fetch setting (best effort) getInactivityWarningMinutes().then(minutes => { if (Number.isFinite(minutes) && minutes > 0) { inactivityWarningMinutes = minutes; } }).catch(() => {}); function hideInactivityWarning() { const el = document.getElementById('inactivity-warning'); if (el && el.remove) el.remove(); } function extendSession() { refreshTokenIfNeeded(); hideInactivityWarning(); showSessionExtendedNotification(); } function showInactivityWarning() { hideInactivityWarning(); const msg = `You've been inactive. Your session may expire due to inactivity.`; if (window.alerts && typeof window.alerts.show === 'function') { window.alerts.show(msg, 'warning', { title: 'Session Warning', html: false, duration: 0, dismissible: true, id: 'inactivity-warning', actions: [ { label: 'Stay Logged In', classes: 'bg-warning-600 hover:bg-warning-700 text-white text-xs px-3 py-1 rounded', onClick: () => extendSession(), autoClose: true }, { label: 'Dismiss', classes: 'bg-neutral-200 hover:bg-neutral-300 text-neutral-800 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:text-neutral-200 text-xs px-3 py-1 rounded', onClick: () => hideInactivityWarning(), autoClose: true } ] }); } else { alert('Session Warning: ' + msg); } // Auto-hide after 2 minutes setTimeout(() => hideInactivityWarning(), 2 * 60 * 1000); } // Track user activity const activityEvents = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart']; activityEvents.forEach(event => { document.addEventListener(event, () => { lastActivity = Date.now(); warningShown = false; const el = document.getElementById('inactivity-warning'); if (el && el.remove) el.remove(); }); }); // Check every 5 minutes for inactivity setInterval(() => { const now = Date.now(); const warningMs = inactivityWarningMinutes * 60 * 1000; const logoutMs = (inactivityWarningMinutes + inactivityGraceMinutes) * 60 * 1000; const timeSinceActivity = now - lastActivity; if (timeSinceActivity > warningMs && !warningShown) { showInactivityWarning(); warningShown = true; } if (timeSinceActivity > logoutMs) { logout('Session expired due to inactivity'); } }, 5 * 60 * 1000); } // Central initializer for auth async function initializeAuthManager() { const token = localStorage.getItem('auth_token'); // If on the login page, do nothing if (isLoginPage()) return; if (token) { // Align in-memory/app state with stored tokens app.token = token; const storedRefresh = localStorage.getItem('refresh_token'); if (storedRefresh) app.refreshToken = storedRefresh; // Verify token and schedule refresh checkTokenValidity(); scheduleTokenRefresh(); // Start inactivity monitoring setupActivityMonitoring(); // Update UI according to user permissions checkUserPermissions(); } else { // No token and not on login page - redirect to login window.location.href = '/login'; } } async function logout(reason = null) { // Best-effort revoke refresh token server-side const rtoken = localStorage.getItem('refresh_token'); try { if (rtoken) { await window.http.wrappedFetch('/api/auth/logout', { method: 'POST', body: JSON.stringify({ refresh_token: rtoken }) }); } } catch (_) { // ignore } if (app.refreshTimerId) { clearTimeout(app.refreshTimerId); app.refreshTimerId = null; } app.refreshInProgress = false; app.token = null; app.user = null; localStorage.removeItem('auth_token'); localStorage.removeItem('refresh_token'); // Authorization header is injected by fetch wrapper; nothing to clean here if (reason) { try { sessionStorage.setItem('logout_reason', reason); } catch (_) {} } window.location.href = '/login'; } // Notification system (delegates to shared alerts utility) function showNotification(message, type = 'info', duration = 5000) { if (window.alerts && typeof window.alerts.show === 'function') { return window.alerts.show(message, type, { duration }); } // Fallback if alerts module not yet loaded return alert(String(message)); } // Loading states function showLoading(element, text = 'Loading...') { const spinner = ``; const originalContent = element.innerHTML; element.innerHTML = `${spinner}${text}`; element.disabled = true; element.dataset.originalContent = originalContent; } function hideLoading(element) { if (element.dataset.originalContent) { element.innerHTML = element.dataset.originalContent; delete element.dataset.originalContent; } element.disabled = false; } // Table helpers function initializeDataTable(tableId, options = {}) { const table = document.getElementById(tableId); if (!table) return null; // Add sorting capability const headers = table.querySelectorAll('th[data-sort]'); headers.forEach(header => { header.classList.add('sortable-header'); header.classList.add('cursor-pointer'); header.classList.add('select-none'); header.addEventListener('click', () => sortTable(table, header)); }); // Add row selection if enabled if (options.selectable) { addRowSelection(table); } return table; } function sortTable(table, header) { const columnIndex = Array.from(header.parentNode.children).indexOf(header); const sortType = header.dataset.sort; const tbody = table.querySelector('tbody'); const rows = Array.from(tbody.querySelectorAll('tr')); const isAscending = !header.classList.contains('sort-asc'); // Remove sort classes from all headers table.querySelectorAll('th').forEach(th => { th.classList.remove('sort-asc', 'sort-desc'); const indicator = th.querySelector('.sort-indicator'); if (indicator) indicator.remove(); }); // Add sort class to current header header.classList.add(isAscending ? 'sort-asc' : 'sort-desc'); const indicator = document.createElement('span'); indicator.className = 'sort-indicator ml-1 text-neutral-400'; indicator.textContent = isAscending ? '▲' : '▼'; header.appendChild(indicator); rows.sort((a, b) => { const aValue = a.children[columnIndex].textContent.trim(); const bValue = b.children[columnIndex].textContent.trim(); let comparison = 0; if (sortType === 'number') { comparison = parseFloat(aValue) - parseFloat(bValue); } else if (sortType === 'date') { comparison = new Date(aValue) - new Date(bValue); } else { comparison = aValue.localeCompare(bValue); } return isAscending ? comparison : -comparison; }); // Re-append sorted rows rows.forEach(row => tbody.appendChild(row)); } function addRowSelection(table) { const tbody = table.querySelector('tbody'); tbody.addEventListener('click', function(e) { const row = e.target.closest('tr'); if (row && e.target.type !== 'checkbox') { const isSelected = row.classList.toggle('bg-neutral-100'); row.classList.toggle('dark:bg-neutral-700', isSelected); // Trigger custom event const event = new CustomEvent('rowSelect', { detail: { row, selected: isSelected } }); table.dispatchEvent(event); } }); } // Form helpers function serializeForm(form) { const formData = new FormData(form); const data = {}; for (let [key, value] of formData.entries()) { // Handle multiple values (checkboxes, multi-select) if (data.hasOwnProperty(key)) { if (!Array.isArray(data[key])) { data[key] = [data[key]]; } data[key].push(value); } else { data[key] = value; } } return data; } function populateForm(form, data) { Object.keys(data).forEach(key => { const field = form.querySelector(`[name="${key}"]`); if (field) { if (field.type === 'checkbox' || field.type === 'radio') { field.checked = data[key]; } else { field.value = data[key]; } } }); } // Search functionality function initializeSearch(searchInput, resultsContainer, searchFunction) { let searchTimeout; searchInput.addEventListener('input', function() { clearTimeout(searchTimeout); const query = this.value.trim(); if (query.length < 2) { resultsContainer.innerHTML = ''; return; } searchTimeout = setTimeout(async () => { try { showLoading(resultsContainer, 'Searching...'); const results = await searchFunction(query); displaySearchResults(resultsContainer, results, query); } catch (error) { resultsContainer.innerHTML = '

Search failed

'; } }, 300); }); } function displaySearchResults(container, results, query = '') { if (!results || results.length === 0) { container.innerHTML = '

No results found

'; return; } const tokens = (window.highlightUtils && typeof window.highlightUtils.buildTokens === 'function') ? window.highlightUtils.buildTokens(query) : []; const resultsHtmlRaw = results.map(result => `
${window.highlightUtils ? window.highlightUtils.highlight(result.title || '', tokens) : (result.title || '')} ${window.highlightUtils ? window.highlightUtils.highlight(result.description || '', tokens) : (result.description || '')}
${result.type}
`).join(''); if (window.setSafeHTML) { window.setSafeHTML(container, resultsHtmlRaw); } else { container.innerHTML = resultsHtmlRaw; } } // Utility functions function formatCurrency(amount) { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount); } function formatDate(date) { return new Intl.DateTimeFormat('en-US').format(new Date(date)); } function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } function throttle(func, limit) { let inThrottle; return function() { const args = arguments; const context = this; if (!inThrottle) { func.apply(context, args); inThrottle = true; setTimeout(() => inThrottle = false, limit); } } } // Export global functions window.app = app; window.showNotification = showNotification; window.apiGet = apiGet; window.apiPost = apiPost; window.apiPut = apiPut; window.apiDelete = apiDelete; window.formatCurrency = formatCurrency; window.formatDate = formatDate; window.toggleTheme = toggleTheme; window.initializeTheme = initializeTheme; window.saveThemePreference = saveThemePreference; window.loadUserThemePreference = loadUserThemePreference; // Global error handling function setupGlobalErrorHandlers() { // Handle unexpected runtime errors window.addEventListener('error', function(event) { try { const payload = { message: event && event.message ? String(event.message) : 'Unhandled error', action: 'window.onerror', stack: event && event.error && event.error.stack ? String(event.error.stack) : null, url: (event && event.filename) ? String(event.filename) : String(window.location.href), line: event && typeof event.lineno === 'number' ? event.lineno : null, column: event && typeof event.colno === 'number' ? event.colno : null, user_agent: navigator.userAgent, extra: { page: window.location.pathname, lastCorrelationId: (window.app && window.app.lastCorrelationId) || null } }; postClientError(payload); } catch (_) {} }); // Handle unhandled promise rejections window.addEventListener('unhandledrejection', function(event) { try { const reason = event && event.reason ? event.reason : null; const payload = { message: reason && reason.message ? String(reason.message) : 'Unhandled promise rejection', action: 'window.unhandledrejection', stack: reason && reason.stack ? String(reason.stack) : null, url: String(window.location.href), user_agent: navigator.userAgent, extra: { page: window.location.pathname, reasonType: reason ? (reason.name || typeof reason) : null, status: reason && typeof reason.status === 'number' ? reason.status : null, correlationId: reason && reason.correlationId ? reason.correlationId : ((window.app && window.app.lastCorrelationId) || null) } }; postClientError(payload); } catch (_) {} }); } async function postClientError(payload) { try { // Fire-and-forget; do not block UI window.http.wrappedFetch('/api/documents/client-error', { method: 'POST', body: JSON.stringify(payload) }).catch(() => {}); } catch (_) { // no-op } } // JWT utilities and refresh handling function decodeJwt(token) { try { const payload = token.split('.')[1]; const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); return JSON.parse(json); } catch (e) { return null; } } function getMsUntilExpiry(token) { const decoded = decodeJwt(token); if (!decoded || !decoded.exp) return null; const expiryMs = decoded.exp * 1000; return expiryMs - Date.now(); } function scheduleTokenRefresh() { if (!app.token) return; // Clear existing timer if (app.refreshTimerId) { clearTimeout(app.refreshTimerId); app.refreshTimerId = null; } const msLeft = getMsUntilExpiry(app.token); if (msLeft === null) return; // Refresh 5 minutes before expiry, but not sooner than 30 seconds from now const leadTimeMs = 5 * 60 * 1000; const refreshInMs = Math.max(msLeft - leadTimeMs, 30 * 1000); app.refreshTimerId = setTimeout(() => { // Fire and forget; apiCall path also guards per-request refreshToken().catch(() => { logout(); }); }, refreshInMs); } async function refreshToken() { if (!app.refreshToken) throw new Error('No refresh token available'); if (app.refreshInProgress) return; // Avoid parallel refreshes app.refreshInProgress = true; try { const response = await window.http.wrappedFetch('/api/auth/refresh', { method: 'POST', body: JSON.stringify({ refresh_token: app.refreshToken }) }); if (!response.ok) { throw new Error('Refresh failed'); } const data = await response.json(); if (!data || !data.access_token) { throw new Error('Invalid refresh response'); } // Handle refresh token rotation if provided setAuthTokens(data.access_token, data.refresh_token || null); } finally { app.refreshInProgress = false; } }