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:
@@ -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>
|
||||
@@ -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);
|
||||
})();
|
||||
Reference in New Issue
Block a user