Files
member-console/internal/embeds/static/error-handler.js
T
cgalo5758 fe19ee415c Add CLA and SPDX headers, fix docs
- Pin Dockerfile to Go 1.23 to match go.mod
- Record README front-door audit findings
2026-09-07 21:32:14 -05:00

91 lines
3.9 KiB
JavaScript

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