From fab18960562e8ea0ab5f7c51a65f4908a390fbad Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Tue, 28 Apr 2026 22:24:48 +0200 Subject: [PATCH] Add magic-link login UI with client-side validation cmd/librenotes/web/public/login.{html,js}: - Email input with required + autocomplete + autofocus, ARIA attributes for screen readers (aria-describedby, aria-invalid, role="alert" on the error container, role="status" on success). - Client-side regex validation runs before POST to /auth/login to avoid a network round-trip for obvious typos. Server is still the source of truth. - Loading state disables the button and changes its label. - Success state replaces the form with "Check your email" including the address, plus the 15-minute / single-use note. - Error states map server statuses to user-friendly messages: 429 -> "too many requests", 400 -> "invalid email", anything else -> generic server error. Network errors get their own message so users can distinguish offline from server problems. - No external CSS or JS dependencies; works with keyboard and on small viewports. Closes #13. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/librenotes/web/public/login.html | 38 +++++++++++++ cmd/librenotes/web/public/login.js | 82 ++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 cmd/librenotes/web/public/login.html create mode 100644 cmd/librenotes/web/public/login.js diff --git a/cmd/librenotes/web/public/login.html b/cmd/librenotes/web/public/login.html new file mode 100644 index 0000000..ba4ed43 --- /dev/null +++ b/cmd/librenotes/web/public/login.html @@ -0,0 +1,38 @@ + + + + + + Sign in — librenotes + + + + +
+

librenotes

+

Sign in with a magic link. We'll email you a one-time link valid for 15 minutes.

+ +
+ + + + +
+ + + + +
+ + + diff --git a/cmd/librenotes/web/public/login.js b/cmd/librenotes/web/public/login.js new file mode 100644 index 0000000..b0ec6ef --- /dev/null +++ b/cmd/librenotes/web/public/login.js @@ -0,0 +1,82 @@ +// login.js — magic-link request flow. +// +// We deliberately do NOT store any state until /auth/verify returns. +// Until verification we have no JWT and no tenant identity, so there +// is nothing to scope. The post-verify page (verify.html) handles +// session storage. + +(function () { + "use strict"; + + const form = document.getElementById("login-form"); + const emailInput = document.getElementById("email"); + const emailError = document.getElementById("email-error"); + const submitBtn = document.getElementById("submit-btn"); + const success = document.getElementById("success"); + const successEmail = document.getElementById("success-email"); + + // Minimal RFC-5322-ish check. Server is the source of truth; this + // only catches the obvious typos so users don't waste a request. + const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + function showError(msg) { + emailError.textContent = msg; + emailError.hidden = false; + emailInput.setAttribute("aria-invalid", "true"); + } + + function clearError() { + emailError.hidden = true; + emailError.textContent = ""; + emailInput.removeAttribute("aria-invalid"); + } + + function setLoading(on) { + submitBtn.disabled = on; + submitBtn.textContent = on ? "Sending…" : "Send magic link"; + } + + async function submit(email) { + setLoading(true); + try { + const resp = await fetch("/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + if (resp.status === 202) { + form.hidden = true; + success.hidden = false; + successEmail.textContent = email; + return; + } + if (resp.status === 429) { + showError("Too many requests for this email. Try again in a few minutes."); + return; + } + if (resp.status === 400) { + showError("That email looks invalid. Double-check and try again."); + return; + } + showError("Something went wrong on our side. Please try again."); + } catch (e) { + showError("Network error — please check your connection."); + } finally { + setLoading(false); + } + } + + form.addEventListener("submit", function (ev) { + ev.preventDefault(); + clearError(); + const email = emailInput.value.trim().toLowerCase(); + if (!emailRe.test(email)) { + showError("Please enter a valid email address."); + emailInput.focus(); + return; + } + submit(email); + }); + + emailInput.addEventListener("input", clearError); +})();