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
+151
View File
@@ -0,0 +1,151 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/session";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { recordAudit } from "@/lib/audit";
import { bumpRev } from "@/lib/ops";
import { createOrUpdateConnection, deleteConnection, domainTakenBy, getConnection, normaliseDomain, ssoConfigured, SsoError } from "@/lib/sso";
export const dynamic = "force-dynamic";
/* An admin's single sign-on settings for their own facility.
*
* GET the switches, the registered domains, and whether the broker holds a connection;
* POST connect: hand the IdP metadata to the broker, then — and only then — switch SSO on;
* PATCH the switches and domains, with SSO already connected;
* DELETE disconnect: remove the connection from the broker and switch everything off.
*
* The IdP metadata never touches this database; the broker keeps it. The switches live on the
* facility row, so the sign-in routes can read them without asking the broker. Admin only, never
* the demo, and 404 throughout when no broker is configured — the feature then does not exist. */
function notHere() { return NextResponse.json({ error: "Single sign-on is not available on this server." }, { status: 404 }); }
async function gate(req: NextRequest, json: boolean) {
if (!ssoConfigured()) return { res: notHere() } as const;
const csrf = sameOriginJson(req, json);
if (csrf) return { res: NextResponse.json({ error: csrf }, { status: 403 }) } as const;
const user = await currentUser();
if (!user) return { res: NextResponse.json({ error: "Not signed in" }, { status: 401 }) } as const;
if (user.role !== "ADMIN") return { res: NextResponse.json({ error: "Admins only" }, { status: 403 }) } as const;
if (user.isDemo) return { res: NextResponse.json({ error: "Not available in the demo." }, { status: 403 }) } as const;
if (!allow("sso-admin:" + user.id, 30, 15 * 60 * 1000)) return { res: NextResponse.json({ error: "Too many changes — try again in a few minutes." }, { status: 429 }) } as const;
return { user } as const;
}
export async function GET() {
if (!ssoConfigured()) return notHere();
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
if (user.role !== "ADMIN") return NextResponse.json({ error: "Admins only" }, { status: 403 });
const f = await prisma.facility.findUniqueOrThrow({ where: { id: user.facilityId }, select: { ssoEnabled: true, ssoRequired: true, ssoStaff: true, ssoDomains: true } });
let connected: boolean | null = null, idp: string | null = null;
try {
const c = await getConnection(user.facilityId);
connected = !!c;
idp = c?.idpMetadata?.provider || c?.idpMetadata?.entityID || null;
} catch {
connected = null; // the broker could not be reached; the switches still say what they say
}
return NextResponse.json({ enabled: f.ssoEnabled, required: f.ssoRequired, staff: f.ssoStaff, domains: f.ssoDomains, connected, idp });
}
export async function POST(req: NextRequest) {
const g = await gate(req, true);
if ("res" in g) return g.res;
const { user } = g;
let body: { metadataUrl?: unknown; metadataXml?: unknown; domains?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const metadataUrl = String(body.metadataUrl ?? "").trim().slice(0, 2000);
const metadataXml = String(body.metadataXml ?? "").trim().slice(0, 200_000);
if (!metadataUrl && !metadataXml) return NextResponse.json({ error: "Paste your identity provider's metadata URL or its XML." }, { status: 400 });
if (metadataUrl) {
let u: URL;
try { u = new URL(metadataUrl); } catch { return NextResponse.json({ error: "That metadata URL isn't a valid URL." }, { status: 400 }); }
// Fetched by the broker server-side: only https, or a document could be swapped in transit.
if (u.protocol !== "https:") return NextResponse.json({ error: "The metadata URL must start with https://." }, { status: 400 });
}
const domains = await checkDomains(body.domains, user.facilityId);
if ("error" in domains) return NextResponse.json({ error: domains.error }, { status: 400 });
if (domains.list.length === 0) return NextResponse.json({ error: "Add at least one email domain — it is how your people reach your sign-in." }, { status: 400 });
const f = await prisma.facility.findUniqueOrThrow({ where: { id: user.facilityId }, select: { name: true } });
try {
await createOrUpdateConnection({ facilityId: user.facilityId, facilityName: f.name, metadataUrl: metadataUrl || undefined, metadataXml: metadataXml || undefined });
} catch (e) {
if (e instanceof SsoError) return NextResponse.json({ error: e.message }, { status: 502 });
throw e;
}
// Only once the broker holds a real connection does the switch go on.
await prisma.facility.update({ where: { id: user.facilityId }, data: { ssoEnabled: true, ssoDomains: domains.list } });
recordAudit(user, "settings.sso.connect", { domains: domains.list, via: metadataUrl ? "url" : "xml" }, clientIp(req.headers));
await bumpRev(user.facilityId);
return NextResponse.json({ ok: true, enabled: true, domains: domains.list });
}
export async function PATCH(req: NextRequest) {
const g = await gate(req, true);
if ("res" in g) return g.res;
const { user } = g;
let body: { required?: unknown; staff?: unknown; domains?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const f = await prisma.facility.findUniqueOrThrow({ where: { id: user.facilityId }, select: { ssoEnabled: true } });
if (!f.ssoEnabled) return NextResponse.json({ error: "Connect your identity provider first." }, { status: 400 });
const data: { ssoRequired?: boolean; ssoStaff?: boolean; ssoDomains?: string[] } = {};
if (body.required !== undefined) {
data.ssoRequired = body.required === true;
if (data.ssoRequired) {
// Requiring SSO with nobody left holding a password is a facility nobody can enter the day
// the identity provider is down. Somebody — an admin — keeps a key.
const keys = await prisma.user.count({ where: { facilityId: user.facilityId, role: "ADMIN", inactive: false, ssoBreakGlass: true } });
if (keys === 0) return NextResponse.json({ error: "Mark at least one admin as break-glass first — they keep a working password for the day the identity provider is down." }, { status: 400 });
}
}
if (body.staff !== undefined) data.ssoStaff = body.staff === true;
if (body.domains !== undefined) {
const domains = await checkDomains(body.domains, user.facilityId);
if ("error" in domains) return NextResponse.json({ error: domains.error }, { status: 400 });
if (domains.list.length === 0) return NextResponse.json({ error: "Keep at least one email domain." }, { status: 400 });
data.ssoDomains = domains.list;
}
await prisma.facility.update({ where: { id: user.facilityId }, data });
recordAudit(user, "settings.sso.update", data, clientIp(req.headers));
await bumpRev(user.facilityId);
return NextResponse.json({ ok: true, ...data });
}
export async function DELETE(req: NextRequest) {
const g = await gate(req, false);
if ("res" in g) return g.res;
const { user } = g;
try {
await deleteConnection(user.facilityId);
} catch (e) {
if (e instanceof SsoError) return NextResponse.json({ error: e.message }, { status: 502 });
throw e;
}
// Everything off, whatever the broker said: the button's job is to end SSO here.
await prisma.facility.update({ where: { id: user.facilityId }, data: { ssoEnabled: false, ssoRequired: false, ssoStaff: false } });
recordAudit(user, "settings.sso.disconnect", {}, clientIp(req.headers));
await bumpRev(user.facilityId);
return NextResponse.json({ ok: true, enabled: false });
}
/** Up to ten well-formed domains, each owned by no other facility. */
async function checkDomains(raw: unknown, facilityId: string): Promise<{ list: string[] } | { error: string }> {
const arr = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[\s,]+/) : [];
const list: string[] = [];
for (const r of arr) {
if (typeof r !== "string" || !r.trim()) continue;
const d = normaliseDomain(r);
if (!d) return { error: `${String(r).slice(0, 60)}” isn't a domain. Use the part after the @ in your work addresses, like health.example.` };
if (["gmail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "live.com"].includes(d)) return { error: `${d} is a public mail service, not your facility's — anyone could sign up there.` };
if (!list.includes(d)) list.push(d);
}
if (list.length > 10) return { error: "Ten domains at most." };
for (const d of list) {
const owner = await domainTakenBy(d, facilityId);
if (owner) return { error: `${d} is already registered by another facility.` };
}
return { list };
}