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 49da3a4 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
"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 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 = "", 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 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) => (
|
||||
<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 facility’s 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'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’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 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."}
|
||||
</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'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 Vincent’s 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user