"use client"; /* Turning a second factor on, from your own account settings. * * Optional, with admins prompted rather than forced. Issuers are casual counter users on shared * ward phones, where mandatory TOTP gets worked around — shared logins, codes written on the wall * — which is worse than not having it. Admins can change pricing, delete records and wipe the * facility, so they get a standing nudge. * * Recovery codes are shown exactly once, at the moment they are created, because they are stored * hashed. The copy says so plainly: an admin locked out with no codes is a locked-out facility, * since deleting the last admin deletes everything. */ import { useCallback, useEffect, useState } from "react"; import { Field } from "@/components/ui"; type Status = { enabled: boolean; enabledAt: string | null; recoveryLeft: number }; export default function TwoFactor({ isAdmin }: { isAdmin: boolean }) { const [st, setSt] = useState(null); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); const [setup, setSetup] = useState<{ secret: string; qr: string } | null>(null); const [code, setCode] = useState(""); const [pw, setPw] = useState(""); const [codes, setCodes] = useState(null); const [confirming, setConfirming] = useState<"disable" | "regenerate" | null>(null); /* A failed status fetch used to render nothing at all. * * With no catch and no error state, `if (!st) return null` meant a dropped connection or a 500 * deleted the whole two-factor section out of Settings → Account — silently, so somebody who * came here to turn 2FA on found no control and no explanation, and the honest conclusion is * that ThreadCount doesn't offer it. Saying so and offering the retry is the difference between * a hiccup and a feature that appears not to exist. */ const [loadErr, setLoadErr] = useState(""); const load = useCallback(async () => { setLoadErr(""); try { const r = await fetch("/api/2fa"); if (!r.ok) { const j = await r.json().catch(() => ({})); setLoadErr(j.error || "Couldn’t check whether two-factor is on."); return; } setSt(await r.json()); } catch { setLoadErr("Couldn’t reach the server, so we can’t say whether two-factor is on."); } }, []); useEffect(() => { void load(); }, [load]); async function post(body: Record) { setBusy(true); setErr(""); const r = await fetch("/api/2fa", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); const j = await r.json().catch(() => ({})); setBusy(false); if (!r.ok) { setErr(j.error || "That didn’t work."); return null; } return j; } const box: React.CSSProperties = { border: "2px solid var(--color-text)", padding: 20, marginTop: 16 }; if (loadErr) { return (
Two-factor authentication

{loadErr}

); } if (!st) return null; // Shown once, immediately after enabling or regenerating. if (codes) { return (
Save these recovery codes

Each works once, in place of a code from your app. This is the only time they can be shown — they are stored hashed, so nobody, including us, can read them back. Print them or put them somewhere you would still reach without your phone.

{codes.map((c) =>
{c}
)}
); } if (setup) { return (
Scan this with your authenticator

Any authenticator app will do. Then type the six-digit code it shows to prove it worked — nothing changes until you do.

Or type it in
{setup.secret}
{(c) => ( { setCode(e.target.value); setErr(""); }} /> )} {err &&
{err}
}
); } return (
Two-factor authentication
{st.enabled ? "On" : "Off"}
{st.enabled ? ( <>

Signing in asks for a code from your authenticator app as well as your password.{" "} {st.recoveryLeft} recovery code{st.recoveryLeft === 1 ? "" : "s"} left. {st.recoveryLeft <= 2 && " Worth generating a fresh set."}

{confirming ? (
{(c) => ( { setPw(e.target.value); setErr(""); }} /> )} {err &&
{err}
}
) : (
)} ) : ( <>

A code from your phone as well as your password. ThreadCount holds names, payroll numbers and phone numbers for every person on your register, and a password on its own is thin protection for that.

{isAdmin && (

You are an admin — you can change pricing, delete records and delete the facility. Worth turning on.

)} {err &&
{err}
} )}
); }