Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 794bab5 on 2026-09-16. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
/* 1J — Account.
|
||||
*
|
||||
* What a wearer can do to their own sign-in, and one thing they deliberately cannot.
|
||||
*
|
||||
* Changing the password is the only revocation they have: a staff token carries a fingerprint of
|
||||
* the password hash, so setting a new one ends every session signed against the old one at once —
|
||||
* a phone left on a ward, a cookie copied off it, a password read over somebody's shoulder. The
|
||||
* copy says so plainly, because the consequence is the feature and somebody who does not know it
|
||||
* happens will not reach for this when they most need it.
|
||||
*
|
||||
* Deleting the account is not here, and is not an oversight. Access is the linen room's to grant
|
||||
* and theirs to remove: a wearer who could delete their own account would take the record of what
|
||||
* they were issued with it.
|
||||
*/
|
||||
import { DELETE_ACCOUNT_URL, PRIVACY_EMAIL, PRIVACY_URL, TERMS_URL } from "@/lib/links";
|
||||
import { useState } from "react";
|
||||
import { MBar, MBody, MError, MRow, MRule, MSection, MTop } from "@/components/m";
|
||||
import { INK, N600, N700 } from "@/components/staffui";
|
||||
import { useStaff } from "@/lib/staffclient";
|
||||
import { forgetPush } from "@/lib/staffpush";
|
||||
import NotificationSettings, { type NotifyPrefs } from "@/components/screens/NotificationSettings";
|
||||
|
||||
const field: React.CSSProperties = {
|
||||
width: "100%", minHeight: 52, padding: "0 14px", border: "2px solid var(--color-divider)",
|
||||
borderRadius: 0, font: "inherit", fontSize: 16, background: "#fff", color: "var(--color-text)",
|
||||
};
|
||||
|
||||
const label: React.CSSProperties = {
|
||||
display: "block", fontSize: 12.5, fontWeight: 800, letterSpacing: "0.06em",
|
||||
textTransform: "uppercase", color: N600,
|
||||
};
|
||||
|
||||
export default function AccountScreen({ email, prefs, pushReady }: {
|
||||
email: string; prefs: NotifyPrefs; pushReady: boolean;
|
||||
}) {
|
||||
const { me, mutate, busy } = useStaff();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [done, setDone] = useState(false);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
|
||||
// The server enforces the same floor; checking it here only saves a round trip and a refusal.
|
||||
const ready = current.length > 0 && next.length >= 8;
|
||||
|
||||
/* ⛔ Signing out never waits on push.forget succeeding, and never fails because it didn't.
|
||||
* Somebody on a ward with no signal still has to be able to leave a phone they are handing on.
|
||||
* An orphaned token is reclaimed three other ways — the next registration re-points it, FCM
|
||||
* reports it gone, and a password change clears the lot. */
|
||||
async function signOut() {
|
||||
setLeaving(true);
|
||||
const token = forgetPush();
|
||||
if (token) void mutate("push.forget", { token });
|
||||
await fetch("/api/staff/logout", {
|
||||
method: "POST", headers: { "content-type": "application/json" }, body: "{}",
|
||||
}).catch(() => {});
|
||||
// A full navigation: the cookie has just been cleared and every screen behind it is
|
||||
// server-rendered.
|
||||
window.location.replace("/my/signin");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Account" back backHref="/my" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
<div style={{ padding: "20px 16px 18px", borderBottom: `2px solid ${INK}`, background: "var(--color-bg)" }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 900, fontSize: 26, letterSpacing: "-0.02em", lineHeight: 1.05 }}>
|
||||
{me.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: N600, marginTop: 4 }}>{email}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "0 16px" }}>
|
||||
<MSection label="Notifications" />
|
||||
<NotificationSettings prefs={prefs} configured={pushReady} />
|
||||
|
||||
<MSection label="Sign-in" />
|
||||
{done ? (
|
||||
<div style={{ background: "#fff", borderLeft: `6px solid ${INK}`, padding: "14px 16px", marginTop: 12 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 800 }}>Password changed</div>
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, margin: "8px 0 0" }}>
|
||||
Every other device signed in as you has been signed out, and any phone of yours set
|
||||
up for notifications has been unregistered. This one stays signed in.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* A disclosure, not a link: the form is on this screen, so the row says so in words
|
||||
a screen reader is given rather than only by what appears underneath it. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 12, width: "100%", minHeight: 60,
|
||||
padding: "9px 0", background: "none", border: 0,
|
||||
borderBottom: "1px solid var(--color-divider)", font: "inherit", color: "inherit",
|
||||
textAlign: "left", cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ display: "block", fontSize: 15, fontWeight: 700 }}>Change your password</span>
|
||||
<span style={{ display: "block", fontSize: 13, color: N600, marginTop: 1 }}>Signs you out on other devices</span>
|
||||
</span>
|
||||
<span aria-hidden="true" style={{ color: N600, fontSize: 18 }}>{open ? "–" : "›"}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={{ padding: "16px 0 4px" }}>
|
||||
{/* The words wrap the box rather than sitting beside it: on the one screen where
|
||||
typing in the wrong one of two password fields is silent, both must announce
|
||||
which they are. */}
|
||||
<label style={{ display: "block" }}>
|
||||
<span style={label}>Current password</span>
|
||||
<input
|
||||
type="password" autoComplete="current-password" value={current}
|
||||
onChange={(e) => { setCurrent(e.target.value); setErr(""); }}
|
||||
style={{ ...field, marginTop: 8 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: "block", marginTop: 16 }}>
|
||||
<span style={label}>New password</span>
|
||||
<input
|
||||
type="password" autoComplete="new-password" value={next}
|
||||
onChange={(e) => { setNext(e.target.value); setErr(""); }}
|
||||
style={{ ...field, marginTop: 8 }}
|
||||
/>
|
||||
</label>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N700, margin: "12px 0 0" }}>
|
||||
At least 8 characters. Changing it signs you out everywhere else straight away.
|
||||
This device stays signed in.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<MSection label="Privacy" />
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: N700, margin: "12px 0 4px" }}>
|
||||
ThreadCount holds your sign-in and the linen room’s record of what you have been
|
||||
issued. You can’t delete this account from here — ask your uniform coordinator and
|
||||
they can remove it{PRIVACY_EMAIL ? <>, or write to {PRIVACY_EMAIL}</> : null}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{PRIVACY_URL && <MRow href={PRIVACY_URL} external mark="ink" title="Privacy policy" sub="What ThreadCount stores, and what it never does" />}
|
||||
{DELETE_ACCOUNT_URL && <MRow href={DELETE_ACCOUNT_URL} external mark="ink" title="Deleting your account" sub="How it is done, and what goes with it" />}
|
||||
{TERMS_URL && <MRow href={TERMS_URL} external mark="ink" title="Terms of use" sub="What you and ThreadCount each agree to" />}
|
||||
|
||||
{/* The one thing a wearer can do to a phone they no longer have, so it is findable without
|
||||
asking — and at the foot, because it is the last thing anybody comes here to do. */}
|
||||
<div style={{ padding: "22px 16px 0" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signOut()}
|
||||
style={{
|
||||
minHeight: 52, width: "100%", border: `2px solid ${INK}`, borderRadius: 0,
|
||||
background: "transparent", color: INK, font: "inherit",
|
||||
fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14,
|
||||
letterSpacing: "0.05em", textTransform: "uppercase", display: "flex",
|
||||
alignItems: "center", justifyContent: "center", padding: "0 14px",
|
||||
cursor: leaving ? "wait" : "pointer",
|
||||
}}
|
||||
>{leaving ? "Signing out…" : "Sign out"}</button>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 24 }} />
|
||||
</MBody>
|
||||
|
||||
{open && !done && (
|
||||
<MBar
|
||||
label={busy ? "Saving…" : "Change my password"}
|
||||
glyph="check"
|
||||
disabled={!ready || busy}
|
||||
onClick={async () => {
|
||||
const r = await mutate("account.password", { current, next });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setCurrent(""); setNext(""); setOpen(false); setDone(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user