/** * 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 }; /** * 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(); } }, }, ]; }