// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo // Error handling for HTMX requests // Shows toast notifications for failed requests and preserves user input. // // htmx 4 swaps every response except 204/304 by default. Swap suppression is // declarative, not scripted: the statuses whose bodies are not designed as // swap content (403, 5xx) sit in the "noSwap" list of each page's // htmx-config meta tag. 422 swaps natively — its body is the re-rendered // form with inline field errors (docs/operator-ux-conventions.md §6), so no // script steers that swap anymore. This script only reports: // htmx:response:error HTTP >= 400 — toast by status; 422 stays silent // htmx:error network failure or timeout — toast // Signal that a request's swap was suppressed or never landed (CSRF expiry, // 5xx, network error). sortable-reorder.js listens for this to recover a // frozen drag-to-reorder: SortableJS has already moved the DOM row and // disabled the instance, so a suppressed swap would leave the table showing // the new order with stale rank badges and drag permanently dead. The // listener re-enables the instance and re-fetches server order (audit // finding #55). It is a no-op unless a reorder was actually in flight, so // dispatching broadly is safe. function signalReorderRecover() { document.body.dispatchEvent(new CustomEvent('operator:reorder-recover')); } // Show error toast with a message function showErrorToast(message) { const toastBody = document.getElementById('errorToastBody'); const toastEl = document.getElementById('errorToast'); if (!toastBody || !toastEl) { console.error('Error toast elements not found'); alert(message); // Fallback to alert return; } toastBody.textContent = message; const toast = new bootstrap.Toast(toastEl, { delay: 8000 }); toast.show(); } // HTTP error responses (status >= 400). Whether the body swaps is decided by // the noSwap config, not here. document.body.addEventListener('htmx:response:error', function(evt) { const ctx = evt.detail && evt.detail.ctx; const status = ctx && ctx.response ? ctx.response.status : 0; // 422 Unprocessable Entity is the server-side validation contract: the // body carries the form re-rendered with `is-invalid` + `.invalid-feedback` // on the offending field(s) and swaps inline by default behavior. The // inline message is strictly more useful than a generic toast, so stay // silent. if (status === 422) { return; } // 403 Forbidden (in noSwap: the swap is suppressed, user input preserved). if (status === 403) { const text = (ctx && ctx.text) || ''; if (text.includes('CSRF') || text.includes('csrf')) { // A CSRF failure means this page's token no longer matches the // browser's cookie (the page outlived the cookie, or another tab // minted a new one). The session itself is fine; a session that has // ended arrives as a 401 with HX-Redirect and never reaches here. showErrorToast('This page is out of date. Refresh it and try again.'); } else { showErrorToast('Access denied. Please refresh the page and try again.'); } signalReorderRecover(); return; } // 5xx server errors (in noSwap: the swap is suppressed). if (status >= 500) { showErrorToast('Server error. Please try again later.'); signalReorderRecover(); return; } // Other 4xx swap by default — the server may return helpful error HTML — // but show a toast as backup. showErrorToast('Request failed. Please check your input and try again.'); }); // Request-level failures: network errors, timeouts (htmx 4 defaults to 60s). document.body.addEventListener('htmx:error', function(evt) { showErrorToast('Network error. Please check your connection and try again.'); // No swap ever lands, so a drag-to-reorder started here stays frozen too. signalReorderRecover(); });