/**
* 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;
}
}