/** * Hoover — api.js * * JSON-friendly fetch wrapper with automatic header management and JWT auth. * Toast notification system with auto-dismiss. * Modal processing guard for async form submissions. */ import { modelFetch } from './model.js?v=10'; import { requestUpdate } from './reactivity.js?v=9'; import { isModalProcessing, setModalProcessing, refreshModals } from './components/modal.js?v=9'; /** * Global state — shared with auth.js component. * * ``window.__auth_token__`` — current access token (in memory, cleared on reload). * ``sessionStorage['vw:refresh']`` — refresh token (tab-scoped, cleared on close). * ``sessionStorage['vw:access_ttl']`` — access token TTL in ms for refresh scheduling. * ``sessionStorage['vw:session_id']`` — session binding ID for token validation. */ /** * Inject ``Authorization: Bearer `` header from ``window.__auth_token__``. * Returns undefined when no token is available. * * @returns {string|undefined} */ function getAuthToken() { return window.__auth_token__; } /** * Store access token in memory and schedule refresh. * * @param {string} token */ function setAuthToken(token) { window.__auth_token__ = token; } /** * Clear all auth tokens from memory and storage. */ function clearAuthTokens() { window.__auth_token__ = undefined; sessionStorage.removeItem('vw:refresh'); sessionStorage.removeItem('vw:access_ttl'); sessionStorage.removeItem('vw:session_id'); sessionStorage.removeItem('vw:user'); sessionStorage.removeItem('vw:user'); if (typeof window.__authRefreshTimer__ !== 'undefined') { clearTimeout(window.__authRefreshTimer__); window.__authRefreshTimer__ = undefined; } } /** * Read refresh token and access TTL from sessionStorage. * @returns {{refresh?: string, ttl?: number, session_id?: string}} */ function getStoredAuth() { return { refresh: sessionStorage.getItem('vw:refresh'), ttl: parseInt(sessionStorage.getItem('vw:access_ttl'), 10) || 300000, session_id: sessionStorage.getItem('vw:session_id'), }; } /** Serialize concurrent refresh attempts — only one refresh in-flight at a time. */ let _refreshPromise = null; /** * Attempt to refresh the access token using the stored refresh token. * Concurrent calls wait on the in-flight refresh; subsequent calls reuse * whatever the outcome was. * * Sends: POST /api/auth/refresh { refresh_token: ... } * On success: updates ``window.__auth_token__`` and ``sessionStorage['vw:refresh']``. * On failure: clears all tokens. * * @returns {Promise} ``true`` if refresh succeeded */ async function tryRefreshToken() { if (_refreshPromise) return _refreshPromise; _refreshPromise = (async () => { try { const stored = getStoredAuth(); if (!stored.refresh) return false; const res = await fetch('/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ refresh_token: stored.refresh }), credentials: 'same-origin', }); if (res.status !== 200) { clearAuthTokens(); return false; } const json = await res.json(); if (!json.ok || !json.data?.tokens) { clearAuthTokens(); return false; } const tokens = json.data.tokens; window.__auth_token__ = tokens.access_token; sessionStorage.setItem('vw:refresh', tokens.refresh_token); sessionStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 300) * 1000)); if (tokens.session_id) { sessionStorage.setItem('vw:session_id', tokens.session_id); } if (json.data.user) { sessionStorage.setItem('vw:user', JSON.stringify(json.data.user)); } return true; } catch { clearAuthTokens(); return false; } })(); _refreshPromise = _refreshPromise.finally(() => { _refreshPromise = null; }); return _refreshPromise; } /** * Redirect to login page, clearing tokens. */ function redirectLogin() { clearAuthTokens(); window.location.href = '/#/login'; } /** * JSON-friendly fetch wrapper. * * Automatically sets Content-Type for object bodies, parses JSON * responses, and normalises the result to { ok, data, error, status }. * Injects ``Authorization: Bearer`` and ``X-Session-Id`` headers when * a token is present. On 401, tries token refresh once; on persistent * failure, redirects to login. * * @param {string} url – Target URL * @param {object} [options] – Fetch options (method, body, headers, …) * @returns {Promise<{ok, data, error, status}>} */ export async function apiFetch(url, options = {}) { const { method = 'GET', body, ...opts } = options; const headers = { 'Accept': 'application/json', ...opts.headers }; const token = getAuthToken(); if (token) { headers['Authorization'] = 'Bearer ' + token; const stored = getStoredAuth(); if (stored.session_id) { headers['X-Session-Id'] = stored.session_id; } } if (body && typeof body === 'object' && !(body instanceof FormData)) { headers['Content-Type'] = 'application/json'; options.body = JSON.stringify(body); } try { const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts }); if (opts.signal?.aborted) { return { ok: false, data: null, error: 'Aborted', status: 0 }; } if (res.status === 401 && getAuthToken()) { const refreshed = await tryRefreshToken(); if (refreshed) { const refreshedStored = getStoredAuth(); headers['Authorization'] = 'Bearer ' + getAuthToken(); if (refreshedStored.session_id) { headers['X-Session-Id'] = refreshedStored.session_id; } const retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts }); if (retryRes.ok) { const json = await retryRes.json().catch(() => null); return { ok: json?.ok ?? true, data: json ? (json.ok ? json.data : json) : null, error: null, status: retryRes.status }; } if (retryRes.status === 401) { redirectLogin(); return { ok: false, data: null, error: 'Session expired', status: 401 }; } const json = await retryRes.json().catch(() => null); return { ok: false, data: null, error: json?.error || `HTTP ${retryRes.status}`, status: retryRes.status }; } redirectLogin(); return { ok: false, data: null, error: 'Session expired', status: 401 }; } const json = await res.json(); if (!res.ok) { return { ok: false, data: null, error: json.error || `HTTP ${res.status}`, status: res.status }; } return { ok: json.ok, data: json.ok ? json.data : json, error: null, status: res.status }; } catch (e) { return { ok: false, data: null, error: e.message || 'Network error', status: 0 }; } } /** * Export auth helpers for use by other modules. */ export { setAuthToken, clearAuthTokens, getAuthToken, tryRefreshToken, redirectLogin }; /** ─── Toast notifications ────────────────────────────────── */ /** Toast notification queue. Exported for ToastContainer component. */ export const _toasts = []; const _toastIds = { next: 1 }; /** * Show a toast notification. Auto-dismisses after `duration` ms. * * @param {string} message – Toast text * @param {string} [type] – 'info' | 'success' | 'error' | 'warning' * @param {number} [duration] – Auto-dismiss timeout in ms (0 = indefinite) * @returns {number} id */ export function toast(message, type = 'info', duration = 4000) { const id = _toastIds.next++; _toasts.push({ id, message, type, createdAt: Date.now(), duration }); requestUpdate(); if (duration > 0) setTimeout(() => dismissToast(id), duration); return id; } /** * Dismiss a toast by id. */ export function dismissToast(id) { const idx = _toasts.findIndex(t => t.id === id); if (idx !== -1) _toasts.splice(idx, 1); requestUpdate(); } /** * Create an abort-checking function from an AbortController. * * @deprecated Use model layer (`modelRegister` / `modelFetch`) for data * fetching with abort handling and loading state management. * @param {AbortController} ac * @returns {function} () => boolean */ export function checkAbort(ac) { return () => ac?.signal?.aborted || false; } /** * Standard data loading wrapper with state management and abort handling. * * Sets loading=true before, loading=false after, tracks errors. * * @deprecated Use model layer (`modelRegister` / `modelFetch`) for data * fetching with abort handling and loading state management. * @param {object} state - Reactive state object * @param {function} dataKey - (s) => any, current data to compare for refresh detection * @param {function} fetchFn - (state, signal, isAborted) => Promise * @param {object} [opts] - Additional options * @param {object} [opts.entry] - Component entry for requestId tracking * @param {AbortController} [opts.abortController] - Fresh abort controller */ export async function refactorLoad(state, dataKey, fetchFn, opts = {}) { const entry = opts.entry; const myId = entry ? entry.requestId : 0; const ab = opts.abortController; const isAborted = ab ? checkAbort(ab) : () => false; const signal = ab ? ab.signal : null; if (entry) { if (dataKey(state) !== undefined) state.refreshing = true; else state.loading = true; } state.error = null; try { await fetchFn(state, signal, isAborted); } catch (e) { if (!isAborted()) state.error = e.message || 'Request failed'; } finally { if (!isAborted()) { if (entry) { state.loading = false; state.refreshing = false; } } } } /** * Poll a URL until success or error condition is met. * * @param {object} opts * @param {string} opts.url - URL to poll * @param {function} opts.successKey - (data) => boolean, when true poll succeeds * @param {function} opts.onErrorKey - (data) => boolean, when true poll fails * @param {function} [opts.onComplete] - (data) => void, called on success * @param {function} [opts.onError] - (data) => void, called on failure * @param {number} [opts.interval] - Poll interval in ms (default: 3000) * @param {number} [opts.timeout] - Overall timeout in ms (default: 60000) */ export async function poll(opts) { const { url, successKey, onErrorKey, onComplete, onError, interval = 3000, timeout = 60000, } = opts; const start = Date.now(); const timer = setInterval(async () => { if (Date.now() - start > timeout) { clearInterval(timer); if (onError) onError(null); return; } const res = await apiFetch(url); if (!res.ok) { clearInterval(timer); if (onError) onError(res); return; } if (successKey(res.data)) { clearInterval(timer); if (onComplete) onComplete(res.data); } else if (onErrorKey(res.data)) { clearInterval(timer); if (onError) onError(res.data); } }, interval); } /** * Centralized handler wrapper that encapsulates processing guard, * processing state, error handling, and modal re-render. * * Used by any handler not using `apiSubmit`. The async function receives * no arguments and should perform validation (via `throw`), API calls, * success/error toasting, modal closing, and data refreshing. * * @param {function} fn – Async handler function * @returns {function} Wrapped handler */ export function formAction(fn) { return async () => { if (isModalProcessing()) return; setModalProcessing(true); try { await fn(); } catch (e) { toast(e.message || 'Failed', 'error'); } finally { setModalProcessing(false); refreshModals(); } }; } /** * Generate action button descriptors for modal form submission. * * Returns an array of action descriptors that can be spread into the * actions array passed to formModal. First item is the submit button. * * @param {object} opts * @param {string} opts.url - API URL to POST/PUT to * @param {string} [opts.method] - HTTP method (default: 'POST') * @param {function} [opts.body] - () => object, body builder * @param {function} [opts.validate] - (body) => string|null, validation function * @param {string} [opts.successMsg] - Success toast message * @param {string|string[]} [opts.refresh] - Model name(s) to refresh via modelFetch * @param {string} [opts.submitText] - Submit button text (default: 'Submit') * @returns {object[]} Array of action descriptors */ export function apiSubmit(opts) { const { url, method = 'POST', body, validate, successMsg = 'Saved', refresh, submitText = 'Submit', closeModal, } = opts; return [ { label: submitText, cls: 'btn-primary', action: 's', processing: true, handler: async () => { if (isModalProcessing()) return; setModalProcessing(true); try { const b = body ? body() : {}; if (validate) { const err = validate(b); if (err) { toast(err, 'error'); return; } } refreshModals(); const res = await apiFetch(url, { method, body: b }); if (res.ok) { const synced = res.data?.synced; let msg = successMsg; if (synced && synced.length) { msg += ' (auto-synced: ' + synced.join(', ') + ')'; synced.forEach(s => modelFetch(s)); } toast(msg, 'success'); if (closeModal) closeModal(); if (refresh) { const models = Array.isArray(refresh) ? refresh : [refresh]; await Promise.all(models.map(m => modelFetch(m))); } } else { toast(res.error || 'Failed', 'error'); } } finally { setModalProcessing(false); refreshModals(); } }, }, ]; }