Files
threadcount-community/app/ops/(console)/security/page.tsx
T
ThreadCount 344b1701dd ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
2026-09-13 08:54:35 +10:00

102 lines
5.8 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 { useEffect, useState } from "react";
/* An operator's own second factor.
*
* Three steps, the same as a coordinator's: set up (a secret and a QR, stored but not yet in
* force), enable (prove one code from it works — only then does the password stop being enough),
* and the recovery codes, shown exactly once. Turning it off or reissuing the codes asks for the
* password, because both are privileged.
*
* The QR arrives from the server as SVG generated by the product's own QR library from a secret
* that never has to reach client-side code; rendering it as markup is the same trust as rendering
* any other server response. */
export const dynamic = "force-dynamic";
type Status = { enabled: boolean; recoveryLeft: number; viaSso: boolean };
export default function OpsSecurity() {
const [st, setSt] = useState<Status | null>(null);
const [qr, setQr] = useState("");
const [secret, setSecret] = useState("");
const [code, setCode] = useState("");
const [pw, setPw] = useState("");
const [codes, setCodes] = useState<string[] | null>(null);
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
async function load() {
const r = await fetch("/api/ops/auth/totp").catch(() => null);
const j = await r?.json().catch(() => null);
if (j && typeof j.enabled === "boolean") setSt(j);
}
useEffect(() => { void load(); }, []);
async function act(action: string, extra: Record<string, string> = {}) {
setBusy(true); setErr("");
try {
const r = await fetch("/api/ops/auth/totp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action, ...extra }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) { setErr(j.error || "That didnt work."); return null; }
return j;
} catch {
setErr("No connection — check the network and try again."); return null;
} finally {
setBusy(false);
}
}
const label: React.CSSProperties = { display: "block", fontSize: 11, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)", margin: "14px 0 6px" };
const p: React.CSSProperties = { fontSize: 13.5, color: "var(--color-neutral-800)", lineHeight: 1.6, margin: "8px 0 0" };
return (
<div style={{ maxWidth: 560, margin: "48px auto", padding: "0 16px" }}>
<div className="sec">Operations console</div>
<h1 className="h1" style={{ marginTop: 12 }}>Your sign-in</h1>
<p style={p}><a href="/ops"> Back</a></p>
{!st && <p style={p}>Loading</p>}
{st && !st.enabled && !qr && (
<>
<p style={p}>The password door has no second factor yet. Until it does, a password alone opens this console and that password has been typed into more places than it should have been.</p>
<button className="btn btn-primary" disabled={busy} style={{ marginTop: 16 }} onClick={async () => { const j = await act("setup"); if (j) { setQr(j.qr); setSecret(j.secret); } }}>Set up an authenticator</button>
</>
)}
{qr && !codes && (
<>
<p style={p}>Scan this with your authenticator app, then enter the current code to switch it on.</p>
<div style={{ marginTop: 12, width: 220, border: "2px solid var(--color-text)", background: "#fff" }} dangerouslySetInnerHTML={{ __html: qr }} />
<p style={{ ...p, fontFamily: "ui-monospace, Menlo, monospace", fontSize: 12, wordBreak: "break-all" }}>{secret}</p>
<label style={label} htmlFor="ops-totp">Code from the app</label>
<input id="ops-totp" className="input" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => { setCode(e.target.value); setErr(""); }} placeholder="000000" style={{ width: "100%" }} />
<button className="btn btn-primary" disabled={busy || !code.trim()} style={{ marginTop: 16 }} onClick={async () => { const j = await act("enable", { code }); if (j) { setCodes(j.codes); setQr(""); await load(); } }}>Turn it on</button>
</>
)}
{codes && (
<>
<p style={p}><b>Recovery codes.</b> Each works once, for the day the phone is lost. They are shown now and never again write them down somewhere that is not this screen.</p>
<pre style={{ marginTop: 12, padding: 12, border: "2px solid var(--color-text)", background: "var(--color-surface)", fontSize: 13, lineHeight: 1.7 }}>{codes.join("\n")}</pre>
<button className="btn" style={{ marginTop: 12 }} onClick={() => { setCodes(null); setCode(""); }}>I have saved these</button>
</>
)}
{st && st.enabled && !codes && (
<>
<p style={p}>A second factor is on. {st.recoveryLeft} recovery {st.recoveryLeft === 1 ? "code" : "codes"} unused.{st.viaSso ? " This session came in by single sign-on." : ""}</p>
<label style={label} htmlFor="ops-pw">Password, to change either of these</label>
<input id="ops-pw" className="input" type="password" autoComplete="current-password" value={pw} onChange={(e) => { setPw(e.target.value); setErr(""); }} style={{ width: "100%" }} />
<div style={{ display: "flex", gap: 8, marginTop: 16, flexWrap: "wrap" }}>
<button className="btn" disabled={busy || !pw} onClick={async () => { const j = await act("regenerate", { password: pw }); if (j) { setCodes(j.codes); setPw(""); await load(); } }}>New recovery codes</button>
<button className="btn" disabled={busy || !pw} onClick={async () => { const j = await act("disable", { password: pw }); if (j) { setPw(""); await load(); } }}>Turn it off</button>
</div>
</>
)}
{err && <div role="alert" style={{ border: "2px solid var(--color-accent)", padding: "8px 12px", fontSize: 13, fontWeight: 600, color: "var(--color-accent-700)", marginTop: 12 }}>{err}</div>}
</div>
);
}