// Error handling for HTMX requests // Shows toast notifications for failed requests and preserves user input // Signal that a request's swap was suppressed (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(); } // Handle HTMX response errors - this fires BEFORE swap document.body.addEventListener('htmx:beforeSwap', function(evt) { const xhr = evt.detail.xhr; const status = xhr.status; // Handle 403 Forbidden (CSRF errors) if (status === 403) { // Check if it's a CSRF error by looking at response const responseText = xhr.responseText || ''; if (responseText.includes('CSRF') || responseText.includes('csrf')) { // Prevent the swap so user input is preserved evt.detail.shouldSwap = false; evt.detail.isError = false; // Prevent htmx:responseError from firing showErrorToast('Your session has expired. Please refresh the page to continue.'); signalReorderRecover(); return; } // Other 403 errors evt.detail.shouldSwap = false; showErrorToast('Access denied. Please refresh the page and try again.'); signalReorderRecover(); return; } // Handle 5xx server errors if (status >= 500) { evt.detail.shouldSwap = false; showErrorToast('Server error. Please try again later.'); signalReorderRecover(); return; } // Handle network errors (status 0) if (status === 0) { evt.detail.shouldSwap = false; showErrorToast('Network error. Please check your connection and try again.'); signalReorderRecover(); return; } // 422 Unprocessable Entity is the server-side validation contract per // docs/operator-ux-conventions.md ยง6: the response body carries the form // re-rendered with `is-invalid` + `.invalid-feedback` on the offending // field(s). htmx 2.x's default responseHandling has `[45]..` set to // `swap:false`, so we must explicitly enable the swap (and clear the // error flag) for the validation body to land in the DOM. The inline // message is strictly more useful than a generic "check your input" // toast, so stay silent on the toast side. if (status === 422) { evt.detail.shouldSwap = true; evt.detail.isError = false; return; } // For other 4xx errors, allow the swap as the server may return helpful error HTML // but show a toast as backup if (status >= 400 && status < 500) { evt.detail.shouldSwap = true; showErrorToast('Request failed. Please check your input and try again.'); } }); // Fallback handler for any errors that slip through document.body.addEventListener('htmx:responseError', function(evt) { console.error('HTMX Response Error:', evt.detail); // Toast already shown by beforeSwap handler in most cases }); // Handle send errors (network failures before response) document.body.addEventListener('htmx:sendError', function(evt) { showErrorToast('Failed to send request. Please check your connection.'); // No swap ever lands, so a drag-to-reorder started here stays frozen too. signalReorderRecover(); });