"use client"; /* Set a new password from an emailed link. * * Deliberately outside both shells: it is reached from an email, by someone who may be on a phone * or a desktop and is by definition not signed in, so it carries its own minimal chrome and works * the same either way. */ import Link from "next/link"; import { Suspense, useState } from "react"; import { useSearchParams } from "next/navigation"; const INK = "#201e1d"; const ACCENT = "#ec3013"; const MIN = 8; function ResetInner() { const sp = useSearchParams(); const token = sp.get("token") || ""; const [pw, setPw] = useState(""); const [again, setAgain] = useState(""); const [show, setShow] = useState(false); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); /* An account with a second factor is not signed in by setting a password. * * /api/auth/reset answers `{ need2fa, ticket }` in exactly the shape /api/auth/login does, and * for the same reason: the password is only the first of two things. Without this step the new * password was saved, no cookie was issued, and the redirect to /app bounced straight back to * /auth with nothing said — which reads as "the reset didn't work" and sends people round again. */ const [ticket, setTicket] = useState(""); const [code, setCode] = useState(""); const input: React.CSSProperties = { width: "100%", minHeight: 50, padding: "10px 12px", border: "2px solid " + INK, background: "#fff", fontSize: 16, fontWeight: 600, borderRadius: 0, }; async function submit(e: React.FormEvent) { e.preventDefault(); setErr(""); if (pw.length < MIN) { setErr(`Use at least ${MIN} characters.`); return; } if (pw !== again) { setErr("The two passwords don't match."); return; } setBusy(true); /* try/finally, because without it a dropped connection left the button disabled reading * "Setting…" for good: the fetch rejects, the line that clears `busy` never runs, and the only * way on is to open the emailed link again — from a page that gives no hint that is what * happened. The reset may well have gone through, so the message says so rather than promising * nothing changed. */ try { const r = await fetch("/api/auth/reset", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token, password: pw }), }); const j = await r.json().catch(() => ({})); if (!r.ok) { setErr(j.error || "That didn't work. Ask for a new link."); return; } // Password saved, but the account carries a second factor — nothing is signed in yet. if (j.need2fa) { setTicket(j.ticket); return; } // A full navigation: the session cookie has just been set and every page is server-rendered. window.location.replace("/app"); } catch { setErr("No connection — try again. If the new password works, it was already set."); } finally { setBusy(false); } } async function submitCode(e: React.FormEvent) { e.preventDefault(); setErr(""); if (!code.trim()) { setErr("Enter the code from your authenticator app."); return; } setBusy(true); try { const r = await fetch("/api/auth/2fa", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ticket, code }), }); const j = await r.json().catch(() => ({})); if (!r.ok) { setErr(j.error || "That code isn't right."); // 400 is an expired or spent ticket. The password is already changed, so the way back is a // fresh sign-in rather than another reset link. if (r.status === 400) { setTicket(""); setCode(""); } return; } window.location.replace("/app"); } catch { setErr("No connection — check the network and try the code again."); } finally { setBusy(false); } } if (!token) { return (

That link is incomplete — reset links only work in full. Copy the whole link out of the email, or ask for a new one.

); } if (ticket) { return ( <>

Your new password is set. This account has two-factor turned on, so one more step: the six-digit code from your authenticator app, or a recovery code if you no longer have the phone.

{err &&
{err}
}
); } return ( <>

At least {MIN} characters. Setting it signs out anywhere else your account was already open, and signs you in here — after your authenticator code, if you have two-factor turned on.

{err &&
{err}
}
); } const kicker: React.CSSProperties = { fontSize: 12, letterSpacing: "0.15em", textTransform: "uppercase", fontWeight: 800, color: ACCENT }; const h1: React.CSSProperties = { fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(28px,5vw,44px)", lineHeight: 1.05, letterSpacing: "-0.03em", margin: "14px 0 0" }; const p: React.CSSProperties = { fontSize: 15.5, lineHeight: 1.65, color: "var(--color-neutral-800)", margin: "16px 0 0", maxWidth: "46ch" }; const lab: React.CSSProperties = { display: "block", fontSize: 11, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginBottom: 6 }; function Shell({ children }: { children: React.ReactNode }) { return (
{/* The page is nothing but this form, so the card is the main landmark and there is no repeated block in front of it for a skip link to bypass. */}
{children}
); } export default function ResetPage() { // The heading is outside the boundary on purpose: useSearchParams suspends, so anything inside // it is absent from the server HTML. This way the page says what it is immediately. return (
ThreadCount

Reset your password.

One moment…

}>
); }