1bc2de655a
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
74 lines
4.2 KiB
TypeScript
74 lines
4.2 KiB
TypeScript
"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>
|
||
);
|
||
}
|