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
+74
View File
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { pwVersion, setSessionCookie } from "@/lib/session";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { decryptSecret, hashRecoveryCode, totpVerify } from "@/lib/totp";
import { readTicket } from "@/lib/twofactor";
import { recordAuthEvent } from "@/lib/audit";
export const dynamic = "force-dynamic";
/* Second step of sign-in: the code from the authenticator, or one recovery code.
*
* Rate limited hard. A six-digit code is one in a million per guess, which is only meaningful if
* guessing is expensive — unthrottled, a million tries is minutes of work. */
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const ip = clientIp(req.headers);
let body: { ticket?: unknown; code?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const t = readTicket(String(body.ticket ?? ""));
if (!t) return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 });
// Per account and per address: one stolen ticket can't be brute-forced, and one machine can't
// work through several accounts at once.
if (!allow("2fa-user:" + t.uid, 10, 15 * 60 * 1000) || !allow("2fa-ip:" + ip, 300, 15 * 60 * 1000)) {
return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 });
}
const u = await prisma.user.findUnique({
where: { id: t.uid },
select: { id: true, facilityId: true, email: true, first: true, last: true, role: true, inactive: true, passwordHash: true, totpSecret: true, totpEnabledAt: true },
});
if (!u || u.inactive || !u.totpEnabledAt) {
return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 });
}
// The password changed between the two steps — the ticket is stale for the same reason a session
// would be.
if (pwVersion(u.passwordHash) !== t.pv) {
return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 });
}
const raw = String(body.code ?? "").trim();
const secret = decryptSecret(u.totpSecret);
let good = !!secret && totpVerify(secret, raw);
let usedRecovery = false;
if (!good && raw.replace(/[^A-Za-z0-9]/g, "").length >= 10) {
// A recovery code. Single use: consumed in the same conditional update that finds it, so two
// simultaneous attempts can't both spend it.
const hash = hashRecoveryCode(raw);
const hit = await prisma.recoveryCode.findFirst({ where: { userId: u.id, codeHash: hash, usedAt: null }, select: { id: true } });
if (hit) {
const consumed = await prisma.recoveryCode.updateMany({ where: { id: hit.id, usedAt: null }, data: { usedAt: new Date() } });
good = consumed.count === 1;
usedRecovery = good;
}
}
if (!good) return NextResponse.json({ error: "That code isn't right. Try the current one from your app." }, { status: 401 });
await setSessionCookie(u.id, u.passwordHash);
// How they got in matters more here than anywhere else: a recovery code means the phone is gone,
// and a run of them means something else is going on.
recordAuthEvent(
{ facilityId: u.facilityId, userId: u.id, userName: `${u.first} ${u.last}`.trim() || u.email },
"auth:signin", ip, usedRecovery ? "recovery" : "totp",
);
const left = await prisma.recoveryCode.count({ where: { userId: u.id, usedAt: null } });
return NextResponse.json({ ok: true, name: `${u.first} ${u.last}`, role: u.role, usedRecovery, recoveryLeft: left });
}
+14
View File
@@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "crypto";
import { resetDemo } from "@/lib/demo";
export const dynamic = "force-dynamic";
// Called by the host's threadcount-demo-reset.timer every 20 minutes with the shared token.
export async function POST(req: NextRequest) {
const want = process.env.DEMO_RESET_TOKEN || "";
const got = req.headers.get("x-demo-token") || "";
if (!want || want.length !== got.length || !timingSafeEqual(Buffer.from(want), Buffer.from(got))) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const f = await resetDemo();
return NextResponse.json({ ok: true, facility: f.name, resetAt: f.demoResetAt });
}
+24
View File
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { COOKIE_NAME, currentUser, signSession } from "@/lib/session";
import { allow, clientIp } from "@/lib/ratelimit";
import { demoUserFor, ensureDemo } from "@/lib/demo";
import { switches } from "@/lib/switches";
export const dynamic = "force-dynamic";
// One-click entry into the shared demo facility. Redirects use a raw relative Location so the
// proxy in front of the app can't rewrite the host.
export async function GET(req: NextRequest) {
if (!(await switches()).demoOpen) return NextResponse.json({ error: "The demo is switched off." }, { status: 404 });
if (req.headers.get("sec-fetch-site") === "cross-site") return new NextResponse(null, { status: 303, headers: { Location: "/demo" } });
const as = req.nextUrl.searchParams.get("as") === "issuer" ? "issuer" : "admin";
// A link can't be used to swap a signed-in coordinator's real session for the demo (login CSRF).
const cur = await currentUser();
if (cur && !cur.isDemo) return new NextResponse(null, { status: 303, headers: { Location: "/demo?signedin=1" } });
if (!allow("demo:" + clientIp(req.headers), 30, 10 * 60 * 1000)) return NextResponse.json({ error: "Too many requests — try again shortly." }, { status: 429 });
const f = await ensureDemo();
const u = demoUserFor(f, as);
const res = new NextResponse(null, { status: 303, headers: { Location: "/app" } });
res.cookies.set(COOKIE_NAME, signSession(u.id, u.passwordHash, 60 * 60 * 4), { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 60 * 60 * 4 });
return res;
}
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { allow, clientIp, fail, over } from "@/lib/ratelimit";
import { sameOriginJson } from "@/lib/csrf";
import { verifyTurnstile } from "@/lib/turnstile";
import { sendTo, transactionalConfigured } from "@/lib/mail";
import { RESET_TTL_MS, newResetToken, resetEmail, resetUrl } from "@/lib/reset";
export const dynamic = "force-dynamic";
/* Request a password reset.
*
* Before this existed a facility whose only admin forgot their password was locked out for good —
* the sign-in screen told them to ask an admin, and they were the admin. Deleting the last admin
* deletes the whole facility, so there was no way back in at all.
*
* The response is identical whether or not the address has an account. Anything else turns this
* into a way to ask "does this hospital use ThreadCount, and is this person a coordinator there?"
*/
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const ip = clientIp(req.headers);
let body: { email?: unknown; cfToken?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160);
// A slow ceiling per IP, so one machine can't walk a staff list to find out which addresses exist
// by watching how long each request takes. The per-address ceiling deliberately lives further
// down, past the bot check — see the note beside it.
if (!allow("forgot-ip:" + ip, 60, 60 * 60 * 1000)) {
return NextResponse.json({ ok: true });
}
const cfErr = await verifyTurnstile(body.cfToken, ip);
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ ok: true });
const user = await prisma.user.findUnique({ where: { email }, select: { id: true, first: true, inactive: true, ssoBreakGlass: true, facility: { select: { ssoRequired: true } } } });
// A facility that requires single sign-on has no password door for most of its people, so a
// reset link would be a way round its identity provider. Break-glass admins keep theirs. The
// answer to the caller is the same either way.
const ssoOnly = !!user && user.facility.ssoRequired && !user.ssoBreakGlass;
if (user && !user.inactive && !ssoOnly) {
// The per-address ceiling counts mail actually sent, not requests received, and it is only
// consulted once the bot check has passed. Spent on requests, it handed a stranger a way to
// hold a facility's only coordinator out of their own account: four anonymous posts with no
// Turnstile token filled the bucket, and every later attempt by the coordinator was answered
// with "a reset link is on its way" and no mail. Counted this way the only way to exhaust the
// budget is to have four reset mails delivered to that same inbox, so whoever forgot their
// password always has a working link waiting for them.
const mailKey = "forgot-email:" + email;
if (over(mailKey, 4, 60 * 60 * 1000)) {
console.warn("[forgot] four reset mails already sent this hour — suppressing another for user", user.id);
} else {
const { token, tokenHash } = newResetToken();
await prisma.$transaction(async (tx) => {
// Asking again supersedes anything outstanding, so a forwarded older email goes dead.
await tx.passwordReset.updateMany({
where: { userId: user.id, usedAt: null },
data: { usedAt: new Date() },
});
await tx.passwordReset.create({
data: { userId: user.id, tokenHash, expiresAt: new Date(Date.now() + RESET_TTL_MS), requestIp: ip },
});
});
const { subject, text } = resetEmail(user.first, resetUrl(token));
const sent = await sendTo(email, subject, text);
// Only a mail that left the building counts. A send that failed gave the coordinator nothing,
// so charging them for it would shut them out for an hour over a mail outage.
if (sent) fail(mailKey, 60 * 60 * 1000);
else console.error("[forgot] reset requested but mail could not be sent for user", user.id);
}
}
// Told to the caller regardless, so the answer carries no information about the address.
return NextResponse.json({ ok: true, mail: transactionalConfigured() });
}
+100
View File
@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { pwVersion, setSessionCookie } from "@/lib/session";
import { mintTicket } from "@/lib/twofactor";
import { sameOriginJson } from "@/lib/csrf";
import { clientIp, fail, over } from "@/lib/ratelimit";
import { signInStaff } from "@/lib/staffauth";
import { verifyTurnstile } from "@/lib/turnstile";
import { recordAuthEvent } from "@/lib/audit";
export const dynamic = "force-dynamic";
// Simple in-memory throttle per IP+email (per process).
const attempts = new Map<string, { n: number; t: number }>();
/** The trail names the person, not the address they typed — see lib/audit.ts. */
const actorFor = (u: { id: string; facilityId: string; first: string; last: string; email: string }) =>
({ facilityId: u.facilityId, userId: u.id, userName: `${u.first} ${u.last}`.trim() || u.email });
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
let body: { email?: string; password?: string; cfToken?: string };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const email = String(body.email || "").trim().toLowerCase().slice(0, 160);
const password = String(body.password || "").slice(0, 200);
// Spray protection independent of the per-(ip,email) counter below. Both buckets count only the
// attempts that FAILED — a whole hospital signs in from one NAT address at shift change, and a
// ceiling on attempts would have to lock that ward out to be worth anything against an attacker.
const ipKey = clientIp(req.headers);
if (over("login-ip:" + ipKey, 40, 15 * 60 * 1000) || (email && over("login-email:" + email, 25, 15 * 60 * 1000))) return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
if (!email || !password) return NextResponse.json({ error: "Enter your email and password." }, { status: 400 });
// nginx appends the real client IP last; earlier entries are client-supplied and spoofable.
const xff = req.headers.get("x-forwarded-for")?.split(",").map((x) => x.trim()).filter(Boolean) || [];
const ip = xff[xff.length - 1] || "local";
if (attempts.size > 5000) for (const [kk, v] of attempts) if (Date.now() - v.t > 15 * 60 * 1000) attempts.delete(kk);
const k = `${ip}|${email}`;
const a = attempts.get(k);
if (a && a.n >= 8 && Date.now() - a.t < 15 * 60 * 1000) return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
const cfErr = await verifyTurnstile(body.cfToken, ipKey); if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
const u = await prisma.user.findUnique({ where: { email } });
/* One box, both kinds of account.
*
* A wearer reaches the product the way anyone else does — the home page, then Log in — and types
* the details they set up in the staff app. So when this address has no coordinator account, the
* register is asked before the answer is called wrong.
*
* A coordinator account always wins: it is the one with the counter, the orders and the register
* behind it, and a coordinator who also wears a uniform can open their own record from inside the
* app. One address therefore has one destination, every time.
*
* This is a lookup, not a second attempt. "Try the coordinator, and if that fails try the staff
* one" would score a failure against every single staff sign-in, and these ceilings count
* failures — behind one hospital's NAT address at shift change that is a locked-out ward.
*/
if (!u) {
const s = await signInStaff(email, password, ipKey, false);
if (s.kind === "ok") return NextResponse.json({ ok: true, name: s.name, staff: true });
if (s.kind === "error") return NextResponse.json({ error: s.error }, { status: s.status });
// `none`: no staff account either, so this falls through to the answer below, which counts the
// failure once and says the same thing it has always said.
}
const ok = u ? await bcrypt.compare(password, u.passwordHash) : await bcrypt.compare(password, "$2b$12$C6UzMDM.H6dfI/f/IKcEeO5x3FvDS3kqB6r0Jt3g7Lz0vX4o0JZ1u");
if (!u || !ok) {
attempts.set(k, { n: (a && Date.now() - a.t < 15 * 60 * 1000 ? a.n : 0) + 1, t: Date.now() });
fail("login-ip:" + ipKey, 15 * 60 * 1000);
if (email) fail("login-email:" + email, 15 * 60 * 1000);
// An address with no account here is recorded nowhere: there is no facility to file it under,
// and a log of attempts on addresses that don't exist would be a list of other people's email
// addresses that nobody asked us to keep.
if (u) recordAuthEvent(actorFor(u), "auth:signin.failed", ipKey);
return NextResponse.json({ error: "Email or password doesnt match." }, { status: 401 });
}
attempts.delete(k);
if (u.inactive) {
// The right password on an account that has been taken away is worth knowing about.
recordAuthEvent(actorFor(u), "auth:signin.refused", ipKey, "inactive");
return NextResponse.json({ error: "This account has been deactivated. Ask an admin at your facility to reactivate it." }, { status: 403 });
}
const fac = await prisma.facility.findUnique({ where: { id: u.facilityId }, select: { isDemo: true, ssoEnabled: true, ssoRequired: true } });
if (fac?.isDemo) return NextResponse.json({ error: "Demo accounts cant log in here — open the demo from the home page." }, { status: 403 });
// The facility has decided its people sign in through its own identity provider. The password
// was right, and it is still refused — except for the admin the facility keeps as its fire
// escape. The box sends them on to single sign-on rather than reporting a failure.
if (fac?.ssoEnabled && fac.ssoRequired && !u.ssoBreakGlass) {
recordAuthEvent(actorFor(u), "auth:signin.refused", ipKey, "sso required");
return NextResponse.json({ error: "Your facility signs in with single sign-on.", ssoRequired: true }, { status: 403 });
}
// With a second factor on the account the password alone opens nothing. The ticket says only
// "this password was correct", is accepted by no other endpoint, and expires in five minutes.
if (u.totpEnabledAt) {
return NextResponse.json({ need2fa: true, ticket: mintTicket(u.id, pwVersion(u.passwordHash)) });
}
await setSessionCookie(u.id, u.passwordHash);
recordAuthEvent(actorFor(u), "auth:signin", ipKey, "password");
return NextResponse.json({ ok: true, name: `${u.first} ${u.last}`, role: u.role });
}
+22
View File
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { clearSessionCookie, currentUser } from "@/lib/session";
import { sameOriginJson } from "@/lib/csrf";
import { clientIp } from "@/lib/ratelimit";
import { recordAuthEvent } from "@/lib/audit";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req, false); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
// Read the session before dropping it, so the trail can say who left. An unauthenticated call
// still clears the cookie and still answers ok — signing out must never fail.
const user = await currentUser();
await clearSessionCookie();
if (user) {
recordAuthEvent(
{ facilityId: user.facilityId, userId: user.id, userName: `${user.first} ${user.last}`.trim() || user.email },
"auth:signout", clientIp(req.headers),
);
}
return NextResponse.json({ ok: true });
}
+96
View File
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { allow, clientIp } from "@/lib/ratelimit";
import { sameOriginJson } from "@/lib/csrf";
import { pwVersion, setSessionCookie } from "@/lib/session";
import { mintTicket } from "@/lib/twofactor";
import { hashResetToken } from "@/lib/reset";
import { recordAuthEvent } from "@/lib/audit";
export const dynamic = "force-dynamic";
const MIN_PASSWORD = 8;
/* Complete a password reset.
*
* Changing the hash invalidates every existing session for that user on its own — the session
* cookie carries a version derived from the password hash — so a reset also kicks out whoever
* prompted it, which is the behaviour you want if the reason was a shared or stolen password. */
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const ip = clientIp(req.headers);
if (!allow("reset-ip:" + ip, 100, 60 * 60 * 1000)) {
return NextResponse.json({ error: "Too many attempts — try again later." }, { status: 429 });
}
let body: { token?: unknown; password?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const token = String(body.token ?? "").trim().slice(0, 400);
const password = String(body.password ?? "");
if (!token) return NextResponse.json({ error: "That link is incomplete. Ask for a new one." }, { status: 400 });
if (password.length < MIN_PASSWORD) {
return NextResponse.json({ error: `Use at least ${MIN_PASSWORD} characters.` }, { status: 400 });
}
// Looked up by hash, so the raw token never has to be compared against stored material.
const row = await prisma.passwordReset.findUnique({
where: { tokenHash: hashResetToken(token) },
select: {
id: true, userId: true, expiresAt: true, usedAt: true,
user: { select: { inactive: true, passwordHash: true, totpEnabledAt: true, facilityId: true, first: true, last: true, email: true } },
},
});
const dead = !row || row.usedAt || row.expiresAt.getTime() < Date.now() || row.user.inactive;
if (dead) {
return NextResponse.json({ error: "That link has expired or has already been used. Ask for a new one." }, { status: 400 });
}
const hash = await bcrypt.hash(password, 12);
await prisma.$transaction(async (tx) => {
// Consume the token in the same write as the password change, so a double submit can't set the
// password twice or leave a live token behind.
const consumed = await tx.passwordReset.updateMany({
where: { id: row.id, usedAt: null },
data: { usedAt: new Date() },
});
if (consumed.count !== 1) throw new Error("token already consumed");
await tx.user.update({ where: { id: row.userId }, data: { passwordHash: hash } });
// Any other outstanding requests for this account die with it.
await tx.passwordReset.updateMany({ where: { userId: row.userId, usedAt: null }, data: { usedAt: new Date() } });
}).catch(() => null);
const fresh = await prisma.user.findUnique({ where: { id: row.userId }, select: { passwordHash: true } });
if (!fresh || fresh.passwordHash !== hash) {
return NextResponse.json({ error: "That link has expired or has already been used. Ask for a new one." }, { status: 400 });
}
const actor = {
facilityId: row.user.facilityId,
userId: row.userId,
userName: [row.user.first, row.user.last].filter(Boolean).join(" ").trim() || row.user.email,
};
// A second factor is a second factor here too. Control of the mailbox is one proof, and on an
// account with TOTP the front door refuses to open on one proof — so this door must not either,
// or resetting the password would be the supported way around the authenticator, and the new
// password would then be enough to turn it off for good.
//
// The same five-minute ticket the sign-in screen uses, accepted by the same endpoint: nothing new
// to keep, nothing new to get wrong.
if (row.user.totpEnabledAt) {
recordAuthEvent(actor, "auth:password.reset", ip, "email-link");
return NextResponse.json({ need2fa: true, ticket: mintTicket(row.userId, pwVersion(hash)) });
}
// Otherwise sign them straight in: they have just proven control of the mailbox and chosen a
// password, and making them type it again immediately is friction with no security value.
await setSessionCookie(row.userId, hash);
recordAuthEvent(actor, "auth:password.reset", ip, "email-link");
recordAuthEvent(actor, "auth:signin", ip, "reset");
return NextResponse.json({ ok: true });
}
+93
View File
@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { setSessionCookie } from "@/lib/session";
import { allow, clientIp } from "@/lib/ratelimit";
import { sameOriginJson } from "@/lib/csrf";
import { verifyTurnstile } from "@/lib/turnstile";
import { sendTo, transactionalConfigured } from "@/lib/mail";
import { switches } from "@/lib/switches";
import { alertNewSignup } from "@/lib/ops/alerts";
import { recordAuthEvent } from "@/lib/audit";
import { TRIAL_DAYS } from "@/lib/plan";
export const dynamic = "force-dynamic";
/* Creating a facility.
*
* The address typed here is not verified, and deliberately isn't: a confirmation step in front of
* a linen room's first ten minutes is a wall, and a facility half-created behind an unclicked link
* is worse than one created. But it is the *only* way back in — /api/auth/forgot answers a
* stranger and the owner identically, so a typo produces no signal at all until the day the
* password is forgotten, and by then the facility is unreachable and undeletable.
*
* So the address is exercised immediately instead. A note goes to it saying, in as many words,
* that this is the address that recovers the account, and the answer here says whether it was
* sent — which is what lets the sign-up screen show the address back and tell someone who never
* receives it what to do about it while they are still signed in and can still act.
*/
function welcomeEmail(first: string, facility: string) {
const subject = "Your ThreadCount facility is set up";
const text = [
`Hi ${first || "there"},`,
"",
`${facility} is set up on ThreadCount, and this address is the coordinator account for it.`,
"",
"Keep this message. This is the address a password reset is sent to, and it is the only way",
"back into the facility if the password is forgotten — so if it is wrong, sign in and add a",
"second admin with an address that works, under Settings → Users.",
"",
`${process.env.NEXT_PUBLIC_SITE_URL || "https://threadcount.tech"}/app`,
"",
"— ThreadCount",
].join("\n");
return { subject, text };
}
export async function POST(req: NextRequest) {
const sw = await switches();
if (!sw.signupsOpen) return NextResponse.json({ error: "New facility sign-ups are closed." }, { status: 403 });
const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
if (!allow("signup:" + clientIp(req.headers), 5, 60 * 60 * 1000)) return NextResponse.json({ error: "Too many sign-ups from this connection — try again later." }, { status: 429 });
let b: Record<string, string>;
try { b = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const first = String(b.first || "").trim().slice(0, 80), last = String(b.last || "").trim().slice(0, 80);
const facility = String(b.facility || "").trim().slice(0, 120);
const email = String(b.email || "").trim().toLowerCase().slice(0, 160);
const password = String(b.password || "");
if (!first || !last || !facility) return NextResponse.json({ error: "Name and facility are required." }, { status: 400 });
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ error: "Enter a valid work email." }, { status: 400 });
if (password.length < 8) return NextResponse.json({ error: "Password must be at least 8 characters." }, { status: 400 });
const cfErr = await verifyTurnstile(b.cfToken, clientIp(req.headers)); if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
if (await prisma.user.findUnique({ where: { email } })) return NextResponse.json({ error: "That email already has an account — log in instead." }, { status: 409 });
const u = await prisma.$transaction(async (tx) => {
// No staff groups: the facility names its own. Any list handed over here would be one employer's
// organisation chart on another employer's register, and a group sitting on a route nobody
// chose decides who is handed a starting kit. Both route lists start empty with it, so until the
// coordinator puts a group on the FTE table or the starting kit, everybody is on manager approval
// and nobody has been promised a kit the counter would not hand over.
// Until plans are live the page still says free, so a facility created today is grandfathered:
// free with everything, for good. Once they are live a new room starts on the plan it chose —
// Hosted Small, free, or a Hosted Facility trial with its end date set now. Anything else
// sent as `plan` is Hosted Small: the free room is the safe misreading.
const trial = sw.plansLive && b.plan === "hosted_facility";
const planData = !sw.plansLive
? { grandfathered: true, planStatus: "free" }
: trial
? { plan: "hosted_facility", planStatus: "trial", trialEndsAt: new Date(Date.now() + TRIAL_DAYS * 86_400_000) }
: { plan: "hosted_small", planStatus: "free" };
const f = await tx.facility.create({ data: { name: facility, coordinator: `${first} ${last}`, ...planData } });
return tx.user.create({ data: { facilityId: f.id, email, passwordHash: await bcrypt.hash(password, 12), first, last, title: "Uniform Coordinator", role: "ADMIN" } });
});
await setSessionCookie(u.id, u.passwordHash);
recordAuthEvent({ facilityId: u.facilityId, userId: u.id, userName: `${first} ${last}`.trim() || email }, "auth:signup", clientIp(req.headers));
alertNewSignup({ id: u.facilityId, name: facility }); // the facility's name only — never the person
const em = welcomeEmail(first, facility);
const mailed = await sendTo(email, em.subject, em.text);
if (!mailed && transactionalConfigured()) console.error("[signup] welcome mail could not be sent for user", u.id);
// `mailed` is false when no SMTP is configured at all, which is a different thing from a bad
// address — the screen says so rather than pretending the address has been proven.
return NextResponse.json({ ok: true, email, mailed, mail: transactionalConfigured() });
}
+89
View File
@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { setSessionCookie } from "@/lib/session";
import { setStaffCookie } from "@/lib/staffsession";
import { allow, clientIp, fail, over } from "@/lib/ratelimit";
import { recordAuthEvent } from "@/lib/audit";
import { exchangeCode, fetchProfile, readState, ssoConfigured, SsoError, STATE_COOKIE } from "@/lib/sso";
export const dynamic = "force-dynamic";
/* The broker sends the browser back here with ?code&state once the identity provider has spoken.
* This route is the whole trust boundary, so, in order:
*
* 1. state must be the nonce in our signed cookie, which also says WHICH facility this login
* was started for and whether a coordinator or a wearer is expected;
* 2. the code is exchanged server-side and the profile read from the broker — the IdP's tokens
* never touch the browser;
* 3. the profile's email must match an existing, active account IN THAT FACILITY: a coordinator
* (User) or, if the facility allows wearers, a staff account. Nothing is ever created here —
* an address the identity provider vouches for but the facility never added is not a person
* the facility asked to let in;
* 4. only then is the ordinary session cookie minted, marked sso. A passed assertion is a whole
* authentication (the identity provider owns the second factor), so no TOTP step follows.
*
* No Turnstile and no same-origin check: this is a top-level navigation from the broker's origin,
* and the state cookie is the CSRF proof. Every failure lands on /auth?error=…, never a bypass. */
const back = (path: string) => {
const res = new NextResponse(null, { status: 303, headers: { Location: path } });
res.cookies.set(STATE_COOKIE, "", { httpOnly: true, sameSite: "lax", path: "/api/auth/sso", maxAge: 0 });
return res;
};
export async function GET(req: NextRequest) {
if (!ssoConfigured()) return back("/auth?error=sso_unavailable");
const ip = clientIp(req.headers);
// Failures only, so a whole site signing in behind one address is never locked out.
if (over("sso-callback:" + ip, 20, 15 * 60 * 1000)) return back("/auth?error=sso_failed");
const bad = (path: string) => { fail("sso-callback:" + ip, 15 * 60 * 1000); return back(path); };
if (!allow("sso-callback-all:" + ip, 120, 15 * 60 * 1000)) return back("/auth?error=sso_failed");
const code = req.nextUrl.searchParams.get("code") || "";
const state = req.nextUrl.searchParams.get("state") || "";
if (req.nextUrl.searchParams.get("error") || !code || !state) return bad("/auth?error=sso_failed");
const st = readState(req.cookies.get(STATE_COOKIE)?.value, state);
if (!st) return bad("/auth?error=sso_state");
const f = await prisma.facility.findUnique({ where: { id: st.facilityId }, select: { id: true, isDemo: true, ssoEnabled: true, ssoStaff: true } });
if (!f || f.isDemo || !f.ssoEnabled) return bad("/auth?error=sso_unavailable");
let email: string;
try {
email = (await fetchProfile(await exchangeCode(code))).email;
} catch (e) {
if (!(e instanceof SsoError)) console.error("[sso] callback exchange failed", e);
return bad("/auth?error=sso_failed");
}
if (st.aud === "staff") {
if (!f.ssoStaff) return bad("/auth?error=sso_unavailable");
const acc = await prisma.staffAccount.findUnique({ where: { email }, select: { id: true, facilityId: true, passwordHash: true, staff: { select: { id: true, first: true, last: true, inactive: true } } } });
if (!acc || acc.facilityId !== f.id) return bad("/auth?error=sso_no_account");
if (acc.staff.inactive) return bad("/auth?error=sso_inactive");
await prisma.staffAccount.update({ where: { id: acc.id }, data: { lastSeenAt: new Date() } });
await setStaffCookie(acc.id, acc.passwordHash, true);
recordAuthEvent({ facilityId: acc.facilityId, userId: acc.staff.id, userName: `${acc.staff.first} ${acc.staff.last}` }, "staff:signin", ip, "sso");
return back("/my");
}
const u = await prisma.user.findUnique({ where: { email }, select: { id: true, facilityId: true, passwordHash: true, first: true, last: true, inactive: true } });
if (!u || u.facilityId !== f.id) {
// A wearer typing at the shared box reaches here with aud "user"; if the facility lets its
// wearers use SSO, look them up too rather than sending them away.
if (f.ssoStaff) {
const acc = await prisma.staffAccount.findUnique({ where: { email }, select: { id: true, facilityId: true, passwordHash: true, staff: { select: { id: true, first: true, last: true, inactive: true } } } });
if (acc && acc.facilityId === f.id) {
if (acc.staff.inactive) return bad("/auth?error=sso_inactive");
await prisma.staffAccount.update({ where: { id: acc.id }, data: { lastSeenAt: new Date() } });
await setStaffCookie(acc.id, acc.passwordHash, true);
recordAuthEvent({ facilityId: acc.facilityId, userId: acc.staff.id, userName: `${acc.staff.first} ${acc.staff.last}` }, "staff:signin", ip, "sso");
return back("/my");
}
}
return bad("/auth?error=sso_no_account");
}
if (u.inactive) return bad("/auth?error=sso_inactive");
await setSessionCookie(u.id, u.passwordHash, true);
recordAuthEvent({ facilityId: u.facilityId, userId: u.id, userName: `${u.first} ${u.last}` }, "auth:signin", ip, "sso");
return back("/app");
}
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { facilityForEmail, ssoConfigured } from "@/lib/sso";
export const dynamic = "force-dynamic";
/* Does this address belong to a facility that signs in with single sign-on?
*
* Asked by the Log in box once an address is typed, so the box can offer "Continue with single
* sign-on" before anyone reaches for a password. It answers about a DOMAIN, never a person: a
* facility that registered its domain is a fact about the facility, and the reply carries nothing
* about whether the address itself has an account. Throttled per address, since it is a lookup
* anyone may make. */
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
if (!allow("sso-lookup:" + clientIp(req.headers), 60, 15 * 60 * 1000)) return NextResponse.json({ sso: false });
if (!ssoConfigured()) return NextResponse.json({ sso: false });
let body: { email?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160);
const f = await facilityForEmail(email);
if (!f) return NextResponse.json({ sso: false });
return NextResponse.json({ sso: true, required: f.ssoRequired, facility: f.name });
}
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { currentUser } from "@/lib/session";
import { allow, clientIp } from "@/lib/ratelimit";
import { buildAuthorizeUrl, facilityForEmail, mintState, ssoConfigured, STATE_COOKIE } from "@/lib/sso";
export const dynamic = "force-dynamic";
/* Begin single sign-on for the facility that owns this address's domain.
*
* Mints a nonce, keeps it in a signed, httpOnly cookie bound to that facility (and to whether a
* wearer or a coordinator is expected back), echoes it as the OAuth `state`, and sends the browser
* to the broker. The callback requires the returned state to be the cookie's nonce, so a forged
* or replayed callback has nothing to match. The redirect target handed to the broker is the
* product's one fixed callback address — never a request header.
*
* A cross-site link may not start this (login CSRF: a stranger's page must not be able to sign
* you into an account of its choosing), and a signed-in coordinator is sent to the app instead. */
export async function GET(req: NextRequest) {
if (!ssoConfigured()) return NextResponse.json({ error: "Single sign-on is not available." }, { status: 404 });
if (req.headers.get("sec-fetch-site") === "cross-site") return new NextResponse(null, { status: 303, headers: { Location: "/auth" } });
if (!allow("sso-start:" + clientIp(req.headers), 60, 15 * 60 * 1000)) return new NextResponse(null, { status: 303, headers: { Location: "/auth?error=sso_failed" } });
const cur = await currentUser();
if (cur && !cur.isDemo) return new NextResponse(null, { status: 303, headers: { Location: "/app" } });
const email = (req.nextUrl.searchParams.get("email") || "").trim().toLowerCase().slice(0, 160);
const f = await facilityForEmail(email);
if (!f) return new NextResponse(null, { status: 303, headers: { Location: "/auth?error=sso_unavailable" } });
const aud = req.nextUrl.searchParams.get("as") === "staff" ? "staff" : "user";
if (aud === "staff" && !f.ssoStaff) return new NextResponse(null, { status: 303, headers: { Location: "/auth?error=sso_unavailable" } });
const { nonce, cookie } = mintState(f.id, aud);
const res = new NextResponse(null, { status: 302, headers: { Location: buildAuthorizeUrl(f.id, nonce) } });
res.cookies.set(STATE_COOKIE, cookie, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax", // the broker returns by a top-level navigation, which lax still sends
path: "/api/auth/sso",
maxAge: 10 * 60,
});
return res;
}