344b1701dd
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
134 lines
9.4 KiB
TypeScript
134 lines
9.4 KiB
TypeScript
"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("Couldn’t read the single sign-on settings."); return; }
|
||
const j = (await r.json()) as Status;
|
||
setSt(j); setDomains(j.domains.join(", "));
|
||
} catch { setErr("Couldn’t 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 didn’t 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 isn’t part of the shared demo.</div>;
|
||
if (st === "none") return <div style={note}>Single sign-on isn’t 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 provider’s 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 service’s 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 couldn’t 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 || "Couldn’t 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>
|
||
);
|
||
}
|