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) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 22:24:48 +02:00
co-authored by Claude Opus 4.7
parent f63c7a7e63
commit fab1896056
2 changed files with 120 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in — librenotes</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/style.css">
</head>
<body class="auth-page">
<main class="auth-card" aria-labelledby="title">
<h1 id="title"><a href="/" class="brand">librenotes</a></h1>
<p class="muted">Sign in with a magic link. We'll email you a one-time link valid for 15 minutes.</p>
<form id="login-form" novalidate>
<label for="email">Email address</label>
<input
id="email"
type="email"
name="email"
autocomplete="email"
required
aria-describedby="email-error"
autofocus>
<p id="email-error" class="error" role="alert" hidden></p>
<button type="submit" id="submit-btn">Send magic link</button>
</form>
<div id="success" class="success" role="status" hidden>
<p>Check your email — we sent a sign-in link to <strong id="success-email"></strong>.</p>
<p class="muted">The link expires in 15 minutes and can only be used once.</p>
</div>
<p class="footer-link"><a href="/">Back to home</a></p>
</main>
<script src="/login.js"></script>
</body>
</html>
+82
View File
@@ -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);
})();