ThreadCount Community edition

Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
ThreadCount
2026-09-13 08:54:35 +10:00
commit 344b1701dd
505 changed files with 56231 additions and 0 deletions
+168
View File
@@ -0,0 +1,168 @@
"use client";
/* Create account — onboarding screen 04. This creates a whole facility, which is why it asks for
the hospital's name: the person signing up becomes its first administrator. */
import Link from "next/link";
import { useState } from "react";
import Turnstile, { awaitTurnstile, resetTurnstile, turnstileOn } from "@/components/Turnstile";
import { track } from "@/lib/analytics";
import { MAuthError, MAuthFooter, MAuthHeader, MField, MShowHide, authInput, authLink } from "@/components/MAuth";
import PlanChoice from "@/components/PlanChoice";
/* `plansLive` comes from the server wrapper at app/m/signup/page.tsx: while it is false the screen
says free and asks nothing about plans; once true it offers the two hosted plans. */
export default function MSignup({ plansLive }: { plansLive: boolean }) {
const [plan, setPlan] = useState<"hosted_small" | "hosted_facility">("hosted_small");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [facility, setFacility] = useState("");
const [pw, setPw] = useState("");
const [show, setShow] = useState(false);
const [agree, setAgree] = useState(false);
const [cfToken, setCfToken] = useState("");
const [busy, setBusy] = useState(false);
const [reveal, setReveal] = useState(false);
const [err, setErr] = 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 emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
// The API wants first and last separately; the design asks for one "Full name" field, which is
// the kinder question. Split on the last space and keep whatever they typed.
const parts = name.trim().split(/\s+/);
const first = parts.length > 1 ? parts.slice(0, -1).join(" ") : parts[0] || "";
const last = parts.length > 1 ? parts[parts.length - 1] : "";
const ready = !!(first && last && emailOk && facility.trim() && pw.length >= 8 && agree);
async function submit() {
if (!ready) {
setErr(!name.trim() ? "Enter your name." : !emailOk ? "That email doesnt look right."
: !last ? "Enter your first and last name." : !facility.trim() ? "Which hospital is this for?"
: pw.length < 8 ? "Password must be at least 8 characters." : "Tick the box to continue.");
return;
}
setBusy(true);
// The widget draws nothing in quiet mode, so nobody can see that it hasn't finished. Wait for
// the token rather than posting an empty one and blaming the person for it.
const token = cfToken || (turnstileOn() ? await awaitTurnstile() : "");
if (turnstileOn() && !token) {
// Eight seconds and no token. Either the check needs an interaction we've asked Turnstile
// not to draw, or it couldn't reach Cloudflare at all. Show the widget rather than send an
// empty token and let the server answer with a check the person was never shown.
setBusy(false);
setReveal(true);
track("security_check_shown", { screen: "signup" });
setErr("Finish the security check below, then try again.");
return;
}
const r = await fetch("/api/auth/signup", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ first, last, facility, email, password: pw, cfToken: token, ...(plansLive ? { plan } : {}) }),
}).catch(() => null);
const j = r ? await r.json().catch(() => ({})) : {};
setBusy(false);
/* The answer never came back, which is not the same as nothing having happened: the request may
well have reached the server and made the facility before the connection went. Without this
the promise rejected and the Create button stayed disabled on its spinner for ever. Trying
again is safe — a second attempt on an address that did get through is refused as already
having an account, which is itself the answer. */
if (!r) {
track("signup_failed", { reason: "network" });
setErr("Couldnt reach the server, so we cant say whether the account was made. Try again — if it was, youll be told the email is already taken.");
setCfToken(""); resetTurnstile();
return;
}
if (!r.ok) {
track("signup_failed", { reason: r.status === 429 ? "throttled" : r.status === 400 ? "rejected" : "other" });
setErr(j.error || "Couldnt create the account."); setCfToken(""); resetTurnstile(); return;
}
// A facility created itself from a phone — the whole point of building sign-up into the app.
track("signup_completed");
/* Show the address back before going anywhere.
*
* The account is made and signed in either way. 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 it is put in front of the person once, with what
* actually happened to it. */
setMade({ email: String(j.email || email), mailed: !!j.mailed, mail: j.mail !== false });
}
if (made) {
return (
<>
<MAuthHeader kicker="Your facility is set up" title={<>Facility<br />created</>} back={false} />
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
<div style={{ flex: 1, overflowY: "auto", padding: "30px 24px", display: "grid", gap: 18, alignContent: "start" }}>
<div style={{ fontSize: 15, lineHeight: 1.6, color: "var(--color-text)" }}>
You&rsquo;re signed in as <b>{made.email}</b>.
</div>
<div style={{ fontSize: 14, lineHeight: 1.6, color: "var(--color-neutral-700)" }}>
{!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."}
</div>
</div>
<MAuthFooter
secondary={<>A second admin under Settings is the way back in if this account is ever locked out.</>}
label="Start" onSubmit={() => window.location.replace("/m/signed-in?new=1")} busy={false} />
</>
);
}
return (
<>
<MAuthHeader kicker={plansLive ? "No card, no seat limit" : "Free — no card, no seat limit"} title={<>Create<br />account</>} />
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
<div style={{ flex: 1, overflowY: "auto", padding: "30px 24px", display: "grid", gap: 24, alignContent: "start" }}>
<MAuthError msg={err} />
<MField n="01" label="Full name">
{(c) => (
<input {...c} style={authInput} autoComplete="name" enterKeyHint="next" placeholder="Sam Whitfield"
value={name} onChange={(e) => { setName(e.target.value); setErr(""); }} />
)}
</MField>
<MField n="02" label="Work email">
{(c) => (
<input {...c} style={authInput} type="email" inputMode="email" autoCapitalize="none" autoCorrect="off"
spellCheck={false} autoComplete="email" enterKeyHint="next" placeholder="you@yourfacility.org"
value={email} onChange={(e) => { setEmail(e.target.value); setErr(""); }} />
)}
</MField>
<MField n="03" label="Hospital or facility">
{(c) => (
<input {...c} style={authInput} autoComplete="organization" enterKeyHint="next" placeholder="Riverside General"
value={facility} onChange={(e) => { setFacility(e.target.value); setErr(""); }} />
)}
</MField>
{plansLive && (
<fieldset style={{ border: 0, padding: 0, margin: 0 }}>
<legend style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginBottom: 8 }}>Plan</legend>
<PlanChoice value={plan} onChange={setPlan} />
</fieldset>
)}
<MField n="04" label="Password" right={<MShowHide on={show} onToggle={() => setShow(!show)} />}>
{(c) => (
<input {...c} style={authInput} type={show ? "text" : "password"} autoComplete="new-password"
enterKeyHint="go" placeholder="8 characters minimum" value={pw}
onChange={(e) => { setPw(e.target.value); setErr(""); }}
onKeyDown={(e) => { if (e.key === "Enter") submit(); }} />
)}
</MField>
<label style={{ display: "flex", gap: 12, alignItems: "flex-start", background: "#fff", borderLeft: "6px solid var(--color-text)", padding: 14, cursor: "pointer" }}>
<input type="checkbox" checked={agree} onChange={(e) => { setAgree(e.target.checked); setErr(""); }}
style={{ width: 20, height: 20, accentColor: "var(--color-accent)", flex: "0 0 20px", marginTop: 1 }} />
<span style={{ fontSize: 13, lineHeight: 1.5, color: "var(--color-neutral-800)" }}>
I agree to the <Link href="/terms" style={authLink}>terms of use</Link> and the{" "}
<Link href="/privacy" style={authLink}>privacy notice</Link>.
</span>
</label>
{turnstileOn() && <Turnstile onToken={setCfToken} action="signup" quiet={!reveal} />}
</div>
<MAuthFooter
secondary={<>Already have one? <Link href="/m/login" style={authLink}>Sign in</Link></>}
label="Create account" onSubmit={submit} busy={busy} />
</>
);
}