ThreadCount Community edition

Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
ThreadCount
2026-09-13 08:54:35 +10:00
commit 344b1701dd
505 changed files with 56231 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
"use client";
/* Single sign-on for a facility, set up by its admin.
*
* Three states, top to bottom: not available on this server (the panel says so and stops);
* not connected (paste the identity provider's metadata and the email domains, connect); and
* connected (the switches — require it, let staff use it — the domains, the break-glass admins,
* and disconnect). The identity provider's metadata is handed to the SSO service and never shown
* back; what this panel shows is what the facility decided. */
import { useCallback, useEffect, useState } from "react";
import { Field } from "@/components/ui";
import type { UserRec } from "@/lib/compute";
type Status = { enabled: boolean; required: boolean; staff: boolean; domains: string[]; connected: boolean | null; idp: string | null };
type Props = {
isAdmin: boolean;
demo: boolean;
sso: { enabled: boolean; required: boolean; staff: boolean; domains: string[] };
users: UserRec[];
onChanged: () => void;
mutate: (op: string, payload: unknown) => Promise<{ ok: boolean; error?: string }>;
};
export default function SsoSettings({ isAdmin, demo, sso, users, onChanged, mutate }: Props) {
const [st, setSt] = useState<Status | null | "none">(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [msg, setMsg] = useState("");
const [metaUrl, setMetaUrl] = useState("");
const [metaXml, setMetaXml] = useState("");
const [domains, setDomains] = useState(sso.domains.join(", "));
const [confirmOff, setConfirmOff] = useState(false);
const load = useCallback(async () => {
try {
const r = await fetch("/api/sso");
if (r.status === 404) { setSt("none"); return; }
if (!r.ok) { setErr("Couldnt read the single sign-on settings."); return; }
const j = (await r.json()) as Status;
setSt(j); setDomains(j.domains.join(", "));
} catch { setErr("Couldnt reach the server."); }
}, []);
useEffect(() => { void load(); }, [load]);
async function call(method: "POST" | "PATCH" | "DELETE", body?: Record<string, unknown>) {
setBusy(true); setErr(""); setMsg("");
try {
const r = await fetch("/api/sso", { method, headers: body ? { "content-type": "application/json" } : undefined, body: body ? JSON.stringify(body) : undefined });
const j = await r.json().catch(() => ({}));
if (!r.ok) { setErr(j.error || "That didnt work."); return false; }
await load(); onChanged();
return true;
} catch { setErr("No connection — check the network and try again."); return false; } finally { setBusy(false); }
}
const note: React.CSSProperties = { fontSize: 13, color: "var(--color-neutral-700)", lineHeight: 1.6, marginTop: "var(--space-2)" };
const box: React.CSSProperties = { border: "2px solid var(--color-text)", padding: "var(--space-3)", marginTop: "var(--space-3)", maxWidth: 720 };
if (demo) return <div style={note}>Single sign-on is set up per facility and isnt part of the shared demo.</div>;
if (st === "none") return <div style={note}>Single sign-on isnt available on this server.</div>;
if (!isAdmin) return <div style={note}>{sso.enabled ? `Your facility ${sso.required ? "signs in" : "can sign in"} with single sign-on.` : "Your facility signs in with passwords. An admin can connect single sign-on here."}</div>;
if (!st) return <div style={note}>{err || "Loading…"}</div>;
const admins = users.filter((u) => u.role === "ADMIN" && !u.inactive);
if (!st.enabled) {
return (
<div style={box}>
<div style={{ fontSize: 13, lineHeight: 1.6 }}>
Let your people sign in with the account they already have Microsoft Entra, Okta, Google Workspace or any provider that speaks SAML or OpenID Connect. Paste the providers metadata, list your email domains, and connect.
</div>
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: 8, lineHeight: 1.6 }}>
Your provider will ask for the services details. ACS / redirect URL: <code>{typeof window === "undefined" ? "" : window.location.origin}/api/auth/sso/callback</code>. Entity ID: <code>https://sso.threadcount.tech</code>. Your admin at ThreadCount can supply the SP metadata if the provider wants a file.
</div>
<Field label="Metadata URL" hint="https:// only. Leave blank if you paste the XML instead." style={{ marginTop: "var(--space-3)" }}>{(c) => <input {...c} className="input" value={metaUrl} onChange={(e) => setMetaUrl(e.target.value)} placeholder="https://login.microsoftonline.com/…/federationmetadata/2007-06/federationmetadata.xml" />}</Field>
<Field label="…or metadata XML" style={{ marginTop: "var(--space-2)" }}>{(c) => <textarea {...c} className="input" rows={4} value={metaXml} onChange={(e) => setMetaXml(e.target.value)} placeholder="<EntityDescriptor …" />}</Field>
<Field label="Email domains" hint="The part after the @ in your work addresses, comma-separated. A person whose address ends in one of these is offered single sign-on." style={{ marginTop: "var(--space-2)" }}>{(c) => <input {...c} className="input" value={domains} onChange={(e) => setDomains(e.target.value)} placeholder="health.example, mail.health.example" />}</Field>
{err && <div role="alert" style={{ marginTop: 10, fontSize: 13, fontWeight: 600, color: "var(--color-accent-700)" }}>{err}</div>}
<button className="btn btn-primary" disabled={busy || (!metaUrl.trim() && !metaXml.trim()) || !domains.trim()} style={{ marginTop: "var(--space-3)" }} onClick={() => void call("POST", { metadataUrl: metaUrl, metadataXml: metaXml, domains })}>{busy ? "Connecting…" : "Connect single sign-on"}</button>
</div>
);
}
return (
<div style={box}>
<div style={{ fontSize: 13, lineHeight: 1.6 }}>
<b>Connected</b>{st.idp ? ` to ${st.idp}` : ""}{st.connected === false ? " — but the SSO service no longer holds the connection. Disconnect and connect again." : st.connected === null ? " — the SSO service couldnt be reached just now." : ""}.
People whose address ends in {st.domains.map((d) => <code key={d} style={{ marginRight: 6 }}>@{d}</code>)} see Continue with single sign-on on the Log in screen.
</div>
<label style={{ display: "flex", gap: 10, alignItems: "flex-start", marginTop: "var(--space-3)", fontSize: 13 }}>
<input type="checkbox" checked={st.required} disabled={busy} onChange={(e) => void call("PATCH", { required: e.target.checked })} />
<span><b>Require single sign-on.</b> Passwords stop working for everyone except the break-glass admins below. Password resets stop too.</span>
</label>
<label style={{ display: "flex", gap: 10, alignItems: "flex-start", marginTop: 8, fontSize: 13 }}>
<input type="checkbox" checked={st.staff} disabled={busy} onChange={(e) => void call("PATCH", { staff: e.target.checked })} />
<span><b>Staff may use it too.</b> Wearers who already have a staff account can sign in on the website with single sign-on. The staff app keeps their password for now.</span>
</label>
<div style={{ marginTop: "var(--space-3)", fontSize: 13 }}>
<b>Break-glass admins</b> keep a working password for the day the identity provider is down.{st.required && !admins.some((a) => a.ssoBreakGlass) ? " None chosen." : ""}
<div style={{ display: "grid", gap: 4, marginTop: 6 }}>
{admins.map((a) => (
<label key={a.id} style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input type="checkbox" checked={a.ssoBreakGlass} disabled={busy} onChange={async (e) => { setErr(""); const r = await mutate("users.update", { id: a.id, ssoBreakGlass: e.target.checked }); if (!r.ok) setErr(r.error || "Couldnt save."); else onChanged(); }} />
<span>{a.first} {a.last} <span style={{ color: "var(--color-neutral-700)" }}>({a.email})</span></span>
</label>
))}
</div>
</div>
<Field label="Email domains" style={{ marginTop: "var(--space-3)" }}>{(c) => <input {...c} className="input" value={domains} onChange={(e) => setDomains(e.target.value)} />}</Field>
<div style={{ display: "flex", gap: 8, marginTop: 8, flexWrap: "wrap" }}>
<button className="btn btn-secondary" disabled={busy || domains.trim() === st.domains.join(", ")} onClick={() => void call("PATCH", { domains }).then((ok) => ok && setMsg("Saved."))}>Save domains</button>
</div>
{err && <div role="alert" style={{ marginTop: 10, fontSize: 13, fontWeight: 600, color: "var(--color-accent-700)" }}>{err}</div>}
{msg && <div style={{ marginTop: 10, fontSize: 12, fontWeight: 600, color: "var(--color-accent-700)" }}>{msg}</div>}
<div style={{ borderTop: "1px solid var(--color-divider)", marginTop: "var(--space-3)", paddingTop: "var(--space-3)" }}>
{confirmOff ? (
<div style={{ fontSize: 13 }}>
Everyone goes back to signing in with their password. Sure?
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<button className="btn btn-primary" disabled={busy} onClick={() => void call("DELETE").then(() => setConfirmOff(false))}>Disconnect</button>
<button className="btn btn-ghost" disabled={busy} onClick={() => setConfirmOff(false)}>Keep it</button>
</div>
</div>
) : (
<button className="btn btn-ghost" disabled={busy} onClick={() => setConfirmOff(true)}>Disconnect single sign-on</button>
)}
</div>
</div>
);
}