ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from e2d6d42 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
/* A layout for one metadata block.
|
||||
*
|
||||
* app/reset/page.tsx is a client component — it reads the token out of the query string — and a
|
||||
* client component cannot export metadata, so the page was inheriting the root layout's homepage
|
||||
* title and its canonical. That left a password-reset screen calling itself "ThreadCount — Uniform
|
||||
* management for hospitals, aged care and clinics" in the tab and pointing search engines at the homepage as
|
||||
* its canonical. This is the smallest place to say otherwise, and noindex belongs here anyway: the
|
||||
* page only works with a one-time token from an email.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: "Reset your password",
|
||||
alternates: { canonical: "/reset" },
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function ResetLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
/* Set a new password from an emailed link.
|
||||
*
|
||||
* Deliberately outside both shells: it is reached from an email, by someone who may be on a phone
|
||||
* or a desktop and is by definition not signed in, so it carries its own minimal chrome and works
|
||||
* the same either way. */
|
||||
import Link from "next/link";
|
||||
import { Suspense, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
const INK = "#201e1d";
|
||||
const ACCENT = "#ec3013";
|
||||
const MIN = 8;
|
||||
|
||||
function ResetInner() {
|
||||
const sp = useSearchParams();
|
||||
const token = sp.get("token") || "";
|
||||
const [pw, setPw] = useState("");
|
||||
const [again, setAgain] = useState("");
|
||||
const [show, setShow] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
/* An account with a second factor is not signed in by setting a password.
|
||||
*
|
||||
* /api/auth/reset answers `{ need2fa, ticket }` in exactly the shape /api/auth/login does, and
|
||||
* for the same reason: the password is only the first of two things. Without this step the new
|
||||
* password was saved, no cookie was issued, and the redirect to /app bounced straight back to
|
||||
* /auth with nothing said — which reads as "the reset didn't work" and sends people round again.
|
||||
*/
|
||||
const [ticket, setTicket] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
|
||||
const input: React.CSSProperties = {
|
||||
width: "100%", minHeight: 50, padding: "10px 12px", border: "2px solid " + INK,
|
||||
background: "#fff", fontSize: 16, fontWeight: 600, borderRadius: 0,
|
||||
};
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setErr("");
|
||||
if (pw.length < MIN) { setErr(`Use at least ${MIN} characters.`); return; }
|
||||
if (pw !== again) { setErr("The two passwords don't match."); return; }
|
||||
setBusy(true);
|
||||
/* try/finally, because without it a dropped connection left the button disabled reading
|
||||
* "Setting…" for good: the fetch rejects, the line that clears `busy` never runs, and the only
|
||||
* way on is to open the emailed link again — from a page that gives no hint that is what
|
||||
* happened. The reset may well have gone through, so the message says so rather than promising
|
||||
* nothing changed. */
|
||||
try {
|
||||
const r = await fetch("/api/auth/reset", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ token, password: pw }),
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) { setErr(j.error || "That didn't work. Ask for a new link."); return; }
|
||||
// Password saved, but the account carries a second factor — nothing is signed in yet.
|
||||
if (j.need2fa) { setTicket(j.ticket); return; }
|
||||
// A full navigation: the session cookie has just been set and every page is server-rendered.
|
||||
window.location.replace("/app");
|
||||
} catch {
|
||||
setErr("No connection — try again. If the new password works, it was already set.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCode(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setErr("");
|
||||
if (!code.trim()) { setErr("Enter the code from your authenticator app."); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await fetch("/api/auth/2fa", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ticket, code }),
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
setErr(j.error || "That code isn't right.");
|
||||
// 400 is an expired or spent ticket. The password is already changed, so the way back is a
|
||||
// fresh sign-in rather than another reset link.
|
||||
if (r.status === 400) { setTicket(""); setCode(""); }
|
||||
return;
|
||||
}
|
||||
window.location.replace("/app");
|
||||
} catch {
|
||||
setErr("No connection — check the network and try the code again.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<p style={p}>
|
||||
That link is incomplete — reset links only work in full. Copy the whole link out of the
|
||||
email, or <Link href="/auth" style={{ color: ACCENT, fontWeight: 700 }}>ask for a new one</Link>.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (ticket) {
|
||||
return (
|
||||
<>
|
||||
<p style={p}>
|
||||
Your new password is set. This account has two-factor turned on, so one more step: the
|
||||
six-digit code from your authenticator app, or a recovery code if you no longer have the
|
||||
phone.
|
||||
</p>
|
||||
<form onSubmit={submitCode} style={{ marginTop: 26, display: "grid", gap: 14 }}>
|
||||
<label style={{ display: "block" }}>
|
||||
<span style={lab}>Code</span>
|
||||
<input inputMode="numeric" autoComplete="one-time-code" autoFocus value={code}
|
||||
onChange={(e) => { setCode(e.target.value); setErr(""); }} placeholder="000000" style={input} />
|
||||
</label>
|
||||
{err && <div role="alert" style={{ background: ACCENT, color: "#fff", padding: "10px 12px", fontSize: 13.5, fontWeight: 600 }}>{err}</div>}
|
||||
<button type="submit" disabled={busy}
|
||||
style={{ minHeight: 56, background: ACCENT, color: "#fff", border: 0, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase", cursor: busy ? "wait" : "pointer", opacity: busy ? 0.6 : 1, textAlign: "left", padding: "0 20px" }}>
|
||||
{busy ? "Checking…" : "Verify and sign in"}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p style={p}>
|
||||
At least {MIN} characters. Setting it signs out anywhere else your account was already open,
|
||||
and signs you in here — after your authenticator code, if you have two-factor turned on.
|
||||
</p>
|
||||
<form onSubmit={submit} style={{ marginTop: 26, display: "grid", gap: 14 }}>
|
||||
<label style={{ display: "block" }}>
|
||||
<span style={lab}>New password</span>
|
||||
<input type={show ? "text" : "password"} value={pw} autoComplete="new-password" autoFocus
|
||||
onChange={(e) => { setPw(e.target.value); setErr(""); }} style={input} />
|
||||
</label>
|
||||
<label style={{ display: "block" }}>
|
||||
<span style={lab}>Type it again</span>
|
||||
<input type={show ? "text" : "password"} value={again} autoComplete="new-password"
|
||||
onChange={(e) => { setAgain(e.target.value); setErr(""); }} style={input} />
|
||||
</label>
|
||||
<button type="button" onClick={() => setShow(!show)}
|
||||
style={{ justifySelf: "start", background: "none", border: 0, padding: 0, font: "inherit", fontSize: 13, fontWeight: 700, color: ACCENT, cursor: "pointer" }}>
|
||||
{show ? "Hide" : "Show"} password
|
||||
</button>
|
||||
{err && <div role="alert" style={{ background: ACCENT, color: "#fff", padding: "10px 12px", fontSize: 13.5, fontWeight: 600 }}>{err}</div>}
|
||||
<button type="submit" disabled={busy}
|
||||
style={{ minHeight: 56, background: ACCENT, color: "#fff", border: 0, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase", cursor: busy ? "wait" : "pointer", opacity: busy ? 0.6 : 1, textAlign: "left", padding: "0 20px" }}>
|
||||
{busy ? "Setting…" : "Set password and sign in"}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const kicker: React.CSSProperties = { fontSize: 12, letterSpacing: "0.15em", textTransform: "uppercase", fontWeight: 800, color: ACCENT };
|
||||
const h1: React.CSSProperties = { fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(28px,5vw,44px)", lineHeight: 1.05, letterSpacing: "-0.03em", margin: "14px 0 0" };
|
||||
const p: React.CSSProperties = { fontSize: 15.5, lineHeight: 1.65, color: "var(--color-neutral-800)", margin: "16px 0 0", maxWidth: "46ch" };
|
||||
const lab: React.CSSProperties = { display: "block", fontSize: 11, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginBottom: 6 };
|
||||
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ fontFamily: "var(--font-body)", color: INK, background: "var(--color-bg)", minHeight: "100vh", display: "flex", alignItems: "center" }}>
|
||||
{/* The page is nothing but this form, so the card is the main landmark and there is no
|
||||
repeated block in front of it for a skip link to bypass. */}
|
||||
<main style={{ maxWidth: 460, margin: "0 auto", padding: "clamp(32px,6vw,64px) 24px", width: "100%" }}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResetPage() {
|
||||
// The heading is outside the boundary on purpose: useSearchParams suspends, so anything inside
|
||||
// it is absent from the server HTML. This way the page says what it is immediately.
|
||||
return (
|
||||
<Shell>
|
||||
<div style={kicker}>ThreadCount</div>
|
||||
<h1 style={h1}>Reset your password.</h1>
|
||||
<Suspense fallback={<p style={p}>One moment…</p>}>
|
||||
<ResetInner />
|
||||
</Suspense>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user