fix(auth): show error feedback on failed htmx login/registration

Found while double-checking /login after the redirect fix: a wrong
password left the form completely silent. Two stacked causes, both
pre-dating the CSP work:

1. The two HX-Request branches for invalid credentials returned JSON,
   unlike every sibling error branch — there was no HTML partial to
   show at all. They now return a shared invalidCredentialsHTML div
   (the JSON shape is kept for non-htmx API callers).
2. htmx does not swap 4xx/5xx responses by default, so even the HTML
   error partials that DID exist (429 lockout, 500s, registration
   failures like "Email already exists") never landed in the target.
   auth_forms.ts now swaps them itself on htmx:beforeSwap when the
   error response is text/html — JSON errors stay ignored.

Verified live: /login success → /dashboard; wrong password shows
"Invalid email/username or password." and stays on /login; duplicate
email on /register shows "Email already exists". Gates: make test 9
pkgs, vitest 155, Playwright 20.
This commit is contained in:
John O'Keefe committed 2026-10-27 14:05:36 -04:00
1 parent 7c9e561e68
commit c71ef4af85
2 files changed
+24 -2

No files matched your search

+18
View File
@@ -16,6 +16,24 @@
// 'user'/'refreshToken', but nothing in web/src ever read them.
export function armAuthFormRedirects() {
// htmx refuses to swap 4xx/5xx responses by default, which left the login
// form silent on wrong passwords: the server's error partials (401 invalid
// credentials, 429 lockout, 500s) never landed in the target div. Swap
// them ourselves whenever the error response is an HTML partial — JSON
// errors (API clients) are still ignored by htmx.
document.body.addEventListener("htmx:beforeSwap", (event) => {
const detail = (event as CustomEvent).detail as {
xhr?: XMLHttpRequest;
shouldSwap?: boolean;
isError?: boolean;
};
const xhr = detail?.xhr;
if (!xhr || xhr.status < 400) return;
if (!(xhr.getResponseHeader("content-type") ?? "").includes("text/html")) return;
detail.shouldSwap = true;
detail.isError = false;
});
document.body.addEventListener("htmx:afterSwap", (event) => {
const detail = (event as CustomEvent).detail as { target?: HTMLElement } | undefined;
const el = detail?.target?.querySelector("[data-redirect]");