"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"; import { HAS_SITE, PRIVACY_URL, TERMS_URL } from "@/lib/links"; /* The desktop door: sign in and create a facility. * * Sign-in asks for the address first and only then shows the one thing that applies — a password, * the facility's single sign-on, or a pointer to the staff app — instead of every door at once. * Creating a facility is three short steps (you, the facility, the plan) rather than one long form, * and ends on the short list a new admin actually needs. */ 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 about it. */ 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.", }; const SETTINGS: [string, string][] = [["", "Choose one (optional)"], ["hospital", "Hospital"], ["aged_care", "Aged care"], ["community", "Community health"], ["other", "Other"]]; const STATES: [string, string][] = [["", "Choose one (optional)"], ["QLD", "Queensland"], ["NSW", "New South Wales"], ["VIC", "Victoria"], ["ACT", "Australian Capital Territory"], ["TAS", "Tasmania"], ["SA", "South Australia"], ["NT", "Northern Territory"], ["WA", "Western Australia"], ["NZ", "New Zealand"]]; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; /** Four bars: length 8, length 12, mixed case, a digit or symbol. A sentence scores full marks. */ function strength(pw: string): { score: number; word: string } { const score = [pw.length >= 8, pw.length >= 12, /[a-z]/.test(pw) && /[A-Z]/.test(pw), /\d|[^\w\s]/.test(pw)].filter(Boolean).length; return { score, word: ["Too short", "Weak", "Fair", "Good", "Strong"][pw.length < 8 ? 0 : score] }; } type LoginStep = "email" | "password" | "sso" | "staff" | "2fa" | "reset"; export default function AuthForm({ initialMode, next, signupsOpen, plansLive = false, ssoError = "", sso: ssoOffered = false }: { initialMode: "login" | "signup"; next: string; signupsOpen: boolean; plansLive?: boolean; ssoError?: string; sso?: boolean }) { const router = useRouter(); const [mode, setMode] = useState<"login" | "signup">(initialMode); const [busy, setBusy] = useState(false); const [cfToken, setCfToken] = useState(""); /** The security check was asked for and never answered — see cfNow. */ const [cfSlow, setCfSlow] = useState(false); // ---- sign in ---- const [step, setStep] = useState("email"); const [li, setLi] = useState({ email: "", pw: "", err: SSO_ERRORS[ssoError] || "" }); const [showPw, setShowPw] = useState(false); const [caps, setCaps] = useState(false); const [remember, setRemember] = useState(false); const [sso, setSso] = useState<{ on: boolean; required: boolean; facility: string }>({ on: false, required: false, facility: "" }); const [ticket, setTicket] = useState(""); const [code, setCode] = useState(""); const [recoveryMode, setRecoveryMode] = useState(false); const [trust, setTrust] = useState(false); const [resetAgain, setResetAgain] = useState(0); // ---- create a facility ---- const [su, setSu] = useState({ first: "", last: "", facility: "", email: "", pw: "", setting: "", state: "", err: "" }); const [suStep, setSuStep] = useState<1 | 2 | 3>(1); const [showSuPw, setShowSuPw] = useState(false); const [plan, setPlan] = useState("hosted_small"); const [agree, setAgree] = useState(false); /** 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 legal = !!(TERMS_URL || PRIVACY_URL); /* The Turnstile token, waited for at submit time rather than demanded before the button works. * On a network that filters challenges.cloudflare.com the token never comes; wait up to eight * seconds, then go anyway and let the server give an honest refusal, with a line saying what * didn't finish. */ async function cfNow(): Promise { if (!turnstileOn()) return ""; const t = cfToken || await awaitTurnstile(); setCfSlow(!t); return t; } 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 email = li.email.trim().toLowerCase(); /** Step one: the address decides the door. Any failure to decide falls back to the password box. */ async function decideDoor() { if (!EMAIL_RE.test(email)) { setLi({ ...li, err: "Enter your work email." }); return; } setBusy(true); try { const post = (url: string) => fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email }) }).then((r) => r.json()).catch(() => ({})); const [s, l] = await Promise.all([ssoOffered ? post("/api/auth/sso/lookup") : Promise.resolve({}), post("/api/auth/lookup")]); const on = !!s.sso; setSso({ on, required: !!s.required, facility: String(s.facility || "") }); setLi({ ...li, err: "" }); if (on) { setStep("sso"); return; } if (l.staff) { setStep("staff"); return; } setStep("password"); } finally { setBusy(false); } } 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(email)); } function backToEmail() { setStep("email"); setLi({ ...li, pw: "", err: "" }); setTicket(""); setCode(""); setRecoveryMode(false); } /* 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 for good. 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.pw) { setLi({ ...li, err: "Enter your 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, password: li.pw, cfToken: token, remember }) }); 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) { 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: "" }); setStep("2fa"); 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. // A full navigation, so no prefetched signed-out copy of the destination is served. 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: recoveryMode ? "Enter one of your recovery codes." : "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, trust, remember }) }); 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(""); setStep("password"); } // expired — back a step 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); } } /** Always reports the same thing, so the reply can’t be used to test whether an address exists. */ async function sendReset() { // Said before the request goes: 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. setStep("reset"); setResetAgain((n) => n + 1); const token = await cfNow(); await fetch("/api/auth/forgot", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, cfToken: token }) }).catch(() => {}); } // ---- create a facility ---- const suEmailOk = EMAIL_RE.test(su.email); const pwOk = su.pw.length >= 8; const step1Ok = !!(su.first.trim() && su.last.trim()) && suEmailOk && pwOk; const step2Ok = !!su.facility.trim(); const st = strength(su.pw); async function doSignup() { if (!(step1Ok && step2Ok) || (legal && !agree)) 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, setting: su.setting, state: su.state, agreed: agree, 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(); // A refusal about the address or the password belongs on the step that asked for it. if (r.status === 409 || /email|password/i.test(String(j.error || ""))) setSuStep(1); return; } track("signup_completed", { screen: "desktop" }); /* Show the address back before going anywhere: it is where a password reset goes and the only * route back into a facility whose one admin is locked out, and a typo is invisible until the * day it matters. */ setMade({ email: String(j.email || su.email), mailed: !!j.mailed, mail: j.mail !== false }); } catch { setSu({ ...su, err: "No connection — try again. If the address is already taken, the account was created." }); } finally { setBusy(false); } } // 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) => ( ); const cfBox = cfNote ? ( ) : null; const title: React.CSSProperties = { fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 26, letterSpacing: "-0.01em", margin: 0 }; const sub: React.CSSProperties = { fontSize: 13.5, color: "var(--color-neutral-800)", marginTop: 8, lineHeight: 1.6 }; const small: React.CSSProperties = { fontSize: 12, color: "var(--color-neutral-700)", lineHeight: 1.6 }; const kicker: React.CSSProperties = { fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-neutral-700)" }; const linkBtn: React.CSSProperties = { background: "none", border: 0, padding: 0, font: "inherit", fontWeight: 700, color: "var(--color-accent-700)", textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }; const arrowBtn = (label: string, extra?: React.CSSProperties) => ( {label} ); /** The address being signed in, with the way back to change it. */ const whoLine = (
{email}
); const showToggle = (on: boolean, set: (v: boolean) => void) => ( ); // ---------------- left pane ---------------- const brand = (
ThreadCount
); const bigTitle: React.CSSProperties = { fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 42, lineHeight: 1.05, letterSpacing: "-0.02em", textWrap: "balance" as never }; const paneFoot =
{plansLive ? "Priced per facility, never per seat · your data stays yours" : "No per-seat pricing · your data stays yours"}
; const pane = mode === "login" ? (
Welcome back.
One address, one door. Type your work email and you are taken the right way: password, single sign-on, or the staff app if that is where you belong.
Not the linen room?
Wearing the uniform? Staff sign-in
At the counter on a phone? Counter sign-in
) : (
Set up your facility in three short steps.
{(["You", "Your facility", plansLive ? "Plan" : "Confirm"] as const).map((t, i) => { const on = made ? true : suStep === i + 1, done = made || suStep > i + 1; return (
{done ? "✓" : i + 1}
{t}
); })}
{POINTS.map((pt) => (
{pt}
))}
); // ---------------- sign-in steps ---------------- const loginBody = (() => { if (step === "email") return (
{ e.preventDefault(); void decideDoor(); }}>

Sign in

Your work email decides the way in.

{(c) => setLi({ ...li, email: e.target.value, err: "" })} placeholder="you@yourfacility.org" />}
{errBox(li.err)} {signupsOpen &&
New facility? · one per facility, you become its first admin.
}
); if (step === "password") return (
{ e.preventDefault(); void doLogin(); }}> {whoLine}

Welcome back

{sso.on &&

{sso.facility} signs in with single sign-on. This password is the break-glass admin’s only.

}
{(c) => setLi({ ...li, pw: e.target.value, err: "" })} onKeyDown={(e) => setCaps(e.getModifierState && e.getModifierState("CapsLock"))} onKeyUp={(e) => setCaps(e.getModifierState && e.getModifierState("CapsLock"))} />}
{showToggle(showPw, setShowPw)}
{caps && } {cfBox} {errBox(li.err)}
); if (step === "sso") return (
{whoLine}

{sso.facility || "Your facility"} signs in with single sign-on

You will be taken to your organisation’s login and brought straight back.

{errBox(li.err)}
{sso.required ? "Break-glass admin? Sign in with a password instead" : "Prefer your password?"}
{sso.required ? "Only the facility’s break-glass admin has a password here; everybody else uses the button above." : "Single sign-on is optional at this facility. A password set on your account still works."} {" "}
); if (step === "staff") return (
{whoLine}

That address is a staff sign-in

It belongs to a uniform record, not a linen-room account. Your kit, requests and orders live in the staff app.

Work in the linen room as well? Ask its admin to add you under Settings → Users; the same address can hold both.
); if (step === "2fa") return (
{ e.preventDefault(); void doCode(); }}> {whoLine}

Two-factor

{recoveryMode ? "Enter one of the recovery codes you saved when you set this up. Each works once." : "Your password was right. Enter the six-digit code from your authenticator app."}

{(c) => ( { setCode(e.target.value); setLi({ ...li, err: "" }); }} placeholder={recoveryMode ? "XXXXX-XXXXX" : "000000"} style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: recoveryMode ? 18 : 24, letterSpacing: recoveryMode ? "0.08em" : "0.35em", minHeight: 52 }} /> )}
{errBox(li.err)}
Lost the phone and the codes?
Another admin at your facility can clear two-factor on your account under Settings → Users. If you are the only admin, write to support with the facility name from the address on the account.
); // reset return (

Reset link sent

To {email}. It works once and expires in an hour.

Nothing there after a few minutes? The address above is the one on the account. If it is wrong, another admin at your facility can fix it under Settings → Users.{resetAgain > 1 ? " Sent again just now." : ""}} style={{ borderLeft: "6px solid var(--color-text)", padding: "8px 12px", fontSize: 12.5, lineHeight: 1.55, color: "var(--color-neutral-800)", marginTop: 12 }} />
You are the only admin and cannot get the mail?
The facility is not lost. Write to support from any address you control, naming the facility and the address on the account; identity is checked before anything is changed.
); })(); // ---------------- create a facility ---------------- const signupBody = made ? (
Facility created

{su.facility.trim() || "Your facility"} is ready{su.first.trim() ? `, ${su.first.trim()}` : ""}

You are signed in as {made.email}.

{[ [made.mail && made.mailed ? "Check the note we sent" : "Check the address is right", made.mail && made.mailed ? "If it never arrives, the address on the account is wrong. Resets cannot reach you until it is fixed." : "It is the only route back in if the password is forgotten."], ["Add a second admin", "Settings → Users. Two people can always get back in; one cannot."], ["Bring in the catalogue and staff register", "CSV templates are on the import screen, or type them over an afternoon."], ["Put the apps on the room’s phones", "The counter app for the linen room, the staff app for wearers. Both sign in with this facility’s addresses."], ].map(([t, s], i) => (
{i + 1}
{t}
{s}
))}
) : suStep === 1 ? (
{ e.preventDefault(); if (step1Ok) { setSu({ ...su, err: "" }); setSuStep(2); } }}>
Step 1 of 3 · You

Who is setting this up?

You will be the facility’s first admin. Everything else can be handed to a colleague later.

{(c) => setSu({ ...su, first: e.target.value, err: "" })} />} {(c) => setSu({ ...su, last: e.target.value, err: "" })} />} {(c) => setSu({ ...su, email: e.target.value, err: "" })} placeholder="you@yourfacility.org" />}
{(c) => setSu({ ...su, pw: e.target.value, err: "" })} />}
{showToggle(showSuPw, setShowSuPw)}
{[1, 2, 3, 4].map((n) =>
= 8 && st.score >= n ? "var(--color-text)" : "var(--color-neutral-300)" }} />)}
{errBox(su.err)}
Already set up? . Joining a facility that already uses ThreadCount? Ask its admin to add you under Settings → Users instead.
) : suStep === 2 ? (
{ e.preventDefault(); if (step2Ok) setSuStep(3); }}>
Step 2 of 3 · Your facility

Name the facility

It appears on every screen, report and order sheet.

{(c) => setSu({ ...su, facility: e.target.value, err: "" })} placeholder="e.g. St Vincent’s Private" />} {(c) => } {(c) => }
) : (
{ e.preventDefault(); void doSignup(); }}>
Step 3 of 3 · {plansLive ? "Plan" : "Confirm"}

{plansLive ? "Start free, or start the trial" : "Ready to create it"}

{plansLive ? "Both give you the whole product. No card either way." : `${su.facility.trim()} will be created with you as its first admin.`}

{plansLive && (
Plan
)} {legal && ( )} {cfBox} {errBox(su.err)}
); return (
{brand} {pane} {paneFoot}
ThreadCount
{mode === "signup" && !made ? `Step ${suStep} of 3` : "Uniform management"}
{mode === "login" ? loginBody : signupBody} {HAS_SITE && (
← threadcount.tech Try the working demo
)}
); }