/** * Hoover — api.js * * JSON-friendly fetch wrapper with automatic header management (JWT headers * injected from the auth model) and 401 re-authentication. * Toast notification system with auto-dismiss. * Modal processing guard for async form submissions. */ import { modelFetch } from './model.js'; import { getAuthToken, getAuthData, refreshAuth } from './auth_model.js'; import { requestUpdate } from './reactivity.js'; import { isModalProcessing, setModalProcessing, refreshModals } from './components/modal.js'; /** * Public auth endpoints that may legitimately 401 (bad credentials) while a * valid session exists elsewhere. 401 recovery (refresh → retry → logout) * is skipped for these so a failed login doesn't tear down a live session. */ const _PUBLIC_AUTH_URLS = new Set([ '/api/auth/login', '/api/auth/webauthn/authenticate-begin', '/api/auth/webauthn/authenticate-finish', ]); function _isPublicAuthUrl(url) { return _PUBLIC_AUTH_URLS.has(String(url).split('?')[0]); } /** * 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 (read from the auth model). On 401 it runs the auth * model's refresh once and retries; a still-401 retry drives the model to * the terminal logout state (clears storage, 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; // Drop the caller's raw headers so the merged object (with the injected // Authorization / X-Session-Id) always wins when spread into the fetch options. const { headers: _callerHeaders, ...safeOpts } = opts; const headers = { 'Accept': 'application/json', ..._callerHeaders }; const token = getAuthToken(); if (token) { headers['Authorization'] = 'Bearer ' + token; const auth = getAuthData(); if (auth?.session_id) headers['X-Session-Id'] = auth.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', ...safeOpts }); if (safeOpts.signal?.aborted) { return { ok: false, data: null, error: 'Aborted', status: 0 }; } if (res.status === 401 && token && !_isPublicAuthUrl(url)) { await refreshAuth(); const auth = getAuthData(); if (auth?.token) { headers['Authorization'] = 'Bearer ' + auth.token; headers['X-Session-Id'] = auth.session_id; // rotated — re-read from model const retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...safeOpts }); 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) { // Refresh succeeded but the retry is still 401 — the session is // dead. Drive the model to the terminal logout state; its // onSuccess clears storage and redirects to #/login. modelFetch('auth', { action: 'logout' }); 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 }; } // No token after the refresh — onSuccess already cleared storage // and redirected to #/login. 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 }; } } /** ─── Toast notifications ────────────────────────────────── */ /** Toast notification queue. Exported for ToastContainer component. */ export const _toasts = []; const _toastIds = { next: 1 }; /** * Default auto-dismiss durations per toast type (ms). 0 = never * auto-dismiss. Errors stay on screen until dismissed so long * failure messages remain readable. */ const _TOAST_DEFAULT_DURATIONS = { info: 4000, success: 4000, warning: 8000, error: 0, }; /** * Auto-dismiss timer that pauses while the toast is hovered. * Re-checks in 1s while hovered instead of dismissing. */ function _scheduleToastDismiss(id, delay) { setTimeout(() => { const t = _toasts.find(t => t.id === id); if (!t) return; if (t.hovered) _scheduleToastDismiss(id, 1000); else dismissToast(id); }, delay); } /** * Show a toast notification. * * When `duration` is omitted, per-type defaults apply: 'info' and * 'success' auto-dismiss after 4000 ms, 'warning' after 8000 ms, and * 'error' toasts never auto-dismiss. An explicit `duration` overrides * the default. The auto-dismiss timer pauses while the toast is * hovered. * * @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) { const id = _toastIds.next++; const dur = duration === undefined ? (_TOAST_DEFAULT_DURATIONS[type] ?? 4000) : duration; _toasts.push({ id, message, type, createdAt: Date.now(), duration: dur, hovered: false }); requestUpdate(); if (dur > 0) _scheduleToastDismiss(id, dur); 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 {function} [opts.confirm] - (body) => string|null; if a message is * returned, a native confirm() dialog gates * the submit; on approval the body gains * force=true (server-side guard override) * @param {string} [opts.successMsg] - Success toast message * @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, confirm, successMsg = 'Saved', 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; } } if (confirm) { const msg = confirm(b); if (msg) { if (!window.confirm(msg)) return; b.force = true; } } 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(', ') + ')'; } toast(msg, 'success'); if (closeModal) closeModal(); // No modelFetch — WS delta updates all affected subsystems. } else { toast(res.error || 'Failed', 'error'); } } finally { setModalProcessing(false); refreshModals(); } }, }, ]; }