From 4e52a9c418e797ba01b1b518b7a9b1c4ce0435f2 Mon Sep 17 00:00:00 2001 From: emi Date: Tue, 21 Jul 2026 03:57:20 +0000 Subject: [PATCH] Wire account verification end-to-end and fix an RFC 5322 mail bug (#1) --- static/account.js | 44 ++++++++++++++++++++++++++++++++++++++++++++ static/style.css | 11 +++++++++++ 2 files changed, 55 insertions(+) diff --git a/static/account.js b/static/account.js index be8fa56..80efaa9 100644 --- a/static/account.js +++ b/static/account.js @@ -45,6 +45,8 @@ async function readJson(res) { if (detail && typeof detail === "object" && detail.reason) msg = detail.reason; else if (detail === "LOGIN_BAD_CREDENTIALS") msg = "Wrong email or password."; else if (detail === "REGISTER_USER_ALREADY_EXISTS") msg = "That email is already registered."; + else if (detail === "VERIFY_USER_BAD_TOKEN") msg = "That verification link is invalid or expired."; + else if (detail === "VERIFY_USER_ALREADY_VERIFIED") msg = "This account is already verified."; else if (typeof detail === "string") msg = detail.replace(/_/g, " ").toLowerCase(); else msg = `Request failed (${res.status}).`; throw new Error(msg); @@ -84,6 +86,10 @@ async function register(email, password) { const res = await apiFetch("api/v2/auth/register", { method: "POST", json: { email, password } }); await readJson(res); } +async function verifyEmail(token) { + const res = await apiFetch("api/v2/auth/verify", { method: "POST", json: { token } }); + await readJson(res); +} export async function logout() { try { await apiFetch("api/v2/auth/logout", { method: "POST" }); } catch (e) {} currentUser = null; @@ -375,6 +381,27 @@ function escapeHtml(s) { ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } +// --- toast --------------------------------------------------------------- +// One-off status messages (e.g. "email verified") that aren't tied to the auth +// modal, which may not even be open. Success/failure share the same neutral +// style — the text says which, not the color; isError only changes the +// live-region role (alert = assertive, status = polite). +let toastTimer = null; + +function showToast(msg, isError = false) { + let el = document.querySelector(".acct-toast"); + if (!el) { + el = document.createElement("div"); + el.className = "acct-toast"; + document.body.appendChild(el); + } + el.textContent = msg; + el.setAttribute("role", isError ? "alert" : "status"); + el.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { el.hidden = true; }, 6000); +} + // --- boot -------------------------------------------------------------------- (async function initAccount() { ensureAcctEl(); @@ -382,4 +409,21 @@ function escapeHtml(s) { await refreshUser(); // then confirm session from the cookie renderHeader(); emitAuth(); + + const params = new URLSearchParams(location.search); + const token = params.get("verify_token"); + if (token) { + params.delete("verify_token"); + const clean = location.pathname + (params.toString() ? `?${params}` : "") + location.hash; + history.replaceState(null, "", clean); + try { + await verifyEmail(token); + await refreshUser(); + renderHeader(); + emitAuth(); + showToast("Email verified — you're all set."); + } catch (err) { + showToast(err.message || "Verification link is invalid or expired.", true); + } + } })(); diff --git a/static/style.css b/static/style.css index 0e95e78..66b0852 100644 --- a/static/style.css +++ b/static/style.css @@ -402,6 +402,17 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } font: inherit; font-weight: 600; padding: 0; text-decoration: underline; } +/* One-off status toast (e.g. "email verified") — neutral for both success and + failure; the text says which, not the color. */ +.acct-toast { + position: fixed; left: 50%; bottom: 24px; z-index: 1200; + transform: translateX(-50%); + max-width: calc(100vw - 32px); padding: 12px 18px; border-radius: 10px; + background: var(--surface); border: 1px solid var(--border); color: var(--text); + font-size: 14px; box-shadow: 0 12px 30px rgba(0, 0, 0, .4); +} +.acct-toast[hidden] { display: none; } + /* --- alerts page (subscriptions) --- */ .alerts-loading { color: var(--muted); } .alerts-gate {