"use client"; import { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import Turnstile, { awaitTurnstile, resetTurnstile, turnstileOn } from "@/components/Turnstile"; import { track } from "@/lib/analytics"; import { Field, LiveRegion } from "@/components/ui"; import PlanChoice, { type SignupPlan } from "@/components/PlanChoice"; const POINTS = [ "Bring your catalogue, staff register and cost centres in from CSV, or type them in over an afternoon.", "Issue with a scan, and the order to replace it starts itself.", "Month-end reporting your finance team can open without ringing you about it.", ]; /* What a failed single sign-on says when it lands back here. The codes come from * /api/auth/sso/callback; every one of them means "you are not signed in", and the sentence says * what to do next rather than what went wrong inside the broker. */ const SSO_ERRORS: Record = { sso_no_account: "Your identity provider signed you in, but that address has no account at this facility yet. Ask your admin to add you, using the same address.", sso_inactive: "That account has been deactivated. Ask an admin at your facility.", sso_state: "That sign-in link had expired or was opened in a different browser. Start again from here.", sso_failed: "Single sign-on didn’t complete. Try again, or use your password if you have one.", sso_unavailable: "Single sign-on isn’t available for that address.", }; export default function AuthForm({ initialMode, next, signupsOpen, plansLive = false, ssoError = "" }: { initialMode: "login" | "signup"; next: string; signupsOpen: boolean; plansLive?: boolean; ssoError?: string }) { // Which hosted plan a new facility starts on, asked only once plans are live (lib/plans-live.ts). const [plan, setPlan] = useState("hosted_small"); const router = useRouter(); const [tab, setTab] = useState<"login" | "signup">(initialMode); const [busy, setBusy] = useState(false); const [li, setLi] = useState({ email: "", pw: "", err: SSO_ERRORS[ssoError] || "" }); /* Whether the typed address belongs to a facility that signs in with single sign-on, asked once * the address is complete. "required" means the password box is beside the point for most people * there, so the SSO button leads and the password stays as the fire escape. */ const [sso, setSso] = useState<{ email: string; on: boolean; required: boolean; facility: string } | null>(null); async function lookupSso(email: string) { const e = email.trim().toLowerCase(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)) { setSso(null); return; } if (sso && sso.email === e) return; try { const r = await fetch("/api/auth/sso/lookup", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: e }) }); const j = await r.json().catch(() => ({})); setSso({ email: e, on: !!j.sso, required: !!j.required, facility: String(j.facility || "") }); } catch { setSso(null); } } function goSso() { // A full navigation: the route answers with a redirect to the broker, and the state cookie it // sets has to belong to this tab's navigation, not to a fetch. window.location.assign("/api/auth/sso/start?email=" + encodeURIComponent(li.email.trim().toLowerCase())); } const [su, setSu] = useState({ first: "", last: "", facility: "", email: "", pw: "", pw2: "", err: "" }); const [cfToken, setCfToken] = useState(""); /** Set once the facility exists, so the address it was created with can be shown back. */ const [made, setMade] = useState<{ email: string; mailed: boolean; mail: boolean } | null>(null); const [sentReset, setSentReset] = useState(false); const [ticket, setTicket] = useState(""); const [code, setCode] = useState(""); /** The security check was asked for and never answered — see cfNow. */ const [cfSlow, setCfSlow] = useState(false); /* The Turnstile token, waited for at submit time rather than demanded before the button works. * * The button used to be disabled until a token arrived, reading "Checking…" with nothing else on * screen. On a hospital network that filters or TLS-inspects challenges.cloudflare.com the token * never comes and the error callback publishes an empty string, so that was a permanent lockout * with no message and no way through — on the only door into the product. /m/login already does * it this way: wait up to eight seconds, then go anyway and let the server give an honest * refusal, with a line on screen saying what didn't finish. */ async function cfNow(): Promise { if (!turnstileOn()) return ""; const t = cfToken || await awaitTurnstile(); setCfSlow(!t); return t; } /** Always reports the same thing, so the reply can’t be used to test whether an address exists. */ async function sendReset() { if (!li.email.trim()) { setLi({ ...li, err: "Put your work email in the box first." }); return; } // Said before the request goes, not after it comes back: the answer is the same either way by // design, and waiting on the security check first would leave the link looking dead for up to // eight seconds on a network that is struggling with it. setSentReset(true); const token = await cfNow(); await fetch("/api/auth/forgot", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: li.email, cfToken: token }), }).catch(() => {}); } /* Shown under the widget when the check didn't answer. Not an error — the request is still sent, * and the server has the last word — but the person deserves to know which part is struggling. */ const cfNote = cfSlow ? "The security check didn’t finish. If this keeps happening, your network may be blocking challenges.cloudflare.com — try again, or ask IT." : ""; const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(su.email); const pwLen = su.pw.length >= 8; const pwMatch = pwLen && su.pw === su.pw2; const named = !!(su.first.trim() && su.last.trim() && su.facility.trim()); /* Every one of these used to `await fetch` with nothing around it. A dropped connection rejects, * so setBusy(false) never ran and the button stayed disabled on a spinner for good — the only fix * being to close the tab, or on the phone to kill the app. The finally clause is the fix; the * catch is what turns "nothing happened" into a sentence somebody can act on. */ async function doLogin() { if (!li.email.trim() || !li.pw) { setLi({ ...li, err: "Enter your email and password." }); return; } setBusy(true); try { const token = await cfNow(); const r = await fetch("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: li.email, password: li.pw, cfToken: token }) }); const j = await r.json().catch(() => ({})); // The facility signs in through its identity provider: the password was right and is still // refused. Nothing to explain — carry on to single sign-on with the same address. if (j.ssoRequired) { track("signin_sso_redirect", { screen: "desktop" }); goSso(); return; } if (!r.ok) { // The kind of refusal only — never the server's words. Same buckets as the phone's sign-in. track("signin_failed", { screen: "desktop", reason: r.status === 429 ? "throttled" : r.status === 400 ? "security_check" : "credentials" }); setLi({ ...li, err: j.error || "Email or password doesn’t match." }); setCfToken(""); resetTurnstile(); return; } // Password accepted, but the account carries a second factor — nothing is signed in yet. if (j.need2fa) { track("signin_2fa_required", { screen: "desktop" }); setTicket(j.ticket); setLi({ ...li, err: "" }); return; } track("signin", { screen: "desktop", kind: j.staff ? "staff" : "coordinator" }); /* A wearer, not a coordinator. The cookie just set is the staff one, and /my is the staff app * — server-rendered, in its own shell, and it already looks like the app at any width, so * there is nothing here that needs to know what kind of device this is. * * A full navigation rather than a router push: the App Router can serve a prefetched copy of * the destination that was fetched before the cookie existed, which lands a freshly signed-in * person on the signed-out screen. */ if (j.staff) { window.location.assign("/my"); return; } router.push(next); router.refresh(); } catch { setLi({ ...li, err: "No connection — check the network and try again." }); } finally { setBusy(false); } } async function doCode() { if (!code.trim()) { setLi({ ...li, err: "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) { track("signin_failed", { screen: "desktop", reason: "second_factor" }); setLi({ ...li, err: j.error || "That code isn’t right." }); if (r.status === 400) { setTicket(""); setCode(""); } // expired — back to the start return; } track("signin", { screen: "desktop", kind: "coordinator", second_factor: true }); router.push(next); router.refresh(); } catch { setLi({ ...li, err: "No connection — check the network and try again." }); } finally { setBusy(false); } } async function doSignup() { if (!(named && emailOk && pwMatch)) return; setBusy(true); try { const token = await cfNow(); const r = await fetch("/api/auth/signup", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ first: su.first, last: su.last, facility: su.facility, email: su.email, password: su.pw, cfToken: token, ...(plansLive ? { plan } : {}) }) }); const j = await r.json().catch(() => ({})); if (!r.ok) { track("signup_failed", { screen: "desktop", reason: r.status === 429 ? "throttled" : r.status === 400 ? "rejected" : "other" }); setSu({ ...su, err: j.error || "Couldn’t create the account." }); setCfToken(""); resetTurnstile(); return; } // A facility exists from here — the conversion every public page points at. track("signup_completed", { screen: "desktop" }); /* Show the address back before going anywhere. * * The account is made and signed in either way — nothing here gates on the answer. But this * is the address a password reset goes to and the only route back into a facility whose one * admin is locked out, and a typo in it is invisible until the day it matters. So the address * is put in front of the person once, with what actually happened to it: a note sent, or no * mail configured at all, which is a different thing from a bad address. */ setMade({ email: String(j.email || su.email), mailed: !!j.mailed, mail: j.mail !== false }); } catch { /* The account may or may not exist now: the POST could have committed before the connection * went. Signing up twice with the same address is refused by the server, so the safe advice * is to try again and read what it says. */ setSu({ ...su, err: "No connection — try again. If the address is already taken, the account was created." }); } finally { setBusy(false); } } const checks = [ { label: "Valid work email", ok: emailOk }, { label: "Password at least 8 characters", ok: pwLen }, { label: "Passwords match", ok: pwMatch }, ]; // A LiveRegion rather than a plain div: a refusal that only appears is a refusal a screen-reader // user never hears, and this is the box that says why they are not signed in. const errBox = (msg: string) => ( ); // The same panel, quieter: this one is a warning about the check, not a refusal of the sign-in. const cfBox = cfNote ? ( ) : null; const title: React.CSSProperties = { fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 26, marginTop: 24, letterSpacing: "-0.01em" }; return (
ThreadCount
Stock that adds up, from day one.
{POINTS.map((pt) => (
{pt}
))}
{plansLive ? "Priced per facility, never per seat · your data stays yours" : "No per-seat pricing · your data stays yours"}
ThreadCount
Uniform management
{signupsOpen && (
{/* Which pane you are on is otherwise carried by a red fill alone. */}
)} {tab === "login" ? ( ticket ? (
{ e.preventDefault(); doCode(); }}>

Two-factor

Your password was right. Enter the six-digit code from your authenticator app — or a recovery code if you no longer have the phone.

{(c) => ( { setCode(e.target.value); setLi({ ...li, err: "" }); }} placeholder="000000" /> )} {errBox(li.err)}
) : (
{ e.preventDefault(); doLogin(); }}> {/* The page's only heading. It was a styled div, so /auth had no heading of any level and a screen-reader user landing here had nothing to orient by. */}

Log in

{(c) => setLi({ ...li, email: e.target.value, err: "" })} onBlur={(e) => void lookupSso(e.target.value)} placeholder="you@yourfacility.org" />} {sso?.on && sso.email === li.email.trim().toLowerCase() && (
{sso.required ? <>{sso.facility} signs in with single sign-on. : <>{sso.facility} offers single sign-on.}
{sso.required &&
The password below is for the facility’s fire-escape admin only.
}
)} {(c) => setLi({ ...li, pw: e.target.value, err: "" })} onKeyDown={(e) => { if (e.key === "Enter") doLogin(); }} placeholder="••••••••" />}
{cfBox} {errBox(li.err)} {/* Disabled while the request is in flight, and at no other time. */} {/* "Ask an admin" was a dead end for the admin themselves — and deleting the last admin deletes the facility, so there was no way back in at all. */}
{sentReset ? If that address has an account, a reset link is on its way. It works once and expires in an hour. : <>Forgot your password? { e.preventDefault(); void sendReset(); }} style={{ fontWeight: 700 }}>Email me a reset link.} {signupsOpen && <> New here? { e.preventDefault(); setTab("signup"); }}>Create your facility's account.}
) ) : made ? (

Facility created

You’re signed in as {made.email}.

{!made.mail ? "No mail is configured on this server, so that address hasn’t been checked. Make sure it is right — it is where a password reset would go." : made.mailed ? "We’ve sent a note there — that is the address a password reset goes to. If it doesn’t arrive, the address is wrong: add a second admin under Settings → Users while you’re still signed in." : "We couldn’t send a note to that address. Check it is right, and add a second admin under Settings → Users while you’re still signed in — otherwise a forgotten password locks the facility out."}

) : (
{ e.preventDefault(); doSignup(); }}>

Create account

One account per facility. You'll be its first admin.
{(c) => setSu({ ...su, first: e.target.value, err: "" })} />} {(c) => setSu({ ...su, last: e.target.value, err: "" })} />} {(c) => setSu({ ...su, facility: e.target.value, err: "" })} placeholder="e.g. St Vincent’s Private" />} {(c) => setSu({ ...su, email: e.target.value, err: "" })} placeholder="you@yourfacility.org" />} {(c) => setSu({ ...su, pw: e.target.value, err: "" })} />} {(c) => setSu({ ...su, pw2: e.target.value, err: "" })} />}
{plansLive && (
Plan
)}
{checks.map((c) => (
{c.ok ? "✓" : "·"}{c.label}
))}
{cfBox} {errBox(su.err)} )}
← threadcount.tech Try the working demo
); }