ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
/* The break-glass door.
|
||||
*
|
||||
* Single sign-on through Cloudflare Access and Authentik is the front door (page.tsx hands off to
|
||||
* it); this password form is the fire escape, for the day one of those is down. So it must not
|
||||
* depend on either of them — which is why there is no Turnstile here. The sitekey is bound to the
|
||||
* product's domain and compiled in at build time; on this hostname the widget would refuse and
|
||||
* report a generic "security check failed", discovered during the exact incident in which this
|
||||
* form is needed. The route behind it rate-limits failures under its own keys instead.
|
||||
*
|
||||
* A full navigation on success, not a router push: /ops is server-rendered behind the cookie
|
||||
* that was just set, and a prefetched copy fetched before it existed would land a signed-in
|
||||
* person on the sign-in screen. */
|
||||
export default function LoginForm({ ssoFailed }: { ssoFailed: boolean }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [pw, setPw] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [needCode, setNeedCode] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!email.trim() || !pw) { setErr("Enter your email and password."); return; }
|
||||
setBusy(true); setErr("");
|
||||
try {
|
||||
const r = await fetch("/api/ops/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, password: pw, code: code || undefined }) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
if (j.needCode) { setNeedCode(true); setErr(j.error || "Enter the code from your authenticator app."); return; }
|
||||
setErr(j.error || "Email or password doesn’t match."); return;
|
||||
}
|
||||
window.location.assign("/ops");
|
||||
} catch {
|
||||
setErr("No connection — check the network and try again.");
|
||||
} 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" };
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 400, margin: "64px auto", padding: "0 16px" }}>
|
||||
<div className="sec">Operations console</div>
|
||||
<h1 className="h1" style={{ marginTop: 12 }}>Sign in</h1>
|
||||
{ssoFailed && (
|
||||
<div role="alert" style={{ border: "2px solid var(--color-accent)", padding: "8px 12px", fontSize: 13, fontWeight: 600, color: "var(--color-accent-700)", marginTop: 12 }}>
|
||||
Single sign-on could not complete. Sign in with your operator password below.
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={submit}>
|
||||
<label style={label} htmlFor="ops-email">Email</label>
|
||||
<input id="ops-email" className="input" type="email" autoComplete="username" autoCapitalize="none" spellCheck={false} value={email} onChange={(e) => { setEmail(e.target.value); setErr(""); }} style={{ width: "100%" }} />
|
||||
<label style={label} htmlFor="ops-pw">Password</label>
|
||||
<input id="ops-pw" className="input" type="password" autoComplete="current-password" value={pw} onChange={(e) => { setPw(e.target.value); setErr(""); }} style={{ width: "100%" }} />
|
||||
{needCode && (
|
||||
<>
|
||||
<label style={label} htmlFor="ops-code">Authenticator code</label>
|
||||
<input id="ops-code" className="input" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => { setCode(e.target.value); setErr(""); }} placeholder="000000" style={{ width: "100%" }} />
|
||||
</>
|
||||
)}
|
||||
{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>}
|
||||
<button className="btn btn-primary" type="submit" disabled={busy} style={{ marginTop: 16, width: "100%" }}>{busy ? "Signing in…" : "Sign in"}</button>
|
||||
</form>
|
||||
<p style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: 16, lineHeight: 1.6 }}>
|
||||
This is the password door. Single sign-on is the usual way in; use this when it isn’t available.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user