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