"use client";
/* Sign in — onboarding screen 03. Public: this is the one /m route someone reaches without a
session, because it is how they get one. */
import Link from "next/link";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import Turnstile, { awaitTurnstile, resetTurnstile, turnstileOn } from "@/components/Turnstile";
import { track } from "@/lib/analytics";
import { MAuthError, MAuthFooter, MAuthHeader, MField, MShowHide, authInput, authLink } from "@/components/MAuth";
function LoginInner() {
const router = useRouter();
const sp = useSearchParams();
const [email, setEmail] = useState("");
const [pw, setPw] = useState("");
const [show, setShow] = useState(false);
const [cfToken, setCfToken] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [reveal, setReveal] = useState(false);
const [forgot, setForgot] = useState(false);
const [sentReset, setSentReset] = useState(false);
const [ticket, setTicket] = useState("");
const [code, setCode] = useState("");
const next = (() => {
const n = sp.get("next") || "";
return n.startsWith("/m") && !n.startsWith("//") ? n : "/m/signed-in";
})();
async function sendReset() {
setErr("");
if (!email.trim()) { setErr("Put your work email in the box above first."); return; }
setBusy(true);
const token = cfToken || (turnstileOn() ? await awaitTurnstile() : "");
await fetch("/api/auth/forgot", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ email, cfToken: token }),
}).catch(() => {});
setBusy(false);
setCfToken(""); resetTurnstile();
// Shown whatever the server said: the answer must not reveal whether the address has an account.
setSentReset(true);
}
async function submitCode() {
setErr("");
if (!code.trim()) { setErr("Enter the six-digit code from your authenticator app."); return; }
setBusy(true);
const r = await fetch("/api/auth/2fa", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ ticket, code }),
}).catch(() => null);
const j = r ? await r.json().catch(() => ({})) : {};
setBusy(false);
// A dropped connection is not a wrong code, and it used to leave the button spinning for ever
// with nothing said. Nothing is signed in either way, so the advice is simply to try again.
if (!r) {
track("signin_failed", { reason: "network" });
setErr("Couldn’t reach the server. Check the connection and try again.");
return;
}
if (!r.ok) {
track("signin_failed", { reason: "second_factor" });
setErr(j.error || "That code isn’t right.");
if (r.status === 400) setTicket(""); // the ticket expired — start again
return;
}
track("signin");
window.location.replace(next);
}
async function submit() {
if (!email.trim() || !pw) { setErr("Enter your email and password."); return; }
setBusy(true);
// The widget draws nothing in quiet mode, so nobody can see that it hasn't finished. Wait for
// the token rather than posting an empty one and blaming the person for it.
const token = cfToken || (turnstileOn() ? await awaitTurnstile() : "");
if (turnstileOn() && !token) {
// Eight seconds and no token. Either the check needs an interaction we've asked Turnstile
// not to draw, or it couldn't reach Cloudflare at all. Show the widget rather than send an
// empty token and let the server answer with a check the person was never shown.
setBusy(false);
setReveal(true);
// How often the invisible check has to show itself. If this climbs, the quiet widget is
// costing people sign-ins and should come back out.
track("security_check_shown", { screen: "signin" });
setErr("Finish the security check below, then try again.");
return;
}
const r = await fetch("/api/auth/login", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password: pw, cfToken: token }),
}).catch(() => null);
const j = r ? await r.json().catch(() => ({})) : {};
setBusy(false);
/* The request never arrived. Ward wifi drops, and without this the promise rejected, `busy`
never cleared and the sign-in button sat disabled on its spinner until the app was killed —
which reads as ThreadCount refusing to let you in. Nothing was signed in, so the token is
spent and the widget reset like any other failed attempt. */
if (!r) {
track("signin_failed", { reason: "network" });
setErr("Couldn’t reach the server. Check the connection and try again.");
setCfToken(""); resetTurnstile();
return;
}
if (!r.ok) {
// Whether it was the password or the security check — no email, no message.
track("signin_failed", { reason: r.status === 429 ? "throttled" : r.status === 400 ? "security_check" : "credentials" });
setErr(j.error || "That email and password don’t match.");
setCfToken(""); resetTurnstile();
return;
}
// The password was right but the account has a second factor; nothing is signed in yet.
if (j.need2fa) { setTicket(j.ticket); track("signin_2fa_required"); return; }
track("signin");
// A full navigation, not a router push: the session cookie has just changed and every /m page
// is server-rendered from it. replace(), not assign(), so the hardware back button doesn't
// land a signed-in person back on the sign-in screen.
window.location.replace(next);
}
return (
<>
{ticket ? "Your password was right. Now the code from your authenticator app." : "Use the account your linen services manager set up."}
Lost your phone? A recovery code works here instead.
> ) : ( <>If that address has an account, a reset link is on its way. It works once and expires in an hour.
) : forgot ? (Put your work email in the box above and we’ll send a link to set a new password.