1028 lines
35 KiB
JavaScript
1028 lines
35 KiB
JavaScript
/**
|
|
* 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 (
|
|
`<div class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-3" data-batch="${pid}">
|
|
<div class="flex items-center justify-between gap-3 mb-2">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-xs px-2 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">${pid}</span>
|
|
<span class="text-xs font-medium ${status === 'COMPLETED' ? 'text-green-600 dark:text-green-400' : status === 'FAILED' ? 'text-red-600 dark:text-red-400' : status === 'CANCELLED' ? 'text-neutral-500' : 'text-amber-600 dark:text-amber-400'}">${status}</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-xs text-neutral-500 dark:text-neutral-400">${success}/${total} ✓ • ${failed} ✕</span>
|
|
<button class="text-xs px-2 py-1 rounded bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 text-neutral-700 dark:text-neutral-300" data-action="cancel" ${status==='RUNNING'||status==='PENDING' ? '' : 'disabled'}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
<div class="w-full h-2 bg-neutral-100 dark:bg-neutral-800 rounded">
|
|
<div class="h-2 rounded ${status==='FAILED'? 'bg-red-500' : status==='CANCELLED' ? 'bg-neutral-500' : 'bg-primary-500'}" style="width:${pct}%"></div>
|
|
</div>
|
|
<div class="mt-2 flex items-center justify-between text-xs text-neutral-600 dark:text-neutral-400">
|
|
<span>${pct}%</span>
|
|
<span>${current ? 'Current: '+current : ''}</span>
|
|
</div>
|
|
</div>`
|
|
);
|
|
}
|
|
|
|
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 = `
|
|
<div class="flex items-center">
|
|
<div class="flex-shrink-0">
|
|
<i class="fas fa-check-circle text-green-500"></i>
|
|
</div>
|
|
<div class="ml-3">
|
|
<p class="text-sm font-medium">Session Extended</p>
|
|
<p class="text-xs mt-1">Your session has been refreshed successfully.</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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 = `<span class="inline-block animate-spin w-4 h-4 border-2 border-current border-t-transparent rounded-full mr-2"></span>`;
|
|
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 = '<p class="text-danger">Search failed</p>';
|
|
}
|
|
}, 300);
|
|
});
|
|
}
|
|
|
|
function displaySearchResults(container, results, query = '') {
|
|
if (!results || results.length === 0) {
|
|
container.innerHTML = '<p class="text-neutral-500">No results found</p>';
|
|
return;
|
|
}
|
|
const tokens = (window.highlightUtils && typeof window.highlightUtils.buildTokens === 'function')
|
|
? window.highlightUtils.buildTokens(query)
|
|
: [];
|
|
|
|
const resultsHtmlRaw = results.map(result => `
|
|
<div class="search-result p-2 border-bottom">
|
|
<div class="flex justify-between">
|
|
<div>
|
|
<strong>${window.highlightUtils ? window.highlightUtils.highlight(result.title || '', tokens) : (result.title || '')}</strong>
|
|
<small class="text-neutral-500 block">${window.highlightUtils ? window.highlightUtils.highlight(result.description || '', tokens) : (result.description || '')}</small>
|
|
</div>
|
|
<span class="inline-block px-2 py-0.5 text-xs rounded bg-neutral-200 text-neutral-700">${result.type}</span>
|
|
</div>
|
|
</div>
|
|
`).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;
|
|
}
|
|
} |