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]");