const API_BASE = '../api'; function getToken() { return localStorage.getItem('qdb_token'); } const WEBSITE_ROLES = ['admin', 'supervisor']; let sessionCheckPromise = null; let sessionCheckToken = null; /** Clear cached session validation (after logout or token change). */ export function invalidateSessionCheck() { sessionCheckPromise = null; sessionCheckToken = null; } /** Clear stored session and open the login page. */ export function redirectToLogin() { invalidateSessionCheck(); localStorage.removeItem('qdb_token'); localStorage.removeItem('qdb_user'); localStorage.removeItem('qdb_role'); window.location.hash = '#/login'; } /** @returns {boolean} true if the response means the session is no longer valid */ function isSessionInvalidResponse(res, json) { if (res.status === 401) return true; const errMsg = json.error?.message || json.error || ''; return res.status === 403 && ( errMsg === 'Invalid or expired token' || errMsg === 'Missing Bearer token' || errMsg === 'Password change required before access' || json.error?.code === 'UNAUTHORIZED' || json.error?.code === 'PASSWORD_CHANGE_REQUIRED' ); } /** * Verify the stored token is still valid on the server. Redirects to login when not. * @returns {Promise} */ export async function ensureValidSession() { const token = getToken(); if (!token) { redirectToLogin(); return false; } if (sessionCheckPromise && sessionCheckToken === token) { return sessionCheckPromise; } sessionCheckToken = token; sessionCheckPromise = (async () => { try { const data = await apiGet('session'); if (!data.success || !data.valid) { redirectToLogin(); return false; } if (data.mustChangePassword) { redirectToLogin(); return false; } if (!WEBSITE_ROLES.includes(data.role)) { redirectToLogin(); return false; } if (data.user) localStorage.setItem('qdb_user', data.user); if (data.role) localStorage.setItem('qdb_role', data.role); return true; } catch (e) { if (e.message !== 'Session expired') { redirectToLogin(); } return false; } })(); return sessionCheckPromise; } async function handleAuthResponse(res) { if (res.status !== 401 && res.status !== 403) return; const json = await res.json().catch(() => ({})); if (isSessionInvalidResponse(res, json)) { redirectToLogin(); throw new Error('Session expired'); } const errMsg = json.error?.message || json.error || ''; throw new Error(errMsg || 'Permission denied'); } async function apiFetch(endpoint, options = {}) { const token = getToken(); const headers = options.headers || {}; headers['X-QDB-Client'] = 'web'; if (token) headers['Authorization'] = `Bearer ${token}`; if (!(options.body instanceof FormData)) { headers['Content-Type'] = 'application/json; charset=UTF-8'; } // Strip .php suffix for clean URLs const cleanEndpoint = endpoint.replace(/\.php(\?|$)/, '$1'); const res = await fetch(`${API_BASE}/${cleanEndpoint}`, { ...options, headers }); if (res.status === 401 || res.status === 403) { await handleAuthResponse(res); } return res; } /** * Unwrap the unified response envelope: * {"ok": true, "data": ...} -> returns { success: true, ...data } * {"ok": false, "error": ...} -> returns { success: false, error: message } * Also supports legacy format for backward compat. */ function unwrap(json) { if (typeof json.ok === 'boolean') { if (json.ok) { const d = json.data || {}; return { success: true, ...(Array.isArray(d) ? { items: d } : d) }; } return { success: false, error: json.error?.message || 'Unknown error' }; } return json; } export async function apiGet(endpoint) { const res = await apiFetch(endpoint); return unwrap(await res.json()); } export async function apiPost(endpoint, body) { const res = await apiFetch(endpoint, { method: 'POST', body: JSON.stringify(body), }); return unwrap(await res.json()); } export async function apiPut(endpoint, body) { const res = await apiFetch(endpoint, { method: 'PUT', body: JSON.stringify(body), }); return unwrap(await res.json()); } export async function apiPatch(endpoint, body) { const res = await apiFetch(endpoint, { method: 'PATCH', body: JSON.stringify(body), }); return unwrap(await res.json()); } export async function apiDelete(endpoint, body) { const res = await apiFetch(endpoint, { method: 'DELETE', body: JSON.stringify(body), }); return unwrap(await res.json()); } export function apiDownloadUrl(endpoint) { const token = getToken(); const cleanEndpoint = endpoint.replace(/\.php(\?|$)/, '$1'); return `${API_BASE}/${cleanEndpoint}${cleanEndpoint.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`; } /** Authenticated file/download fetch (marks request as website for activity logging). */ export async function apiDownloadFetch(url, options = {}) { const token = getToken(); const headers = { ...(options.headers || {}), 'X-QDB-Client': 'web' }; if (token) headers['Authorization'] = `Bearer ${token}`; const res = await fetch(url, { ...options, headers }); const ct = res.headers.get('Content-Type') || ''; if (ct.includes('application/json')) { await handleAuthResponse(res); } else if (res.status === 401 || res.status === 403) { redirectToLogin(); throw new Error('Session expired'); } return res; }