Files
threadcount-community/components/AuthForm.tsx
T
ThreadCount cbf533d90b 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 440e645 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 12:12:58 +10:00

354 lines
25 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 } from "@/lib/links";
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<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.",
};
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 }) {
// Which hosted plan a new facility starts on, asked only once plans are live (lib/plans-live.ts).
const [plan, setPlan] = useState<SignupPlan>("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) {
if (!ssoOffered) return;
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<string> {
if (!turnstileOn()) return "";
const t = cfToken || await awaitTurnstile();
setCfSlow(!t);
return t;
}
/** Always reports the same thing, so the reply cant 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 didnt 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 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: "" }); 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 isnt 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 || "Couldnt 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) => (
<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 }} />
);
// The same panel, quieter: this one is a warning about the check, not a refusal of the sign-in.
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, marginTop: 24, letterSpacing: "-0.01em" };
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)" }}>
<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>
<div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 42, lineHeight: 1.05, letterSpacing: "-0.02em", textWrap: "balance" as never }}>Stock that adds up, from day one.</div>
<div style={{ marginTop: 20, display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }}>
{POINTS.map((pt) => (
<div key={pt} style={{ display: "flex", gap: 12, alignItems: "baseline", fontSize: 14, 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>
<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>
</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" }}>Uniform management</span>
</div>
{signupsOpen && (
<div className="seg" role="group" aria-label="Log in or create an account" style={{ display: "flex" }}>
{/* Which pane you are on is otherwise carried by a red fill alone. */}
<button className={"seg-opt" + (tab === "login" ? " btn-primary" : "")} aria-pressed={tab === "login"} style={{ flex: 1 }} onClick={() => setTab("login")}>Log in</button>
<button className={"seg-opt" + (tab === "signup" ? " btn-primary" : "")} aria-pressed={tab === "signup"} style={{ flex: 1 }} onClick={() => setTab("signup")}>Create account</button>
</div>
)}
{tab === "login" ? (
ticket ? (
<form onSubmit={(e) => { e.preventDefault(); doCode(); }}>
<h1 style={title}>Two-factor</h1>
<p style={{ fontSize: 13.5, color: "var(--color-neutral-800)", marginTop: 8, lineHeight: 1.6 }}>
Your password was right. Enter the six-digit code from your authenticator app or a
recovery code if you no longer have the phone.
</p>
<Field label="Code" style={{ marginTop: 16 }}>
{(c) => (
<input {...c} className="input" inputMode="numeric" autoComplete="one-time-code" autoFocus
value={code} onChange={(e) => { setCode(e.target.value); setLi({ ...li, err: "" }); }}
placeholder="000000" />
)}
</Field>
{errBox(li.err)}
<button type="submit" className="btn btn-primary" disabled={busy} style={{ marginTop: 16, width: "100%" }}>{busy ? "Checking…" : "Verify"}</button>
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: 12 }}>
<a href="#" onClick={(e) => { e.preventDefault(); setTicket(""); setCode(""); setLi({ ...li, err: "" }); }}>Start again</a>
</div>
</form>
) : (
<form onSubmit={(e) => { 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. */}
<h1 style={title}>Log in</h1>
<div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 16 }}>
<Field label="Work email">{(c) => <input {...c} className="input" type="email" inputMode="email" autoCapitalize="none" autoCorrect="off" spellCheck={false} enterKeyHint="next" autoComplete="email" value={li.email} onChange={(e) => setLi({ ...li, email: e.target.value, err: "" })} onBlur={(e) => void lookupSso(e.target.value)} placeholder="you@yourfacility.org" />}</Field>
{sso?.on && sso.email === li.email.trim().toLowerCase() && (
<div style={{ border: "2px solid var(--color-text)", padding: "10px 12px", display: "grid", gap: 8 }}>
<div style={{ fontSize: 13, lineHeight: 1.5 }}>{sso.required ? <><b>{sso.facility}</b> signs in with single sign-on.</> : <><b>{sso.facility}</b> offers single sign-on.</>}</div>
<button type="button" className="btn btn-primary" onClick={goSso} style={{ width: "100%" }}>Continue with single sign-on</button>
{sso.required && <div style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>The password below is for the facilitys fire-escape admin only.</div>}
</div>
)}
<Field label="Password">{(c) => <input {...c} className="input" type="password" enterKeyHint="go" autoComplete="current-password" value={li.pw} onChange={(e) => setLi({ ...li, pw: e.target.value, err: "" })} onKeyDown={(e) => { if (e.key === "Enter") doLogin(); }} placeholder="••••••••" />}</Field>
</div>
<Turnstile action="login" onToken={setCfToken} />
{cfBox}
{errBox(li.err)}
{/* Disabled while the request is in flight, and at no other time. */}
<button type="submit" className="btn btn-primary" disabled={busy} style={{ marginTop: 16, width: "100%" }}>{busy ? "Logging in…" : "Log in"}</button>
{/* "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. */}
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: 12, lineHeight: 1.6 }}>
{sentReset
? <span>If that address has an account, a reset link is on its way. It works once and expires in an hour.</span>
: <>Forgot your password? <a href="#" onClick={(e) => { e.preventDefault(); void sendReset(); }} style={{ fontWeight: 700 }}>Email me a reset link</a>.</>}
{signupsOpen && <> New here? <a href="#" onClick={(e) => { e.preventDefault(); setTab("signup"); }}>Create your facility&apos;s account</a>.</>}
</div>
</form>
)
) : made ? (
<div>
<h1 style={title}>Facility created</h1>
<p style={{ fontSize: 13.5, color: "var(--color-neutral-800)", marginTop: 8, lineHeight: 1.6 }}>
You&rsquo;re signed in as <b>{made.email}</b>.
</p>
<p style={{ fontSize: 13.5, color: "var(--color-neutral-800)", marginTop: 10, lineHeight: 1.6 }}>
{!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
? "Weve sent a note there — that is the address a password reset goes to. If it doesnt arrive, the address is wrong: add a second admin under Settings → Users while youre still signed in."
: "We couldnt send a note to that address. Check it is right, and add a second admin under Settings → Users while youre still signed in — otherwise a forgotten password locks the facility out."}
</p>
<button className="btn btn-primary" style={{ marginTop: 18, width: "100%" }}
onClick={() => { router.push("/app?welcome=1"); router.refresh(); }}>Open ThreadCount</button>
</div>
) : (
<form onSubmit={(e) => { e.preventDefault(); doSignup(); }}>
<h1 style={title}>Create account</h1>
<div style={{ fontSize: 13, color: "var(--color-neutral-700)", marginTop: 4 }}>One account per facility. You&apos;ll be its first admin.</div>
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginTop: 16 }}>
<Field label="First name">{(c) => <input {...c} className="input" autoComplete="given-name" autoCapitalize="words" 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="Facility name" style={{ gridColumn: "1/-1" }}>{(c) => <input {...c} className="input" autoComplete="organization" autoCapitalize="words" value={su.facility} onChange={(e) => setSu({ ...su, facility: e.target.value, err: "" })} placeholder="e.g. St Vincents Private" />}</Field>
<Field label="Work email" style={{ gridColumn: "1/-1" }}>{(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>
<Field label="Password">{(c) => <input {...c} className="input" type="password" autoComplete="new-password" value={su.pw} onChange={(e) => setSu({ ...su, pw: e.target.value, err: "" })} />}</Field>
<Field label="Confirm">{(c) => <input {...c} className="input" type="password" autoComplete="new-password" value={su.pw2} onChange={(e) => setSu({ ...su, pw2: e.target.value, err: "" })} />}</Field>
</div>
{plansLive && (
<fieldset style={{ border: 0, padding: 0, margin: "14px 0 0" }}>
<legend style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginBottom: 8 }}>Plan</legend>
<PlanChoice value={plan} onChange={setPlan} />
</fieldset>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 4, marginTop: 10 }}>
{checks.map((c) => (
<div key={c.label} style={{ display: "flex", gap: 8, alignItems: "center", fontSize: 12, color: c.ok ? "var(--color-text)" : "var(--color-neutral-600)" }}><b style={{ width: 14 }}>{c.ok ? "✓" : "·"}</b>{c.label}</div>
))}
</div>
<Turnstile action="signup" onToken={setCfToken} />
{cfBox}
{errBox(su.err)}
<button type="submit" className="btn btn-primary" disabled={busy || !(named && emailOk && pwMatch)} style={{ marginTop: 16, width: "100%" }}>{busy ? "Creating…" : "Create account"}</button>
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: 12 }}>Already set up? <a href="#" onClick={(e) => { e.preventDefault(); setTab("login"); }}>Log in</a>.</div>
</form>
)}
<div className="tc-authfoot" style={{ display: "none", justifyContent: "space-between", gap: 12, marginTop: 28, paddingTop: 14, borderTop: "1px solid var(--color-divider)", fontSize: 12 }}>
{HAS_SITE && <Link href="/" style={{ fontWeight: 700 }}> threadcount.tech</Link>}
{HAS_SITE && <Link href="/demo" style={{ fontWeight: 700 }}>Try the working demo</Link>}
</div>
</div>
</div>
</div>
);
}