Files
threadcount-community/components/AuthForm.tsx
T
ThreadCount 7d650e4c10 ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from d947f89 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-15 18:27:45 +10:00

498 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string, string> = {
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 didnt complete. Try again, or use your password if you have one.",
sso_unavailable: "Single sign-on isnt 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<LoginStep>("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<SignupPlan>("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<string> {
if (!turnstileOn()) return "";
const t = cfToken || await awaitTurnstile();
setCfSlow(!t);
return t;
}
const cfNote = cfSlow ? "The security check didnt 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 doesnt 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 isnt 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 cant 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 || "Couldnt 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) => (
<LiveRegion tone="alert" msg={msg} style={{ border: "2px solid var(--color-accent)", padding: "8px 12px", fontSize: 13, fontWeight: 600, color: "var(--color-accent-700)", marginTop: 12 }} />
);
const cfBox = cfNote ? (
<LiveRegion msg={cfNote} style={{ borderLeft: "6px solid var(--color-text)", padding: "8px 12px", fontSize: 12.5, lineHeight: 1.55, color: "var(--color-neutral-800)", marginTop: 12 }} />
) : 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) => (
<span style={{ display: "flex", justifyContent: "space-between", width: "100%", ...extra }}><span>{label}</span><span aria-hidden></span></span>
);
/** The address being signed in, with the way back to change it. */
const whoLine = (
<div style={{ ...small, display: "flex", gap: 8, alignItems: "center", marginBottom: 14 }}>
<button type="button" onClick={backToEmail} style={linkBtn}> Not you?</button>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{email}</span>
</div>
);
const showToggle = (on: boolean, set: (v: boolean) => void) => (
<button type="button" onClick={() => set(!on)} aria-pressed={on} style={{ ...linkBtn, fontSize: 12, position: "absolute", right: 10, top: 0, height: 36, display: "flex", alignItems: "center" }}>{on ? "Hide" : "Show"}</button>
);
// ---------------- left pane ----------------
const brand = (
<Link href="/" style={{ display: "flex", alignItems: "center", gap: 10, textDecoration: "none", color: "var(--color-text)" }}>
<div style={{ width: 16, height: 16, background: "var(--color-accent)" }} />
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 22, letterSpacing: "-0.01em" }}>ThreadCount</div>
</Link>
);
const bigTitle: React.CSSProperties = { fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 42, lineHeight: 1.05, letterSpacing: "-0.02em", textWrap: "balance" as never };
const paneFoot = <div style={{ fontSize: 11, color: "var(--color-neutral-600)", textTransform: "uppercase", letterSpacing: "0.08em" }}>{plansLive ? "Priced per facility, never per seat · your data stays yours" : "No per-seat pricing · your data stays yours"}</div>;
const pane = mode === "login" ? (
<div>
<div style={bigTitle}>Welcome back.</div>
<div style={{ marginTop: 18, fontSize: 14, lineHeight: 1.6, color: "var(--color-neutral-800)", maxWidth: 400 }}>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.</div>
<div style={{ marginTop: 32, borderTop: "2px solid var(--color-text)", paddingTop: 16, display: "flex", flexDirection: "column", gap: 10, maxWidth: 400 }}>
<div style={kicker}>Not the linen room?</div>
<div style={{ display: "flex", gap: 10, alignItems: "center", fontSize: 13 }}><span style={{ width: 8, height: 8, background: "var(--color-text)", flex: "none" }} /><span>Wearing the uniform? <Link href="/my/signin" style={{ fontWeight: 700 }}>Staff sign-in</Link></span></div>
<div style={{ display: "flex", gap: 10, alignItems: "center", fontSize: 13 }}><span style={{ width: 8, height: 8, background: "var(--color-text)", flex: "none" }} /><span>At the counter on a phone? <Link href="/m/login" style={{ fontWeight: 700 }}>Counter sign-in</Link></span></div>
</div>
</div>
) : (
<div>
<div style={bigTitle}>Set up your facility in three short steps.</div>
<div style={{ marginTop: 24, display: "flex", flexDirection: "column", gap: 14 }}>
{(["You", "Your facility", plansLive ? "Plan" : "Confirm"] as const).map((t, i) => {
const on = made ? true : suStep === i + 1, done = made || suStep > i + 1;
return (
<div key={t} style={{ display: "flex", gap: 14, alignItems: "center" }}>
<div style={{ width: 28, height: 28, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, ...(on || done ? { background: "var(--color-text)", color: "var(--color-bg)" } : { border: "2px solid var(--color-text)" }) }}>{done ? "✓" : i + 1}</div>
<div style={{ fontSize: 14, fontWeight: on ? 700 : 500, color: on ? "var(--color-text)" : "var(--color-neutral-700)" }}>{t}</div>
</div>
);
})}
</div>
<div style={{ marginTop: 32, display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }}>
{POINTS.map((pt) => (
<div key={pt} style={{ display: "flex", gap: 12, alignItems: "baseline", fontSize: 13.5, lineHeight: 1.55, color: "var(--color-neutral-800)" }}>
<span style={{ width: 8, height: 8, background: "var(--color-accent)", flex: "none", transform: "translateY(-1px)" }} />{pt}
</div>
))}
</div>
</div>
);
// ---------------- sign-in steps ----------------
const loginBody = (() => {
if (step === "email") return (
<form onSubmit={(e) => { e.preventDefault(); void decideDoor(); }}>
<h1 style={title}>Sign in</h1>
<p style={sub}>Your work email decides the way in.</p>
<div style={{ marginTop: 20 }}>
<Field label="Work email">{(c) => <input {...c} className="input" type="email" inputMode="email" autoCapitalize="none" autoCorrect="off" spellCheck={false} enterKeyHint="next" autoComplete="email" autoFocus value={li.email} onChange={(e) => setLi({ ...li, email: e.target.value, err: "" })} placeholder="you@yourfacility.org" />}</Field>
</div>
{errBox(li.err)}
<button type="submit" className="btn btn-primary" disabled={busy} style={{ marginTop: 16, width: "100%" }}>{arrowBtn(busy ? "One moment…" : "Continue")}</button>
{signupsOpen && <div style={{ ...small, marginTop: 14 }}>New facility? <button type="button" onClick={() => { setMode("signup"); setSu({ ...su, err: "" }); }} style={linkBtn}>Create its account</button> · one per facility, you become its first admin.</div>}
</form>
);
if (step === "password") return (
<form onSubmit={(e) => { e.preventDefault(); void doLogin(); }}>
{whoLine}
<h1 style={title}>Welcome back</h1>
{sso.on && <p style={sub}>{sso.facility} signs in with single sign-on. This password is the break-glass admins only.</p>}
<div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 20 }}>
<div style={{ position: "relative" }}>
<Field label="Password">{(c) => <input {...c} className="input" type={showPw ? "text" : "password"} enterKeyHint="go" autoComplete="current-password" autoFocus value={li.pw} style={{ paddingRight: 56 }}
onChange={(e) => setLi({ ...li, pw: e.target.value, err: "" })}
onKeyDown={(e) => setCaps(e.getModifierState && e.getModifierState("CapsLock"))} onKeyUp={(e) => setCaps(e.getModifierState && e.getModifierState("CapsLock"))} />}</Field>
<div style={{ position: "absolute", right: 0, top: 19 }}>{showToggle(showPw, setShowPw)}</div>
</div>
<label style={{ display: "flex", gap: 10, alignItems: "center", fontSize: 13, cursor: "pointer" }}>
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} style={{ width: 16, height: 16, margin: 0 }} />
<span>Keep me signed in on this computer for 30 days</span>
</label>
</div>
{caps && <LiveRegion msg="Caps Lock is on." style={{ borderLeft: "6px solid var(--color-text)", padding: "8px 12px", fontSize: 12.5, color: "var(--color-neutral-800)", marginTop: 12 }} />}
<Turnstile action="login" onToken={setCfToken} quiet />
{cfBox}
{errBox(li.err)}
<button type="submit" className="btn btn-primary" disabled={busy} style={{ marginTop: 16, width: "100%" }}>{arrowBtn(busy ? "Signing in…" : "Sign in")}</button>
<div style={{ ...small, marginTop: 14 }}><button type="button" onClick={() => void sendReset()} style={linkBtn}>Forgot your password?</button></div>
</form>
);
if (step === "sso") return (
<div>
{whoLine}
<h1 style={title}>{sso.facility || "Your facility"} signs in with single sign-on</h1>
<p style={sub}>You will be taken to your organisations login and brought straight back.</p>
{errBox(li.err)}
<button type="button" className="btn btn-primary" onClick={goSso} style={{ marginTop: 20, width: "100%" }}>{arrowBtn(`Continue with ${sso.facility || "single sign-on"}`)}</button>
<details style={{ marginTop: 18 }}>
<summary style={{ fontSize: 12, fontWeight: 700, color: "var(--color-neutral-700)", cursor: "pointer" }}>{sso.required ? "Break-glass admin? Sign in with a password instead" : "Prefer your password?"}</summary>
<div style={{ ...small, marginTop: 8 }}>
{sso.required ? "Only the facilitys 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."}
{" "}<button type="button" onClick={() => setStep("password")} style={linkBtn}>Use a password</button>
</div>
</details>
</div>
);
if (step === "staff") return (
<div>
{whoLine}
<h1 style={title}>That address is a staff sign-in</h1>
<p style={sub}>It belongs to a uniform record, not a linen-room account. Your kit, requests and orders live in the staff app.</p>
<button type="button" className="btn btn-primary" onClick={() => window.location.assign("/my/signin")} style={{ marginTop: 20, width: "100%" }}>{arrowBtn("Open the staff sign-in")}</button>
<div style={{ ...small, marginTop: 14 }}>Work in the linen room as well? Ask its admin to add you under Settings Users; the same address can hold both. <button type="button" onClick={() => setStep("password")} style={linkBtn}>I have a linen-room password</button></div>
</div>
);
if (step === "2fa") return (
<form onSubmit={(e) => { e.preventDefault(); void doCode(); }}>
{whoLine}
<h1 style={title}>Two-factor</h1>
<p style={sub}>{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."}</p>
<div style={{ marginTop: 20 }}>
<Field label={recoveryMode ? "Recovery code" : "Code"}>{(c) => (
<input {...c} className="input" inputMode={recoveryMode ? "text" : "numeric"} autoComplete="one-time-code" autoFocus autoCapitalize="characters" spellCheck={false}
value={code} onChange={(e) => { 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 }} />
)}</Field>
</div>
<label style={{ display: "flex", gap: 10, alignItems: "center", fontSize: 13, cursor: "pointer", marginTop: 14 }}>
<input type="checkbox" checked={trust} onChange={(e) => setTrust(e.target.checked)} style={{ width: 16, height: 16, margin: 0 }} />
<span>Trust this computer for 30 days</span>
</label>
{errBox(li.err)}
<button type="submit" className="btn btn-primary" disabled={busy} style={{ marginTop: 16, width: "100%" }}>{arrowBtn(busy ? "Checking…" : "Verify")}</button>
<div style={{ ...small, marginTop: 14, display: "flex", flexDirection: "column", gap: 4 }}>
<button type="button" onClick={() => { setRecoveryMode(!recoveryMode); setCode(""); setLi({ ...li, err: "" }); }} style={{ ...linkBtn, alignSelf: "flex-start" }}>{recoveryMode ? "Use the authenticator app instead" : "Use a recovery code instead"}</button>
<details><summary style={{ cursor: "pointer" }}>Lost the phone and the codes?</summary><div style={{ marginTop: 6 }}>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.</div></details>
</div>
</form>
);
// reset
return (
<div>
<div style={{ ...small, marginBottom: 14 }}><button type="button" onClick={() => setStep("password")} style={linkBtn}> Back to sign in</button></div>
<h1 style={title}>Reset link sent</h1>
<p style={sub}>To <b>{email}</b>. It works once and expires in an hour.</p>
<LiveRegion msg={<>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 }} />
<button type="button" className="btn btn-secondary" onClick={() => void sendReset()} style={{ marginTop: 16, width: "100%" }}>Send it again</button>
<details style={{ ...small, marginTop: 14 }}><summary style={{ cursor: "pointer", fontWeight: 700 }}>You are the only admin and cannot get the mail?</summary><div style={{ marginTop: 6 }}>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.</div></details>
</div>
);
})();
// ---------------- create a facility ----------------
const signupBody = made ? (
<div>
<div style={{ display: "inline-block", fontSize: 10, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", padding: "3px 6px", background: "var(--color-text)", color: "var(--color-bg)" }}>Facility created</div>
<h1 style={{ ...title, marginTop: 12 }}>{su.facility.trim() || "Your facility"} is ready{su.first.trim() ? `, ${su.first.trim()}` : ""}</h1>
<p style={sub}>You are signed in as <b>{made.email}</b>.</p>
<LiveRegion msg={!made.mail
? "No mail is configured on this server, so that address hasnt been checked. Make sure it is right — it is where a password reset would go."
: made.mailed
? "We sent a note to that address. If it does not arrive, the address is wrong — fix it under Settings → Account before you sign out, or a forgotten password locks the facility."
: "We couldnt send a note to that address. Check it is right under Settings → Account before you sign out — otherwise a forgotten password locks the facility."}
style={{ borderLeft: "6px solid var(--color-text)", padding: "8px 12px", fontSize: 12.5, lineHeight: 1.55, color: "var(--color-neutral-800)", marginTop: 12 }} />
<div style={{ marginTop: 20 }}>
{[
[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 rooms phones", "The counter app for the linen room, the staff app for wearers. Both sign in with this facilitys addresses."],
].map(([t, s], i) => (
<div key={t} style={{ display: "flex", gap: 12, alignItems: "flex-start", padding: "12px 0", borderTop: "1px solid var(--color-divider)" }}>
<div style={{ width: 26, height: 26, flex: "none", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 12, border: "2px solid var(--color-text)" }}>{i + 1}</div>
<div><div style={{ fontWeight: 700, fontSize: 14 }}>{t}</div><div style={{ ...small, marginTop: 2 }}>{s}</div></div>
</div>
))}
</div>
<button className="btn btn-primary" style={{ marginTop: 20, width: "100%" }} onClick={() => { router.push("/app?welcome=1"); router.refresh(); }}>{arrowBtn("Open ThreadCount")}</button>
</div>
) : suStep === 1 ? (
<form onSubmit={(e) => { e.preventDefault(); if (step1Ok) { setSu({ ...su, err: "" }); setSuStep(2); } }}>
<div style={{ ...kicker, marginBottom: 10 }}>Step 1 of 3 · You</div>
<h1 style={title}>Who is setting this up?</h1>
<p style={sub}>You will be the facilitys first admin. Everything else can be handed to a colleague later.</p>
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginTop: 20 }}>
<Field label="First name">{(c) => <input {...c} className="input" autoComplete="given-name" autoCapitalize="words" autoFocus value={su.first} onChange={(e) => setSu({ ...su, first: e.target.value, err: "" })} />}</Field>
<Field label="Last name">{(c) => <input {...c} className="input" autoComplete="family-name" autoCapitalize="words" value={su.last} onChange={(e) => setSu({ ...su, last: e.target.value, err: "" })} />}</Field>
<Field label="Work email" style={{ gridColumn: "1/-1" }} hint="Password resets go here, so it has to be right.">{(c) => <input {...c} className="input" type="email" inputMode="email" autoCapitalize="none" autoCorrect="off" spellCheck={false} autoComplete="email" value={su.email} onChange={(e) => setSu({ ...su, email: e.target.value, err: "" })} placeholder="you@yourfacility.org" />}</Field>
<div style={{ gridColumn: "1/-1", position: "relative" }}>
<Field label="Password" hint={su.pw ? `${st.word}. At least 8 characters; a sentence works best.` : "At least 8 characters; a sentence works best."}>{(c) => <input {...c} className="input" type={showSuPw ? "text" : "password"} autoComplete="new-password" value={su.pw} style={{ paddingRight: 56 }} onChange={(e) => setSu({ ...su, pw: e.target.value, err: "" })} />}</Field>
<div style={{ position: "absolute", right: 0, top: 19 }}>{showToggle(showSuPw, setShowSuPw)}</div>
<div aria-hidden style={{ display: "flex", gap: 4, marginTop: 6 }}>{[1, 2, 3, 4].map((n) => <div key={n} style={{ flex: 1, height: 4, background: su.pw.length >= 8 && st.score >= n ? "var(--color-text)" : "var(--color-neutral-300)" }} />)}</div>
</div>
</div>
{errBox(su.err)}
<button type="submit" className="btn btn-primary" disabled={!step1Ok} style={{ marginTop: 20, width: "100%" }}>{arrowBtn("Next: your facility")}</button>
<div style={{ ...small, marginTop: 14 }}>Already set up? <button type="button" onClick={() => { setMode("login"); setStep("email"); }} style={linkBtn}>Sign in</button>. Joining a facility that already uses ThreadCount? Ask its admin to add you under Settings Users instead.</div>
</form>
) : suStep === 2 ? (
<form onSubmit={(e) => { e.preventDefault(); if (step2Ok) setSuStep(3); }}>
<div style={{ ...kicker, marginBottom: 10 }}>Step 2 of 3 · Your facility</div>
<h1 style={title}>Name the facility</h1>
<p style={sub}>It appears on every screen, report and order sheet.</p>
<div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 20 }}>
<Field label="Facility name">{(c) => <input {...c} className="input" autoComplete="organization" autoCapitalize="words" autoFocus value={su.facility} onChange={(e) => setSu({ ...su, facility: e.target.value, err: "" })} placeholder="e.g. St Vincents Private" />}</Field>
<Field label="Setting" hint="Sets the starting staff groups; rename or remove them any time.">{(c) => <select {...c} className="input" value={su.setting} onChange={(e) => setSu({ ...su, setting: e.target.value })}>{SETTINGS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select>}</Field>
<Field label="State or territory" hint="Sets the time zone for counts and month-end, nothing else.">{(c) => <select {...c} className="input" value={su.state} onChange={(e) => setSu({ ...su, state: e.target.value })}>{STATES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select>}</Field>
</div>
<div style={{ display: "flex", gap: 10, marginTop: 20 }}>
<button type="button" className="btn btn-secondary" onClick={() => setSuStep(1)}> Back</button>
<button type="submit" className="btn btn-primary" disabled={!step2Ok} style={{ flex: 1 }}>{arrowBtn(plansLive ? "Next: plan" : "Next: confirm")}</button>
</div>
</form>
) : (
<form onSubmit={(e) => { e.preventDefault(); void doSignup(); }}>
<div style={{ ...kicker, marginBottom: 10 }}>Step 3 of 3 · {plansLive ? "Plan" : "Confirm"}</div>
<h1 style={title}>{plansLive ? "Start free, or start the trial" : "Ready to create it"}</h1>
<p style={sub}>{plansLive ? "Both give you the whole product. No card either way." : `${su.facility.trim()} will be created with you as its first admin.`}</p>
{plansLive && (
<fieldset style={{ border: 0, padding: 0, margin: "20px 0 0" }}>
<legend style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" }}>Plan</legend>
<PlanChoice value={plan} onChange={setPlan} />
</fieldset>
)}
{legal && (
<label style={{ display: "flex", gap: 10, alignItems: "flex-start", fontSize: 12.5, lineHeight: 1.5, marginTop: 16, cursor: "pointer" }}>
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} style={{ width: 16, height: 16, margin: "1px 0 0", flex: "none" }} />
<span>I agree to the {TERMS_URL ? <a href={TERMS_URL} target="_blank" rel="noopener">Terms of use</a> : "Terms of use"}{TERMS_URL && PRIVACY_URL ? " and the " : ""}{PRIVACY_URL ? <a href={PRIVACY_URL} target="_blank" rel="noopener">Privacy policy</a> : null}, and I can act for {su.facility.trim() || "this facility"}.</span>
</label>
)}
<Turnstile action="signup" onToken={setCfToken} quiet />
{cfBox}
{errBox(su.err)}
<div style={{ display: "flex", gap: 10, marginTop: 16 }}>
<button type="button" className="btn btn-secondary" onClick={() => setSuStep(2)}> Back</button>
<button type="submit" className="btn btn-primary" disabled={busy || (legal && !agree)} style={{ flex: 1, minWidth: 0 }}>{arrowBtn(busy ? "Creating…" : `Create ${su.facility.trim() || "the facility"}`, { overflow: "hidden" })}</button>
</div>
</form>
);
return (
<div className="tc-auth" style={{ minHeight: "100vh", display: "grid", gridTemplateColumns: "1fr 1fr", fontFamily: "var(--font-body)", color: "var(--color-text)", background: "var(--color-bg)" }}>
<div className="tc-brandpane" style={{ borderRight: "2px solid var(--color-text)", padding: 48, display: "flex", flexDirection: "column", justifyContent: "space-between", background: "var(--color-surface)" }}>
{brand}
{pane}
{paneFoot}
</div>
<div className="tc-authpane" style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: 32 }}>
<div style={{ width: 400, maxWidth: "100%" }}>
<div className="tc-brandmobile" style={{ display: "none", alignItems: "center", justifyContent: "space-between", gap: 12, paddingBottom: 14, marginBottom: 18, borderBottom: "2px solid var(--color-text)" }}>
<Link href="/" style={{ display: "flex", alignItems: "center", gap: 9, textDecoration: "none", color: "var(--color-text)" }}>
<div style={{ width: 14, height: 14, background: "var(--color-accent)" }} />
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, letterSpacing: "-0.01em" }}>ThreadCount</div>
</Link>
<span style={{ fontSize: 11, color: "var(--color-neutral-700)", textTransform: "uppercase", letterSpacing: "0.08em", textAlign: "right" }}>{mode === "signup" && !made ? `Step ${suStep} of 3` : "Uniform management"}</span>
</div>
{mode === "login" ? loginBody : signupBody}
{HAS_SITE && (
<div className="tc-authfoot" style={{ display: "flex", justifyContent: "space-between", gap: 12, marginTop: 28, paddingTop: 14, borderTop: "1px solid var(--color-divider)", fontSize: 12 }}>
<Link href="/" style={{ fontWeight: 700 }}> threadcount.tech</Link>
<Link href="/demo" style={{ fontWeight: 700 }}>Try the working demo</Link>
</div>
)}
</div>
</div>
</div>
);
}