344b1701dd
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
102 lines
5.8 KiB
TypeScript
102 lines
5.8 KiB
TypeScript
"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 didn’t 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>
|
||
);
|
||
}
|