all working
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
|
||||
// ... add the JS content ...
|
||||
|
||||
// Modify modal showing/hiding to use classList.add/remove('hidden') instead of Bootstrap modal
|
||||
// Modify modal showing/hiding to use classList.add/remove('hidden')
|
||||
|
||||
// For example:
|
||||
function showQuickTimeModal() {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Global application state
|
||||
const app = {
|
||||
token: localStorage.getItem('auth_token'),
|
||||
refreshToken: localStorage.getItem('refresh_token'),
|
||||
user: null,
|
||||
initialized: false,
|
||||
refreshTimerId: null,
|
||||
@@ -13,9 +14,98 @@ const app = {
|
||||
|
||||
// Initialize application
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
try { setupGlobalErrorHandlers(); } catch (_) {}
|
||||
initializeApp();
|
||||
});
|
||||
|
||||
// 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 fetch('/api/auth/theme-preference', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ theme_preference: theme })
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('Could not save theme preference to server:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
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 fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
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) {
|
||||
console.log('Could not load theme preference from server:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply theme immediately on script load
|
||||
try { initializeTheme(); } catch (_) {}
|
||||
|
||||
async function initializeApp() {
|
||||
// Initialize keyboard shortcuts
|
||||
if (window.keyboardShortcuts) {
|
||||
@@ -29,6 +119,11 @@ async function initializeApp() {
|
||||
|
||||
// Initialize API helpers
|
||||
setupAPIHelpers();
|
||||
|
||||
// Initialize authentication manager (centralized)
|
||||
if (typeof initializeAuthManager === 'function') {
|
||||
initializeAuthManager();
|
||||
}
|
||||
|
||||
app.initialized = true;
|
||||
console.log('Delphi Database System initialized');
|
||||
@@ -103,6 +198,14 @@ async function apiCall(url, options = {}) {
|
||||
|
||||
try {
|
||||
let response = await fetch(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
|
||||
@@ -113,6 +216,7 @@ async function apiCall(url, options = {}) {
|
||||
...options
|
||||
};
|
||||
response = await fetch(url, retryConfig);
|
||||
lastCorrelationId = updateCorrelationFromResponse(response);
|
||||
} catch (_) {
|
||||
// fall through to logout below
|
||||
}
|
||||
@@ -124,7 +228,10 @@ async function apiCall(url, options = {}) {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Request failed' }));
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`);
|
||||
const err = new Error(errorData.detail || `HTTP ${response.status}`);
|
||||
err.status = response.status;
|
||||
err.correlationId = lastCorrelationId || null;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
@@ -158,16 +265,263 @@ async function apiDelete(url) {
|
||||
}
|
||||
|
||||
// Authentication functions
|
||||
function setAuthToken(token) {
|
||||
app.token = token;
|
||||
localStorage.setItem('auth_token', token);
|
||||
window.apiHeaders['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
function setAuthTokens(accessToken, newRefreshToken = null) {
|
||||
if (accessToken) {
|
||||
app.token = accessToken;
|
||||
localStorage.setItem('auth_token', accessToken);
|
||||
window.apiHeaders['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
if (newRefreshToken) {
|
||||
app.refreshToken = newRefreshToken;
|
||||
localStorage.setItem('refresh_token', newRefreshToken);
|
||||
}
|
||||
// Reschedule refresh on token update
|
||||
scheduleTokenRefresh();
|
||||
if (accessToken) {
|
||||
scheduleTokenRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
function setAuthToken(token) {
|
||||
// Backwards compatibility
|
||||
setAuthTokens(token, null);
|
||||
}
|
||||
|
||||
// Page helpers
|
||||
function isLoginPage() {
|
||||
const path = window.location.pathname;
|
||||
return path === '/login' || path === '/';
|
||||
}
|
||||
|
||||
// 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 fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
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 fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
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 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 fetch('/api/settings/inactivity_warning_minutes', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
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 fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: rtoken })
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (app.refreshTimerId) {
|
||||
clearTimeout(app.refreshTimerId);
|
||||
app.refreshTimerId = null;
|
||||
@@ -176,7 +530,12 @@ function logout() {
|
||||
app.token = null;
|
||||
app.user = null;
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
delete window.apiHeaders['Authorization'];
|
||||
|
||||
if (reason) {
|
||||
try { sessionStorage.setItem('logout_reason', reason); } catch (_) {}
|
||||
}
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
@@ -404,6 +763,70 @@ 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 {
|
||||
const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
|
||||
const token = (window.app && window.app.token) || localStorage.getItem('auth_token');
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
// Fire-and-forget; do not block UI
|
||||
fetch('/api/documents/client-error', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(() => {});
|
||||
} catch (_) {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
// JWT utilities and refresh handling
|
||||
function decodeJwt(token) {
|
||||
@@ -448,13 +871,14 @@ function scheduleTokenRefresh() {
|
||||
}
|
||||
|
||||
async function refreshToken() {
|
||||
if (!app.token) throw new Error('No token to refresh');
|
||||
if (!app.refreshToken) throw new Error('No refresh token available');
|
||||
if (app.refreshInProgress) return; // Avoid parallel refreshes
|
||||
app.refreshInProgress = true;
|
||||
try {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { ...window.apiHeaders }
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: app.refreshToken })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Refresh failed');
|
||||
@@ -463,7 +887,8 @@ async function refreshToken() {
|
||||
if (!data || !data.access_token) {
|
||||
throw new Error('Invalid refresh response');
|
||||
}
|
||||
setAuthToken(data.access_token);
|
||||
// Handle refresh token rotation if provided
|
||||
setAuthTokens(data.access_token, data.refresh_token || null);
|
||||
} finally {
|
||||
app.refreshInProgress = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user