From c71ef4af8574469d5172bffb6f6857bce7a7052e Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 27 Oct 2026 14:05:36 -0400 Subject: [PATCH] fix(auth): show error feedback on failed htmx login/registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/handlers/auth.go | 8 ++++++-- web/src/auth_forms.ts | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 273cbb12..72853e10 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -365,6 +365,10 @@ func (h *AuthHandler) Register(c *echo.Context) error { } // Login handles POST /api/auth/login +// invalidCredentialsHTML is the htmx error partial for failed logins; +// web/src/auth_forms.ts swaps 4xx/5xx HTML responses into the form target. +const invalidCredentialsHTML = `
Invalid email/username or password.
` + func (h *AuthHandler) Login(c *echo.Context) error { login := c.FormValue("login") password := c.FormValue("password") @@ -421,7 +425,7 @@ func (h *AuthHandler) Login(c *echo.Context) error { } if c.Request().Header.Get("HX-Request") == "true" { - return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) + return c.HTML(http.StatusUnauthorized, invalidCredentialsHTML) } return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) } @@ -437,7 +441,7 @@ func (h *AuthHandler) Login(c *echo.Context) error { } if c.Request().Header.Get("HX-Request") == "true" { - return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) + return c.HTML(http.StatusUnauthorized, invalidCredentialsHTML) } return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) } diff --git a/web/src/auth_forms.ts b/web/src/auth_forms.ts index 04055c65..d1831f93 100644 --- a/web/src/auth_forms.ts +++ b/web/src/auth_forms.ts @@ -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]");