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
+121
View File
@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import QRCode from "qrcode";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/session";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import {
decryptSecret, encryptSecret, hashRecoveryCode, newRecoveryCodes, newTotpSecret, otpauthUrl, totpVerify,
} from "@/lib/totp";
import { recordAuthEvent } from "@/lib/audit";
export const dynamic = "force-dynamic";
/* Turning a second factor on and off, for your own account only.
*
* Three steps rather than one, because a secret that is stored the moment it is generated leaves
* an account half-enrolled if the person never finishes — and then their next sign-in asks for
* codes from an app they never set up.
*
* setup — generate a secret and show the QR. Stored, but not yet in force.
* enable — prove a code from it works, then switch it on and hand back recovery codes.
* disable — password required, because turning a factor off is a privileged act.
*/
export async function GET() {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const u = await prisma.user.findUnique({ where: { id: user.id }, select: { totpEnabledAt: true } });
const left = await prisma.recoveryCode.count({ where: { userId: user.id, usedAt: null } });
return NextResponse.json({ enabled: !!u?.totpEnabledAt, enabledAt: u?.totpEnabledAt ?? null, recoveryLeft: left });
}
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const ip = clientIp(req.headers);
if (!allow("2fa-manage:" + user.id, 30, 15 * 60 * 1000)) {
return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 });
}
// Turning a second factor on or off is one of the few changes to an account that leaves no trace
// in the records themselves, so it is one of the few worth recording on its own.
const actor = {
facilityId: user.facilityId, userId: user.id,
userName: `${user.first} ${user.last}`.trim() || user.email,
};
let body: { action?: unknown; code?: unknown; password?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const action = String(body.action ?? "");
const u = await prisma.user.findUnique({
where: { id: user.id },
select: { id: true, email: true, passwordHash: true, totpSecret: true, totpEnabledAt: true },
});
if (!u) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
if (action === "setup") {
if (u.totpEnabledAt) return NextResponse.json({ error: "Two-factor is already on for this account." }, { status: 400 });
const secret = newTotpSecret();
await prisma.user.update({ where: { id: u.id }, data: { totpSecret: encryptSecret(secret) } });
const url = otpauthUrl(secret, u.email);
// SVG, generated here rather than in the browser: it keeps a QR library out of the bundle that
// ward phones download, and the secret never has to be handed to client-side code to render.
const qr = await QRCode.toString(url, { type: "svg", margin: 1, width: 220, errorCorrectionLevel: "M" });
recordAuthEvent(actor, "2fa:setup", ip);
return NextResponse.json({ ok: true, secret, url, qr });
}
if (action === "enable") {
if (u.totpEnabledAt) return NextResponse.json({ error: "Two-factor is already on." }, { status: 400 });
const secret = decryptSecret(u.totpSecret);
if (!secret) return NextResponse.json({ error: "Start the setup again." }, { status: 400 });
if (!totpVerify(secret, String(body.code ?? ""))) {
return NextResponse.json({ error: "That code isn't right. Use the current one from your app." }, { status: 400 });
}
const codes = newRecoveryCodes();
await prisma.$transaction(async (tx) => {
await tx.user.update({ where: { id: u.id }, data: { totpEnabledAt: new Date() } });
await tx.recoveryCode.deleteMany({ where: { userId: u.id } });
await tx.recoveryCode.createMany({ data: codes.map((c) => ({ userId: u.id, codeHash: hashRecoveryCode(c) })) });
});
recordAuthEvent(actor, "2fa:enable", ip);
// The only time these are ever readable. They are stored hashed, so there is no second chance.
return NextResponse.json({ ok: true, codes });
}
if (action === "disable") {
if (!u.totpEnabledAt) return NextResponse.json({ ok: true });
const pw = String(body.password ?? "");
if (!pw || !(await bcrypt.compare(pw, u.passwordHash))) {
return NextResponse.json({ error: "That password isn't right." }, { status: 401 });
}
await prisma.$transaction(async (tx) => {
await tx.user.update({ where: { id: u.id }, data: { totpEnabledAt: null, totpSecret: "" } });
await tx.recoveryCode.deleteMany({ where: { userId: u.id } });
});
recordAuthEvent(actor, "2fa:disable", ip);
return NextResponse.json({ ok: true });
}
if (action === "regenerate") {
if (!u.totpEnabledAt) return NextResponse.json({ error: "Two-factor isn't on." }, { status: 400 });
const pw = String(body.password ?? "");
if (!pw || !(await bcrypt.compare(pw, u.passwordHash))) {
return NextResponse.json({ error: "That password isn't right." }, { status: 401 });
}
const codes = newRecoveryCodes();
await prisma.$transaction(async (tx) => {
await tx.recoveryCode.deleteMany({ where: { userId: u.id } });
await tx.recoveryCode.createMany({ data: codes.map((c) => ({ userId: u.id, codeHash: hashRecoveryCode(c) })) });
});
recordAuthEvent(actor, "2fa:regenerate", ip);
return NextResponse.json({ ok: true, codes });
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
}
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/session";
export const dynamic = "force-dynamic";
const PAGE = 100;
/* The audit trail, read back.
*
* Admin only, and scoped to the caller's own facility by the query rather than by a filter the
* client sends — the client never gets to say which facility it wants. Paged by cursor rather
* than offset so a busy room's log doesn't shift under you as new rows land while you read.
*
* The cursor is (timestamp, id), not timestamp alone. Prisma stores DateTime at millisecond
* precision, and two events sharing a millisecond is ordinary rather than exotic — two coordinators
* saving at once, or two ops committed inside one transaction. A strict `at < cursor` dropped every
* row that shared the last one's millisecond, so the log looked complete with an event missing from
* it, which is the one failure an audit trail cannot have.
*/
/** `<iso>|<id>` — one opaque string, because the client only ever hands it straight back. */
function readCursor(raw: string | null): { at: Date; id: string } | null {
if (!raw) return null;
const cut = raw.lastIndexOf("|");
const iso = cut === -1 ? raw : raw.slice(0, cut);
const id = cut === -1 ? "" : raw.slice(cut + 1);
if (Number.isNaN(Date.parse(iso))) return null;
return { at: new Date(iso), id: id.slice(0, 40) };
}
export async function GET(req: NextRequest) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
// SessionUser.role is the database enum ("ADMIN"), not the snapshot's display form ("Admin").
if (user.role !== "ADMIN") return NextResponse.json({ error: "Admin only" }, { status: 403 });
const cursor = readCursor(req.nextUrl.searchParams.get("before"));
const rows = await prisma.auditEvent.findMany({
where: {
facilityId: user.facilityId,
// Everything strictly older, plus the rest of the millisecond we stopped in the middle of.
...(cursor ? { OR: [{ at: { lt: cursor.at } }, { at: cursor.at, id: { lt: cursor.id } }] } : {}),
},
orderBy: [{ at: "desc" }, { id: "desc" }],
take: PAGE + 1,
select: { id: true, at: true, userName: true, op: true, target: true },
});
const more = rows.length > PAGE;
const page = rows.slice(0, PAGE);
const last = page[page.length - 1];
return NextResponse.json({
events: page.map((r) => ({ id: r.id, at: r.at.toISOString(), who: r.userName, op: r.op, target: r.target })),
nextBefore: more && last ? `${last.at.toISOString()}|${last.id}` : null,
});
}
+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;
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { currentUser } from "@/lib/session";
import { prisma } from "@/lib/db";
import { exportBackup } from "@/lib/ops";
import { facilityToday } from "@/lib/compute";
export const dynamic = "force-dynamic";
export async function GET() {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
if (user.role !== "ADMIN") return NextResponse.json({ error: "Admin only" }, { status: 403 });
const data = await exportBackup(user);
// The date on the filename is the day where the linen room stands, not where the box is. It has
// to agree with the lastBackup stamp exportBackup writes against the same facility, or a room
// taking a backup at eight in the morning ends up with a file named for yesterday sitting beside
// a settings screen that says it was taken today.
const fac = await prisma.facility.findUniqueOrThrow({ where: { id: user.facilityId }, select: { timezone: true } });
return new NextResponse(JSON.stringify(data, null, 1), {
headers: {
"content-type": "application/json; charset=utf-8",
"content-disposition": `attachment; filename="threadcount-backup-${facilityToday(fac.timezone)}.json"`,
},
});
}
+81
View File
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { allow, clientIp } from "@/lib/ratelimit";
import { sameOriginJson } from "@/lib/csrf";
import { verifyTurnstile } from "@/lib/turnstile";
import { mailConfigured, sendMail } from "@/lib/mail";
export const dynamic = "force-dynamic";
const str = (v: unknown, max: number) => String(v ?? "").trim().slice(0, max);
/* Retention.
*
* A message through the contact form carries a name, a work address, a facility, a role, whatever
* the person chose to write and the address they wrote it from. It was kept forever: the model has
* no facility to cascade from, so nothing would ever have deleted one. Twelve months is long
* enough for the enquiry and any follow-up it turns into, and the privacy note says the same
* number — this is the mechanism that makes that sentence true rather than aspirational.
*
* Swept from here rather than from a cron, because a cron is a second thing to deploy and this
* table only grows when this handler runs. The limiter is doing duty as an interval: one sweep an
* hour, and the message the person is sending never waits on it. */
const RETENTION_DAYS = 365;
function pruneOldMessages() {
if (!allow("contact-prune", 1, 60 * 60 * 1000)) return;
const cutoff = new Date(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000);
void prisma.contactMessage
.deleteMany({ where: { createdAt: { lt: cutoff } } })
.then((r) => { if (r.count) console.log(`[contact] retention: removed ${r.count} message(s) older than ${RETENTION_DAYS} days`); })
.catch((e) => console.error("[contact] retention sweep failed:", (e as Error).message));
}
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const ip = clientIp(req.headers);
// Two buckets: a burst guard and a slower daily ceiling, so one address can't grind through it.
if (!allow("contact:" + ip, 5, 60 * 60 * 1000) || !allow("contact-day:" + ip, 20, 24 * 60 * 60 * 1000)) {
return NextResponse.json({ error: "That's a few messages in a short time. Try again later, or email hello@threadcount.tech." }, { status: 429 });
}
let b: Record<string, unknown>;
try { b = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
// Honeypot: a real person never fills this in.
if (str(b.company, 100)) return NextResponse.json({ ok: true });
const name = str(b.name, 120);
const email = str(b.email, 160).toLowerCase();
const message = str(b.message, 4000);
if (!name) return NextResponse.json({ error: "Add your name so I know who I'm replying to." }, { status: 400 });
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ error: "Add an email address I can reply to." }, { status: 400 });
if (message.length < 10) return NextResponse.json({ error: "Say a little more about what you need." }, { status: 400 });
const cfErr = await verifyTurnstile(b.cfToken, ip);
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
pruneOldMessages();
const row = await prisma.contactMessage.create({
data: {
name, email, message, ip,
role: str(b.role, 120), facility: str(b.facility, 160),
topic: str(b.topic, 60), slot: str(b.slot, 60),
},
});
const emailed = await sendMail(
`ThreadCount contact — ${row.topic || "A question"}${name}`,
[`From: ${name}${row.role ? ` (${row.role})` : ""}`, row.facility && `Facility: ${row.facility}`, `Email: ${email}`,
row.topic && `Topic: ${row.topic}`, row.slot && `Walkthrough: ${row.slot}`, "", message, "", `Received ${row.createdAt.toISOString()} from ${ip}`]
.filter(Boolean).join("\n"),
email,
);
if (emailed) await prisma.contactMessage.update({ where: { id: row.id }, data: { emailed: true } });
else if (!mailConfigured()) console.warn("[contact] stored", row.id, "— SMTP not configured, no notification sent");
return NextResponse.json({ ok: true });
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
/* Is this server actually able to do its job?
*
* The deploy probes /app, which proves the process is serving HTML — but /app renders a redirect to
* the sign-in page whether or not Prisma can reach the database, so the one failure that takes the
* whole product down is exactly the one that probe cannot see. This asks the database a question
* instead, and answers 503 when it cannot.
*
* No auth and no cache on purpose: it is watched continuously by an uptime monitor with no account,
* and it must never answer from a cached success. It is listed in proxy.ts's `publicApi` for the
* same reason. Nothing about the facility, the schema or the error is returned — a monitor needs a
* status code, and an unauthenticated caller is owed nothing more.
*/
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({ ok: true }, { headers: { "cache-control": "no-store" } });
} catch (e) {
console.error("[health] database unreachable:", (e as Error).message);
return NextResponse.json({ ok: false }, { status: 503, headers: { "cache-control": "no-store" } });
}
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { currentUser } from "@/lib/session";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
/** Serves the signed-in user's facility logo (stored as a data URL). */
export async function GET() {
const user = await currentUser();
if (!user) return new NextResponse(null, { status: 401 });
const fac = await prisma.facility.findUnique({ where: { id: user.facilityId }, select: { logoData: true } });
const m = /^data:(image\/(?:png|jpeg|jpg|gif|webp));base64,([A-Za-z0-9+/=]+)$/.exec(fac?.logoData || "");
if (!m) return new NextResponse(null, { status: 404 });
return new NextResponse(Buffer.from(m[2], "base64"), { headers: { "content-type": m[1], "cache-control": "private, no-cache", "x-content-type-options": "nosniff", "content-security-policy": "sandbox" } });
}
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/session";
import { allow } from "@/lib/ratelimit";
import { gtinInfo } from "@/lib/compute";
export const dynamic = "force-dynamic";
export type LookupResult = {
code: string;
gtin: ReturnType<typeof gtinInfo>;
enabled: boolean; // is public lookup turned on for this facility
found: boolean;
name?: string;
brand?: string;
category?: string;
source?: string;
note?: string; // why there's no result, in plain words
};
const TIMEOUT_MS = 4500;
const cache = new Map<string, { at: number; v: Omit<LookupResult, "enabled" | "gtin" | "code"> }>();
const CACHE_MS = 12 * 60 * 60 * 1000;
async function getJson(url: string): Promise<unknown | null> {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), TIMEOUT_MS);
try {
const r = await fetch(url, { signal: ac.signal, headers: { accept: "application/json", "user-agent": "ThreadCount/1.0 (uniform stock management)" }, cache: "no-store" });
if (!r.ok) return null;
return await r.json();
} catch { return null; } finally { clearTimeout(t); }
}
/** UPCitemdb's keyless trial tier — small daily quota per server IP, so misses are expected. */
async function upcItemDb(gtin: string) {
const j = await getJson(`https://api.upcitemdb.com/prod/trial/lookup?upc=${encodeURIComponent(gtin)}`) as { items?: { title?: string; brand?: string; category?: string }[] } | null;
const it = j?.items?.[0];
if (!it?.title) return null;
return { name: String(it.title).slice(0, 160), brand: String(it.brand || "").slice(0, 80), category: String(it.category || "").slice(0, 80), source: "UPCitemdb" };
}
/** Open Products Facts — the non-food sibling of Open Food Facts; open data, no key. */
async function openProductsFacts(gtin: string) {
const j = await getJson(`https://world.openproductsfacts.org/api/v2/product/${encodeURIComponent(gtin)}.json?fields=product_name,brands,categories`) as { status?: number; product?: { product_name?: string; brands?: string; categories?: string } } | null;
const pr = j?.product;
if (j?.status !== 1 || !pr?.product_name) return null;
return { name: String(pr.product_name).slice(0, 160), brand: String(pr.brands || "").slice(0, 80), category: String(pr.categories || "").slice(0, 80), source: "Open Products Facts" };
}
export async function GET(req: NextRequest) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
if (user.role !== "ADMIN") return NextResponse.json({ error: "Admin only" }, { status: 403 });
const gtin = gtinInfo(req.nextUrl.searchParams.get("code") || "");
const base = { code: gtin.code, gtin, found: false } as LookupResult;
if (!gtin.code) return NextResponse.json({ ...base, enabled: false, note: "No barcode given." });
const fac = await prisma.facility.findUnique({ where: { id: user.facilityId }, select: { barcodeLookup: true } });
const enabled = !!fac?.barcodeLookup;
if (!enabled) return NextResponse.json({ ...base, enabled: false, note: "Product lookup is off. Turn it on in Settings → Data if you want ThreadCount to ask a public barcode database for a name." });
// Only real retail GTINs are worth sending anywhere; a mis-read or an in-house code never matches.
if (!gtin.valid || !["EAN-13", "UPC-A", "EAN-8", "GTIN-14"].includes(gtin.kind)) {
return NextResponse.json({ ...base, enabled, note: gtin.kind ? "The check digit doesn't match, so this wasn't looked up — scan it again." : "Not a standard retail barcode, so there's nothing to look up. Type the details in." });
}
if (!allow("lookup:" + user.facilityId, 120, 60 * 60 * 1000)) return NextResponse.json({ ...base, enabled, note: "Too many lookups this hour — type the details in for now." }, { status: 429 });
const hit = cache.get(gtin.digits);
if (hit && Date.now() - hit.at < CACHE_MS) return NextResponse.json({ ...base, enabled, ...hit.v });
let found = await upcItemDb(gtin.digits);
if (!found) found = await openProductsFacts(gtin.digits);
const v = found
? { found: true, ...found }
: { found: false, note: "No public listing for this barcode — normal for workwear and hospital uniforms. Type the details in once and the barcode stays bound." };
cache.set(gtin.digits, { at: Date.now(), v });
if (cache.size > 500) for (const k of [...cache.keys()].slice(0, 100)) cache.delete(k);
return NextResponse.json({ ...base, enabled, ...v });
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { currentUser } from "@/lib/session";
import { OpError, bumpRev, demoGuard, restoreBackup, runOp } from "@/lib/ops";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { recordAudit } from "@/lib/audit";
import { report } from "@/lib/glitchtip";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
if (parseInt(req.headers.get("content-length") || "0", 10) > 60 * 1024 * 1024) return NextResponse.json({ error: "Request too large" }, { status: 413 });
let body: { op?: string; payload?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad JSON" }, { status: 400 }); }
const op = String(body.op || "");
if (!allow("mutate:" + user.id, 600, 60 * 1000)) return NextResponse.json({ error: "Slow down — too many changes in a minute." }, { status: 429 });
if (op === "photo.put" && !allow("photo:" + user.facilityId, 120, 60 * 60 * 1000)) return NextResponse.json({ error: "Photo limit reached for this hour." }, { status: 429 });
if ((op === "backup.restore" || op === "import.rows") && !allow("bulk:" + user.id, 20, 10 * 60 * 1000)) return NextResponse.json({ error: "Too many imports — wait a few minutes." }, { status: 429 });
try {
if (op === "backup.restore") demoGuard(user, op);
const result = op === "backup.restore" ? await restoreBackup(user, body.payload) : await runOp(user, op, body.payload);
// Only after it actually succeeded, and only from here: every one of the 57 ops passes through
// this one function, so the trail can't be forgotten in a new case branch later.
recordAudit(user, op, body.payload, clientIp(req.headers));
// Handed back so the screen that made this change does not bounce again when it next polls.
const rev = await bumpRev(user.facilityId);
return NextResponse.json({ ok: true, result, rev });
} catch (e) {
if (e instanceof OpError) return NextResponse.json({ error: e.message }, { status: e.status });
// Reported from here, not from instrumentation.ts: onRequestError only sees what Next itself
// catches, and an exception caught in this handler never reaches it. Every write in the product
// comes through this line, so without it the whole write path fails invisibly.
report({ error: e, where: "server", url: "/api/mutate", tags: { op } });
console.error(`[mutate ${op}]`, e);
return NextResponse.json({ error: "Something went wrong — nothing was saved." }, { status: 500 });
}
}
+100
View File
@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { sameOriginJson } from "@/lib/csrf";
import { clientIp, fail, over } from "@/lib/ratelimit";
import { setOpsCookie, logOperatorEvent } from "@/lib/ops/session";
import { verifyOperatorCode } from "@/lib/ops/totp";
import { hashRecoveryCode } from "@/lib/totp";
export const dynamic = "force-dynamic";
/* The break-glass door for the operations console.
*
* Single sign-on is the front door. This is the fire escape, and a fire escape must not depend
* on the thing that is on fire — so there is no Turnstile here. The sitekey is bound to the
* product's domain and compiled in at build time; on this hostname it would refuse with a
* generic "security check failed", discovered during the exact incident this route exists for.
*
* Instead: failures are counted under the console's own buckets. Not `login-ip:` — that is the
* product's, and a public credential-stuffing run against coordinator accounts must not be able
* to lock the operator out of the console. The limits are tighter than the product's because
* there is one operator, not a ward arriving at shift change.
*
* ⛔ This route mints an OPERATOR session and nothing else. It must never call setSessionCookie
* or setStaffCookie; an operator signing in as a customer is the data plane by another door. */
// A constant to compare against when there is no such operator, so a missing address and a
// wrong password take the same time.
const DUMMY = "$2b$12$C6UzMDM.H6dfI/f/IKcEeO5x3FvDS3kqB6r0Jt3g7Lz0vX4o0JZ1u";
const WINDOW = 15 * 60 * 1000;
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
let body: { email?: unknown; password?: unknown; code?: 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 password = String(body.password ?? "").slice(0, 200);
const code = body.code === undefined || body.code === null ? "" : String(body.code).slice(0, 16);
const ip = clientIp(req.headers);
if (over("ops-login-ip:" + ip, 20, WINDOW) || (email && over("ops-login-email:" + email, 10, WINDOW))) {
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 });
const op = await prisma.operator.findUnique({
where: { email },
select: { id: true, name: true, passwordHash: true, inactive: true, totpSecret: true, totpEnabledAt: true },
});
const ok = await bcrypt.compare(password, op?.passwordHash ?? DUMMY);
if (!op || !ok) {
fail("ops-login-ip:" + ip, WINDOW);
if (email) fail("ops-login-email:" + email, WINDOW);
if (op) logOperatorEvent({ operatorId: op.id, action: "ops:signin.failed", detail: "password", ip });
return NextResponse.json({ error: "Email or password doesnt match." }, { status: 401 });
}
if (op.inactive) {
logOperatorEvent({ operatorId: op.id, action: "ops:signin.refused", detail: "inactive", ip });
return NextResponse.json({ error: "This operator account has been deactivated." }, { status: 403 });
}
// Once a second factor is enrolled, the password alone opens nothing. `needCode` tells the form
// to ask for it; a wrong code is a counted failure like a wrong password.
let second = "";
if (op.totpEnabledAt) {
if (!code) return NextResponse.json({ error: "Enter the code from your authenticator app.", needCode: true }, { status: 401 });
// Ten or more letters and digits is a recovery code; six digits is an authenticator code — the
// same heuristic as the coordinator door. A recovery code is spent in the same conditional
// update that finds it, so it cannot be used twice, and a spent or unknown one is a counted
// failure like any other.
const looksRecovery = code.replace(/[^A-Za-z0-9]/g, "").length >= 10;
if (looksRecovery) {
const spent = await prisma.operatorRecoveryCode.updateMany({
where: { operatorId: op.id, codeHash: hashRecoveryCode(code), usedAt: null },
data: { usedAt: new Date() },
});
if (spent.count !== 1) {
fail("ops-login-ip:" + ip, WINDOW);
fail("ops-login-email:" + email, WINDOW);
logOperatorEvent({ operatorId: op.id, action: "ops:signin.failed", detail: "recovery", ip });
return NextResponse.json({ error: "That recovery code isnt right, or has already been used.", needCode: true }, { status: 401 });
}
second = "recovery";
} else if (!verifyOperatorCode(op, code)) {
fail("ops-login-ip:" + ip, WINDOW);
fail("ops-login-email:" + email, WINDOW);
logOperatorEvent({ operatorId: op.id, action: "ops:signin.failed", detail: "totp", ip });
return NextResponse.json({ error: "That code isnt right.", needCode: true }, { status: 401 });
} else {
second = "totp";
}
}
await prisma.operator.update({ where: { id: op.id }, data: { lastSeenAt: new Date() } });
await setOpsCookie(op.id, op.passwordHash, false);
logOperatorEvent({ operatorId: op.id, action: "ops:signin", detail: second ? `password+${second}` : "password", ip });
return NextResponse.json({ ok: true, name: op.name });
}
+16
View File
@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { clientIp } from "@/lib/ratelimit";
import { clearOpsCookie, currentOperator, logOperatorEvent } from "@/lib/ops/session";
export const dynamic = "force-dynamic";
/* Ends the operator session. Reached by a plain form post from the console, so it answers with a
* redirect rather than JSON. Recorded in the trail when there was a session to end. */
export async function POST(req: NextRequest) {
const op = await currentOperator();
if (op) logOperatorEvent({ operatorId: op.id, action: "ops:signout", ip: clientIp(req.headers) });
await clearOpsCookie();
const url = req.nextUrl.clone();
url.pathname = "/ops/login"; url.search = "";
return NextResponse.redirect(url, { status: 303 });
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { clientIp } from "@/lib/ratelimit";
import { safeOpsNext, verifyAccessJwt } from "@/lib/ops/cfAccess";
import { logOperatorEvent, setOpsCookie } from "@/lib/ops/session";
export const dynamic = "force-dynamic";
/* Single sign-on entry. Reached when a browser arrives with a Cloudflare Access assertion but no
* operator session (the sign-in page hands off here). The assertion is verified fail-closed
* (lib/ops/cfAccess.ts), the verified email is mapped to an EXISTING, ACTIVE operator, and the
* ordinary tc_ops session is minted, marked `sso`. Never creates an operator: an email Access
* admits but the console does not know falls back to the password door. Every failure lands on
* /ops/login?sso=failed — never a bypass, never a loop (the sign-in page does not re-trigger SSO
* when ?sso is present).
*
* Relative Location on purpose: an absolute URL built from the request would carry the origin
* nginx sees (127.0.0.1:3000), not the public host. */
function seeOther(location: string): NextResponse {
return new NextResponse(null, { status: 303, headers: { Location: location } });
}
export async function GET(req: NextRequest) {
const failed = seeOther("/ops/login?sso=failed");
const next = safeOpsNext(req.nextUrl.searchParams.get("next"));
const ip = clientIp(req.headers);
const email = await verifyAccessJwt(req.headers.get("cf-access-jwt-assertion"));
if (!email) return failed;
const op = await prisma.operator.findUnique({
where: { email },
select: { id: true, inactive: true, passwordHash: true },
});
if (!op || op.inactive) {
// The trail cannot name an operator it does not have; the address goes in the detail, since
// "who Access let in that the console refused" is the fact worth keeping.
console.warn("[ops sso] no active operator for", email, "from", ip);
return failed;
}
await setOpsCookie(op.id, op.passwordHash, true);
logOperatorEvent({ operatorId: op.id, action: "ops:signin.sso", ip });
prisma.operator.update({ where: { id: op.id }, data: { lastSeenAt: new Date() } }).catch(() => {});
return seeOther(next);
}
+112
View File
@@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import QRCode from "qrcode";
import { prisma } from "@/lib/db";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { hashRecoveryCode, newRecoveryCodes, newTotpSecret, otpauthUrl, totpVerify } from "@/lib/totp";
import { currentOperator, logOperatorEvent } from "@/lib/ops/session";
import { decryptOpsSecret, encryptOpsSecret } from "@/lib/ops/totp";
export const dynamic = "force-dynamic";
/* An operator's second factor: the same three steps as a coordinator's (app/api/2fa/route.ts),
* for the same reason — a secret stored the moment it is generated leaves an account
* half-enrolled if the person never finishes, and their next sign-in asks for codes from an app
* they never set up.
*
* setup — generate a secret and show the QR. Stored, but not yet in force.
* enable — prove a code from it works, switch it on, hand back recovery codes once.
* disable — password required; turning a factor off is a privileged act.
* regenerate — password required; new recovery codes, old ones gone.
*
* The secret is encrypted under the console's own key (lib/ops/totp.ts), never the product's.
* The QR is generated here as SVG, as the product does it: the secret never has to be handed to
* client-side code to render. */
export async function GET() {
const op = await currentOperator();
if (!op) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const left = await prisma.operatorRecoveryCode.count({ where: { operatorId: op.id, usedAt: null } });
return NextResponse.json({ enabled: op.totpEnabled, recoveryLeft: left, viaSso: op.viaSso });
}
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const op = await currentOperator();
if (!op) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const ip = clientIp(req.headers);
if (!allow("ops-2fa-manage:" + op.id, 30, 15 * 60 * 1000)) {
return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 });
}
let body: { action?: unknown; code?: unknown; password?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const action = String(body.action ?? "");
const o = await prisma.operator.findUnique({
where: { id: op.id },
select: { id: true, email: true, passwordHash: true, totpSecret: true, totpEnabledAt: true },
});
if (!o) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
if (action === "setup") {
if (o.totpEnabledAt) return NextResponse.json({ error: "Two-factor is already on for this account." }, { status: 400 });
const secret = newTotpSecret();
await prisma.operator.update({ where: { id: o.id }, data: { totpSecret: encryptOpsSecret(secret) } });
const url = otpauthUrl(secret, o.email, "ThreadCount ops");
const qr = await QRCode.toString(url, { type: "svg", margin: 1, width: 220, errorCorrectionLevel: "M" });
logOperatorEvent({ operatorId: o.id, action: "ops:2fa.setup", ip });
return NextResponse.json({ ok: true, secret, url, qr });
}
if (action === "enable") {
if (o.totpEnabledAt) return NextResponse.json({ error: "Two-factor is already on." }, { status: 400 });
const secret = decryptOpsSecret(o.totpSecret);
if (!secret) return NextResponse.json({ error: "Start the setup again." }, { status: 400 });
if (!totpVerify(secret, String(body.code ?? "").replace(/\s+/g, ""))) {
return NextResponse.json({ error: "That code isn't right. Use the current one from your app." }, { status: 400 });
}
const codes = newRecoveryCodes();
await prisma.$transaction(async (tx) => {
await tx.operator.update({ where: { id: o.id }, data: { totpEnabledAt: new Date() } });
await tx.operatorRecoveryCode.deleteMany({ where: { operatorId: o.id } });
await tx.operatorRecoveryCode.createMany({ data: codes.map((c) => ({ operatorId: o.id, codeHash: hashRecoveryCode(c) })) });
});
logOperatorEvent({ operatorId: o.id, action: "ops:2fa.enable", ip });
// The only time these are ever readable. Stored hashed, so there is no second chance.
return NextResponse.json({ ok: true, codes });
}
if (action === "disable") {
if (!o.totpEnabledAt) return NextResponse.json({ ok: true });
const pw = String(body.password ?? "");
if (!pw || !(await bcrypt.compare(pw, o.passwordHash))) {
return NextResponse.json({ error: "That password isn't right." }, { status: 401 });
}
await prisma.$transaction(async (tx) => {
await tx.operator.update({ where: { id: o.id }, data: { totpEnabledAt: null, totpSecret: "" } });
await tx.operatorRecoveryCode.deleteMany({ where: { operatorId: o.id } });
});
logOperatorEvent({ operatorId: o.id, action: "ops:2fa.disable", ip });
return NextResponse.json({ ok: true });
}
if (action === "regenerate") {
if (!o.totpEnabledAt) return NextResponse.json({ error: "Two-factor isn't on." }, { status: 400 });
const pw = String(body.password ?? "");
if (!pw || !(await bcrypt.compare(pw, o.passwordHash))) {
return NextResponse.json({ error: "That password isn't right." }, { status: 401 });
}
const codes = newRecoveryCodes();
await prisma.$transaction(async (tx) => {
await tx.operatorRecoveryCode.deleteMany({ where: { operatorId: o.id } });
await tx.operatorRecoveryCode.createMany({ data: codes.map((c) => ({ operatorId: o.id, codeHash: hashRecoveryCode(c) })) });
});
logOperatorEvent({ operatorId: o.id, action: "ops:2fa.regenerate", ip });
return NextResponse.json({ ok: true, codes });
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
}
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { currentOperator } from "@/lib/ops/session";
import { ControlError, deleteFacility, PLAN_NOTE_MAX, planControl, resetDemoNow, setSwitch, type PlanAct } from "@/lib/ops/controls";
export const dynamic = "force-dynamic";
/* The console's one write endpoint. Each action is a function in lib/ops/controls.ts; this route
* checks the operator, shapes the input and turns a ControlError into a status. */
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const op = await currentOperator();
if (!op) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const ip = clientIp(req.headers);
if (!allow("ops-controls:" + op.id, 30, 15 * 60 * 1000)) {
return NextResponse.json({ error: "Too many changes — try again in a few minutes." }, { status: 429 });
}
let body: Record<string, unknown>;
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const s = (k: string, max = 200) => String(body[k] ?? "").slice(0, max);
const facilityId = s("facilityId", 40);
const idOk = /^[a-z0-9]{20,40}$/.test(facilityId);
try {
switch (s("action", 40)) {
case "switch": {
const key = s("key", 40);
if (key !== "signupsDisabled" && key !== "demoDisabled" && key !== "plansLive") return NextResponse.json({ error: "Unknown switch" }, { status: 400 });
await setSwitch(op, key, body.value === true, ip);
return NextResponse.json({ ok: true });
}
case "demo.reset":
await resetDemoNow(op, ip);
return NextResponse.json({ ok: true });
case "plan": {
if (!idOk) return NextResponse.json({ error: "Bad request" }, { status: 400 });
const num = (k: string) => Number(body[k]);
let a: PlanAct;
switch (s("act", 20)) {
case "set": a = { act: "set", plan: s("plan", 40), planNote: s("planNote", PLAN_NOTE_MAX + 1), grandfathered: typeof body.grandfathered === "boolean" ? body.grandfathered : undefined }; break;
case "trial": a = { act: "trial", days: num("days") }; break;
case "paid": a = { act: "paid", months: num("months") }; break;
case "readonly": a = { act: "readonly", on: body.on === true }; break;
case "free": a = { act: "free" }; break;
default: return NextResponse.json({ error: "Unknown plan action" }, { status: 400 });
}
await planControl(op, facilityId, a, ip);
return NextResponse.json({ ok: true });
}
case "facility.delete": {
if (!idOk) return NextResponse.json({ error: "Bad request" }, { status: 400 });
const r = await deleteFacility(op, facilityId, s("confirm", 200), s("code", 20), ip);
return NextResponse.json({ ok: true, deleted: r.name });
}
default:
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
}
} catch (e) {
if (e instanceof ControlError) return NextResponse.json({ error: e.message }, { status: e.status });
throw e;
}
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { currentOperator } from "@/lib/ops/session";
import { opsDb } from "@/lib/ops/db";
import { grantReveal, REASON_MIN, REASON_MAX, REVEAL_MINUTES } from "@/lib/ops/reveal";
export const dynamic = "force-dynamic";
/* Open a thirty-minute window on one facility's coordinator contacts. The whole act — grant row,
* trail row, email — is lib/ops/reveal.ts; this route only checks the operator, the facility and
* the reason, and answers. The contacts are not in the response: the page reads them, through the
* reveal role, on its next render. */
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const op = await currentOperator();
if (!op) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const ip = clientIp(req.headers);
// Ten an hour: a reveal is a considered act, and a run of them across facilities is exactly the
// pattern the limit exists to slow down.
if (!allow("ops-reveal:" + op.id, 10, 60 * 60 * 1000)) {
return NextResponse.json({ error: "Too many reveals in the last hour." }, { status: 429 });
}
let body: { facilityId?: unknown; reason?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const facilityId = String(body.facilityId ?? "").trim();
const reason = String(body.reason ?? "").trim();
if (!/^[a-z0-9]{20,40}$/.test(facilityId)) return NextResponse.json({ error: "Bad request" }, { status: 400 });
if (reason.length < REASON_MIN) {
return NextResponse.json({ error: `Give a reason — at least ${REASON_MIN} characters. It goes in the trail and in the email.` }, { status: 400 });
}
if (reason.length > REASON_MAX) return NextResponse.json({ error: `Keep the reason under ${REASON_MAX} characters.` }, { status: 400 });
// The facility's name and kind come from the ordinary role; nothing here reads a contact.
const f = await opsDb().facility.findUnique({ where: { id: facilityId }, select: { id: true, name: true, isDemo: true } });
if (!f) return NextResponse.json({ error: "No such facility" }, { status: 404 });
if (f.isDemo) return NextResponse.json({ error: "The demo facility has no real contacts to reveal." }, { status: 400 });
const r = await grantReveal({ operator: op, facilityId: f.id, facilityName: f.name, reason, ip });
return NextResponse.json({ ok: true, minutes: REVEAL_MINUTES, expiresAt: r.expiresAt.toISOString(), mailed: r.mailed });
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/session";
import { parseDataUrl, readPhoto } from "@/lib/photostore";
export const dynamic = "force-dynamic";
/* Serves a stored capture or signature to a signed-in user of the same facility.
*
* Images live on disk now; rows written before that move still carry a base64 data URL, so both
* are handled and old records keep working without a flag day. */
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const { id } = await ctx.params;
// Scoped by facility in the query: a photo id from another room is simply not found.
const ph = await prisma.photo.findFirst({
where: { id, facilityId: user.facilityId },
select: { data: true, path: true, mime: true },
});
if (!ph) return NextResponse.json({ error: "Not found" }, { status: 404 });
let mime = ph.mime;
let bytes: Buffer | null = null;
if (ph.path) {
bytes = await readPhoto(ph.path);
} else if (ph.data) {
const parsed = parseDataUrl(ph.data);
if (parsed) { mime = parsed.mime; bytes = parsed.bytes; }
}
if (!bytes) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!/^image\/(jpeg|png)$/.test(mime)) return NextResponse.json({ error: "Bad photo" }, { status: 500 });
return new NextResponse(new Uint8Array(bytes), {
headers: {
"content-type": mime,
"cache-control": "private, max-age=3600",
"content-disposition": "inline",
"x-content-type-options": "nosniff",
"content-security-policy": "sandbox",
},
});
}
+220
View File
@@ -0,0 +1,220 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/session";
import { bagLines, linesSummary, reqLines } from "@/lib/staffdata";
import { decisionSummary, garmentCount } from "@/lib/staffreq";
export const dynamic = "force-dynamic";
/* The linen room's view of staff requests.
*
* Its own endpoint rather than part of the snapshot, for the same reason the audit trail is: this
* grows without limit, and putting it in the snapshot would make every page in the app heavier
* forever to serve one screen.
*/
export async function GET(req: NextRequest) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
/* One person's requests, or the whole facility's.
*
* `?staff=` is how a staff record asks for its own order-form history. Without it that screen
* pulled the facility's last 400 requests — every line, message and event on each — and kept the
* handful belonging to one person, which is a large answer to a small question on a busy
* register. Worse, that person's older requests fell off the end of the 400 and simply were not
* on their record any more. Scoped, the ceiling is per person, and either way it is reported
* back so a screen can say it has been reached rather than ending a history without a word.
*/
const staffId = (req.nextUrl.searchParams.get("staff") || "").trim().slice(0, 64);
const requestLimit = staffId ? 200 : 400;
// One more row than is returned, so "there are older ones than these" is something we know
// rather than something guessed from a full page.
const found = await prisma.request.findMany({
where: { facilityId: user.facilityId, ...(staffId ? { subjectId: staffId } : {}) },
orderBy: { createdAt: "desc" },
take: requestLimit + 1,
include: {
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
subject: { select: { first: true, last: true, num: true, dept: true } },
messages: { orderBy: { createdAt: "asc" }, select: { id: true, fromStaff: true, authorName: true, body: true, createdAt: true } },
events: { orderBy: { at: "asc" }, select: { id: true, label: true, meta: true, actorName: true, at: true } },
},
});
const requests = found.slice(0, requestLimit);
const moreRequests = found.length > requestLimit;
const mapped = requests.map((r) => {
/* Every line, and separately the ones that are actually a pick.
*
* The linen room needs both. `lines` is the record — a declined fleece still belongs on the
* order the wearer will read — while `bag` is the work: what to take off the shelf, put in
* the bag and hand across the counter. Picking from `lines` would put a garment the manager
* refused into somebody's hands, so the two are never the same field. */
const lines = reqLines(r.lines);
const bag = bagLines(lines);
return {
id: r.id, code: r.code, status: r.status,
staffId: r.subjectId,
staffName: `${r.subject.first} ${r.subject.last}`.trim(),
staffNum: r.subject.num, ward: r.subject.dept,
lines, bag,
summary: linesSummary(lines), garments: garmentCount(bag), lineCount: lines.length,
decision: decisionSummary(lines),
reason: r.reason, note: r.note,
managerName: r.managerName,
/* Who the approver is, not just how their name is spelled. A manager may now approve a
* request raised for herself, and the only thing that can show that happened is this id
* beside the subject's — the name on its own would have any screen comparing two spellings
* of the same person, which is precisely how a self-approval goes unnoticed. */
managerId: r.managerId,
declineReason: r.declineReason,
route: r.route, collectCode: r.collectCode, holdUntil: r.holdUntil,
signerName: r.signerName, signerRole: r.signerRole,
signedAt: r.signedAt?.toISOString() ?? null,
claimedAt: r.claimedAt?.toISOString() ?? null,
/* Who raised it, and which person on the register that is.
*
* The name alone is not enough for the queue screen: it builds the list of people a stranded
* request can be handed to, and the one name certain to be refused is the person who raised
* it — a manager asking for one of her own reports' garments is exactly why the request
* escalated with nobody to approve it. Told only her name, the screen would have to match
* her by spelling against a ward where two people share one, which is how the wrong person
* drops out of a dropdown.
*
* Only the staff column, because only it can ever name somebody who could approve anything.
* A raise at the counter is stamped with the coordinator's own account instead, and a
* coordinator is not on the ward register at all; a wearer raising for herself is stamped
* with neither. Both arrive here as null, which is right — neither is a name this queue
* could offer. */
raisedById: r.raisedByStaffId,
raisedByName: r.raisedByName,
createdAt: r.createdAt.toISOString(),
decidedAt: r.decidedAt?.toISOString() ?? null,
messages: r.messages.map((m) => ({ id: m.id, fromStaff: m.fromStaff, authorName: m.authorName, body: m.body, at: m.createdAt.toISOString() })),
events: r.events.map((e) => ({ id: e.id, label: e.label, meta: e.meta, actorName: e.actorName, at: e.at.toISOString() })),
};
});
/* Everything below is the linen room's queue screen — open disputes, the kit check, the
* waitlist, damage nobody has handed back. A staff record asks for one person's order forms and
* reads none of it, so a scoped ask stops here instead of running four more facility-wide
* queries whose answers are thrown away. Those keys are absent from a scoped reply rather than
* empty: an empty list would read as "there are none", which nobody asked and nobody knows. */
if (staffId) return NextResponse.json({ requests: mapped, requestLimit, moreRequests });
const [disputes, cycle, waiting, damage] = await Promise.all([
prisma.recordDispute.findMany({
where: { facilityId: user.facilityId, resolvedAt: null },
orderBy: { createdAt: "desc" },
take: 100,
include: { staff: { select: { first: true, last: true, num: true, dept: true } } },
}),
prisma.kitCheck.findFirst({
where: { facilityId: user.facilityId, closedAt: null },
orderBy: { openedAt: "desc" },
select: { id: true, dueBy: true, openedAt: true, openedBy: true, _count: { select: { answers: true } } },
}),
prisma.waitlistEntry.findMany({
where: { facilityId: user.facilityId, leftAt: null, acceptedAt: null },
orderBy: { createdAt: "asc" },
include: {
staff: { select: { first: true, last: true, num: true, dept: true } },
item: { select: { item: true, sizes: true } },
},
}),
// Damage reports the counter has not yet taken the garment back for. Reporting damage and
// asking for a replacement are two separate acts in the staff app, so a report can arrive with
// no request behind it — and until this list existed nothing in the product ever showed one to
// anybody, which made the Damage screen's promise ("it comes off your record when you hand it
// in at the counter") a promise no screen could keep.
prisma.damageReport.findMany({
where: { facilityId: user.facilityId, handedInAt: null },
orderBy: { createdAt: "desc" },
take: 100,
include: {
staff: { select: { first: true, last: true, num: true, dept: true } },
issue: { select: { sizeIndex: true, item: { select: { item: true, sizes: true } } } },
},
}),
]);
/* What the open kit check has actually turned up.
*
* The cycle used to be reported to the linen room as a bare count of answers, which is the one
* thing about it that doesn't matter: nobody opens a kit check to find out how many people
* replied. The answers are the point — every one where somebody could not account for what the
* record says they hold — and until this query existed no screen, export or report in the
* product read them, so the whole cycle collected evidence into a table nothing looked at.
*
* Only the shortfalls, and only for the cycle still open. An answer that matches the record is
* the record agreeing with itself; a closed cycle is history and belongs with the rest of it.
*/
const answers = cycle
? await prisma.kitCheckAnswer.findMany({
where: { kitCheckId: cycle.id, confirmed: { lt: prisma.kitCheckAnswer.fields.onRecord } },
orderBy: { answeredAt: "desc" },
take: 400,
include: {
staff: { select: { id: true, first: true, last: true, num: true, dept: true } },
item: { select: { item: true, sizes: true } },
},
})
: [];
// DamageReport.requestId is a plain column rather than a relation, so the replacement's code is
// looked up here. It is what the linen room actually needs: "torn, and she has asked for R-0042"
// is a different job from "torn, and she has not".
const replacementCodes = new Map<string, string>();
const replacementIds = damage.map((d) => d.requestId).filter((x): x is string => !!x);
if (replacementIds.length) {
const reps = await prisma.request.findMany({
where: { facilityId: user.facilityId, id: { in: replacementIds } },
select: { id: true, code: true },
});
for (const r of reps) replacementCodes.set(r.id, r.code);
}
return NextResponse.json({
requests: mapped, requestLimit, moreRequests,
disputes: disputes.map((d) => ({
id: d.id, body: d.body,
staffName: `${d.staff.first} ${d.staff.last}`.trim(),
staffNum: d.staff.num, ward: d.staff.dept,
at: d.createdAt.toISOString(),
})),
cycle: cycle && {
id: cycle.id, dueBy: cycle.dueBy, openedBy: cycle.openedBy,
openedAt: cycle.openedAt.toISOString(), answers: cycle._count.answers,
},
shortfalls: answers.map((a) => ({
id: a.id,
staffId: a.staff.id,
staffName: `${a.staff.first} ${a.staff.last}`.trim(),
staffNum: a.staff.num, ward: a.staff.dept,
item: a.item.item, size: String(a.item.sizes[a.sizeIndex] ?? a.sizeIndex),
onRecord: a.onRecord, confirmed: a.confirmed, short: a.onRecord - a.confirmed,
at: a.answeredAt.toISOString(),
})),
waiting: waiting.map((w) => ({
id: w.id,
staffName: `${w.staff.first} ${w.staff.last}`.trim(),
staffNum: w.staff.num, ward: w.staff.dept,
item: w.item.item, size: String(w.item.sizes[w.sizeIndex] ?? w.sizeIndex),
since: w.createdAt.toISOString(),
offeredAt: w.offeredAt?.toISOString() ?? null,
})),
damage: damage.map((d) => ({
id: d.id, kind: d.kind, note: d.note, photoId: d.photoId,
staffId: d.staffId,
staffName: `${d.staff.first} ${d.staff.last}`.trim(),
staffNum: d.staff.num, ward: d.staff.dept,
// The garment comes off the Issue the report was raised against. That issue can be deleted
// (a wipe, a correction) and the column is SetNull, so an older report may name no garment.
item: d.issue ? d.issue.item.item : "",
size: d.issue ? String(d.issue.item.sizes[d.issue.sizeIndex] ?? d.issue.sizeIndex) : "",
requestCode: d.requestId ? replacementCodes.get(d.requestId) ?? "" : "",
at: d.createdAt.toISOString(),
})),
});
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/session";
import { currentStaff } from "@/lib/staffsession";
export const dynamic = "force-dynamic";
/* "Has anything changed?", answered in one integer.
*
* Every screen in the product already knows how to reload itself — a mutation ends in
* router.refresh(). What it could not know was that somebody ELSE had changed something, so a
* phone left open on a ward showed whatever the catalogue looked like when it was opened, and a
* coordinator adding a garment at the desk had to tell the counter to pull down to refresh.
*
* The obvious fix — poll the snapshot and diff it — is the expensive one: that is the facility's
* catalogue, staff register, stock and history, re-read on a timer by every open device to learn,
* almost always, that nothing happened. This returns the counter that the three mutating routes
* bump, so the cost of asking is a primary-key lookup, and the cost of the real reload is paid only
* when the number has actually moved.
*
* Both session kinds answer here. A coordinator at the desk and a wearer on a ward are watching the
* same facility, and there is nothing in a bare revision number to keep apart — it says that
* something changed, never what. Anyone with no session at all gets 401 rather than a number,
* because even "this facility is busy" is not ours to hand out.
*/
export async function GET(_req: NextRequest) {
const user = await currentUser();
const facilityId = user?.facilityId || (await currentStaff())?.facilityId;
if (!facilityId) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const f = await prisma.facility.findUnique({ where: { id: facilityId }, select: { rev: true } });
if (!f) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
// Never from a cache: a stale revision is indistinguishable from nothing having happened, which
// is the one wrong answer this endpoint can give.
return NextResponse.json({ rev: f.rev }, { headers: { "cache-control": "no-store" } });
}
+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 };
}
+132
View File
@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { normaliseCode, setStaffCookie } from "@/lib/staffsession";
import { verifyTurnstile } from "@/lib/turnstile";
import { recordAuthEvent } from "@/lib/audit";
import { bumpRev } from "@/lib/ops";
import { SLIP_DAYS, facilityToday, slipLive } from "@/lib/compute";
export const dynamic = "force-dynamic";
const MIN_PW = 8;
/* Claiming your own record with the code the linen room printed for you.
*
* The code alone identifies the person, because it is presented before we know anything about them
* — there is no facility to scope it to and no email to look up yet. That is why it is globally
* unique, why it is 58 bits wide, and why this route is throttled to the point where working
* through the space is not a strategy.
*
* It is spent in the same update that finds it, so two people racing the same slip can't both
* claim the record; the loser gets the ordinary "code isn't right" message.
*
* It also goes stale on its own after fourteen days, because the far more likely way a slip is
* misused is not a guessed code but a printed one nobody ever collected.
*/
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("staff-activate:" + ip, 200, 15 * 60 * 1000)) {
return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
}
let body: { code?: unknown; email?: unknown; password?: unknown; cfToken?: unknown; agreed?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const code = normaliseCode(String(body.code ?? ""));
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160);
const password = String(body.password ?? "").slice(0, 200);
if (!code) return NextResponse.json({ error: "That code isn't right. It's twelve characters, in three groups." }, { status: 400 });
if (!/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) return NextResponse.json({ error: "Enter an email address you can get to." }, { status: 400 });
if (password.length < MIN_PW) return NextResponse.json({ error: `Use at least ${MIN_PW} characters for your password.` }, { status: 400 });
// The agreement is collected where the account is created. The screen's tick is what sets it,
// and the door checks it too so a client that skips the box gets the same answer.
if (body.agreed !== true) return NextResponse.json({ error: "Tick the box to agree to the terms of use and privacy policy." }, { status: 400 });
// Checked before the code is looked up, so a bot working through the code space is stopped by
// Cloudflare rather than by the per-IP throttle alone.
const cfErr = await verifyTurnstile(body.cfToken, ip);
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
const staff = await prisma.staff.findUnique({
where: { activateCode: code },
select: { id: true, facilityId: true, first: true, last: true, inactive: true, activateCodeAt: true, account: { select: { id: true } }, facility: { select: { timezone: true } } },
});
// One message for every way this can fail, so the response can't be used to tell a real code from
// a spent one.
const nope = () => NextResponse.json({ error: "That code isn't right, or it has already been used. Ask the linen room for a new one." }, { status: 400 });
if (!staff || staff.inactive || staff.account) return nope();
/* An old slip is refused whether or not anyone ever claimed it. It is a bearer token on paper:
* whoever picks one out of a folder months later can bind their own email and password to this
* person's record and from then on be them — their issues, their requests, their signature on the
* ward round, and their approvals queue if they manage anyone. Fourteen days is long enough for
* someone on leave to come back to it and short enough that a forgotten one is dead by the time
* it turns up.
*
* An unstamped code counts as stale: its age is unknown, so it has to be assumed old. This leans
* on staff.selfCode in lib/ops.ts stamping activateCodeAt as it prints — if that stamp ever stops
* being written, every new slip is dead on arrival.
*
* This says plainly that the slip has expired rather than joining the deliberately vague message
* above. Landing here means the code was right, and a code is 58 bits behind a throttle and a
* Turnstile — so anyone who gets this far is holding a real slip and needs to be told that a
* reprint, not a retype, is the fix.
*
* The age is asked of slipLive() in lib/compute, the same test the staff register and the requests
* queue use to say whether a slip is still worth chasing, counted in whole days on the facility's
* own calendar. The day a coordinator's screen calls a slip expired is therefore the day this
* refuses it — never a few hours later, with a nurse who was told it was dead finding it still
* works, or one who was told it was fine being turned away. */
const tz = staff.facility.timezone;
if (!slipLive(staff.activateCodeAt, facilityToday(tz), tz)) {
return NextResponse.json(
{ error: `That code was printed ${SLIP_DAYS} or more days ago, so it has expired. Ask the linen room to print you a new slip.` },
{ status: 400 },
);
}
// The email has to be free across staff accounts. Coordinator accounts live in a different table
// and a person may legitimately be both — a linen-room supervisor who also wears the uniform.
const taken = await prisma.staffAccount.findUnique({ where: { email }, select: { id: true } });
if (taken) return NextResponse.json({ error: "That email is already on an account here. Sign in instead." }, { status: 400 });
const passwordHash = await bcrypt.hash(password, 12);
// Spend the code first, conditionally. If it has gone in the meantime, nothing was created.
// The stamp goes with the code, so a spent row can't be read as a slip still waiting out there.
const spent = await prisma.staff.updateMany({ where: { id: staff.id, activateCode: code }, data: { activateCode: null, activateCodeAt: null } });
if (spent.count !== 1) return nope();
let account;
try {
account = await prisma.staffAccount.create({
data: { facilityId: staff.facilityId, staffId: staff.id, email, passwordHash },
select: { id: true, passwordHash: true },
});
} catch {
// The code is gone but the account didn't happen — put the code back rather than stranding
// someone with a dead slip. The original print date goes back with it: a failed attempt is not
// a reprint and must not restart the fourteen days.
await prisma.staff.update({ where: { id: staff.id }, data: { activateCode: code, activateCodeAt: staff.activateCodeAt } }).catch(() => {});
return NextResponse.json({ error: "That didn't work — try again." }, { status: 500 });
}
await setStaffCookie(account.id, account.passwordHash);
recordAuthEvent(
{ facilityId: staff.facilityId, userId: staff.id, userName: `${staff.first} ${staff.last}`.trim() || email },
"staff:activate", ip,
);
// The fourth door that changes a facility's data, and the only one outside the three mutate
// routes. Without this the coordinator standing over the nurse while she activates keeps seeing
// "code outstanding" until some unrelated edit moves the revision, and reissues a code that
// can't be reissued.
await bumpRev(staff.facilityId);
return NextResponse.json({ ok: true, name: `${staff.first} ${staff.last}` });
}
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { readApprovalToken } from "@/lib/approvallink";
import { StaffOpError, decideRequest } from "@/lib/staffops";
import { recordFor } from "@/lib/audit";
import { bumpRev } from "@/lib/ops";
export const dynamic = "force-dynamic";
/* Deciding a request from the emailed link, without signing in.
*
* This is a POST and only a POST. The link in the email is a GET that renders /my/approve, and the
* decision is made from that page — because corporate mail scanners and link-preview crawlers
* fetch every URL in every message, and a GET that approved a uniform request would be approved by
* the mail gateway before the manager ever saw it.
*
* The decision itself is decideRequest()'s, not this route's. A request now carries a line per
* garment, and settling it means settling every line and then rolling the request up from them;
* an approval made here that moved only the request would leave every line `awaiting`, so the
* linen room's bag would come out empty and the wearer's order would show no decision at all.
* There is no room on this page for a garment-by-garment answer — there is no signed-in person to
* check one against — so it takes the whole-request shorthand, `approveAll`, which is the reason
* that argument exists.
*
* Single use falls out of the state machine rather than a table of spent tokens: decideRequest's
* update is conditional on the request still being `awaiting`, so the approve link and the decline
* link in the same email both stop working the moment either is used.
*/
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("staff-decide:" + ip, 200, 15 * 60 * 1000)) {
return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 });
}
let body: { token?: unknown; action?: unknown; reason?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
const claim = readApprovalToken(String(body.token ?? ""));
if (!claim) return NextResponse.json({ error: "That link has expired. Open the app and use the approvals queue." }, { status: 400 });
const action = String(body.action ?? "");
if (action !== "approve" && action !== "decline") return NextResponse.json({ error: "Unknown action" }, { status: 400 });
let done: Awaited<ReturnType<typeof decideRequest>>;
try {
done = await decideRequest({
requestId: claim.rid,
managerId: claim.mid,
approveAll: action === "approve",
reason: body.reason,
});
} catch (e) {
if (!(e instanceof StaffOpError)) throw e;
/* The refusals are worded for somebody standing in their mail client, not in the app.
*
* A 403 here is decideRequest re-reading the register and finding the manager off it, or the
* wearer off it — the check that makes a fortnight-old token in a mailbox that has since been
* closed or handed on safe. Neither the sacked manager nor a stranger reading their mail is
* told which of the two it was; "ask the linen room" is where that conversation belongs.
*
* A 409 is the link already spent, and keeps the `already` flag the page reads to show the
* decision that was made rather than an error. */
if (e.status === 403) return NextResponse.json({ error: "That link is no longer valid — ask the linen room." }, { status: 403 });
if (e.status === 404) return NextResponse.json({ error: "That request is no longer there." }, { status: 404 });
if (e.status === 409) return NextResponse.json({ error: "That request has already been decided.", already: true }, { status: 409 });
return NextResponse.json({ error: e.message }, { status: e.status });
}
// Filed under the manager's own Staff id, exactly as the in-app approval is, so the log names
// the same person either way; the op says which door the decision came through, because "an
// email link, from an address we can't see" is part of the answer to who authorised this.
recordFor(
{ facilityId: done.facilityId, userId: claim.mid, userName: done.managerName },
done.status === "accepted" ? "staff:request.approve.email" : "staff:request.decline.email",
{ id: claim.rid }, ip,
);
// The third door into the facility's data, so the third place the revision has to move: a manager
// approving from their mail is exactly the change the linen room's screen is waiting to see.
await bumpRev(done.facilityId);
return NextResponse.json({ ok: true, status: done.status, notified: done.notified });
}
+45
View File
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { sameOriginJson } from "@/lib/csrf";
import { clientIp } from "@/lib/ratelimit";
import { signInStaff, staffThrottled } from "@/lib/staffauth";
import { verifyTurnstile } from "@/lib/turnstile";
export const dynamic = "force-dynamic";
/* The staff app's own door: the printed slip, the Play app's welcome, and /my/signin.
*
* The Log in box on the website reaches the same register through lib/staffauth.ts, so what counts
* as a match, what a deactivated record is told, and what lands in the audit trail are decided in
* one place for both. This route is the HTTP shape of it: the origin check, the security check, and
* the throttle asked in that order. */
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
let body: { email?: unknown; password?: 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);
const password = String(body.password ?? "").slice(0, 200);
const ip = clientIp(req.headers);
// Asked before the security check, because a Turnstile token is good for one use and somebody who
// is already throttled should not spend theirs to be told so.
if (staffThrottled(email, ip)) {
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 });
// The same bot check the coordinator door has. A ward account opens one person's uniform record,
// and a manager's opens the approvals queue, so leaving this to the in-memory throttles alone
// meant a list of hospital addresses and enough patience was the whole attack.
const cfErr = await verifyTurnstile(body.cfToken, ip);
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
// `true`: this is the register's own door, so an address with no account here is a plain wrong
// answer and is counted as one.
const r = await signInStaff(email, password, ip, true);
if (r.kind === "ok") return NextResponse.json({ ok: true, name: r.name });
if (r.kind === "error") return NextResponse.json({ error: r.error }, { status: r.status });
// Unreachable at this door: `none` is only returned when the caller asked not to be counted.
return NextResponse.json({ error: "Email or password doesnt match." }, { status: 401 });
}
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { sameOriginJson } from "@/lib/csrf";
import { clearStaffCookie, currentStaff } from "@/lib/staffsession";
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);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
// Read the session before dropping it, so the trail can say who left. Signing out still succeeds
// when there was nothing to sign out of.
const sess = await currentStaff();
await clearStaffCookie();
if (sess) {
recordAuthEvent(
{ facilityId: sess.facilityId, userId: sess.staffId, userName: `${sess.first} ${sess.last}`.trim() || sess.email },
"staff:signout", clientIp(req.headers),
);
}
return NextResponse.json({ ok: true });
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { currentStaff } from "@/lib/staffsession";
import { StaffOpError, runStaffOp } from "@/lib/staffops";
import { sameOriginJson } from "@/lib/csrf";
import { allow, clientIp } from "@/lib/ratelimit";
import { recordStaffAudit } from "@/lib/audit";
import { bumpRev } from "@/lib/ops";
import { report } from "@/lib/glitchtip";
export const dynamic = "force-dynamic";
/* The one door for everything a wearer, manager or ward clerk changes.
*
* Separate from /api/mutate, and reached only with a staff session. The two never share a handler:
* a single endpoint that accepted either kind of caller would put the whole coordinator op table
* one authorisation slip away from a wearer's phone.
*/
export async function POST(req: NextRequest) {
const sess = await currentStaff();
if (!sess) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
let body: { op?: string; payload?: unknown };
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad JSON" }, { status: 400 }); }
const op = String(body.op || "");
if (!allow("staff-mutate:" + sess.accountId, 120, 60 * 1000)) {
return NextResponse.json({ error: "Slow down — too many changes in a minute." }, { status: 429 });
}
// Requests are the expensive ones: each sends an email to a manager. A tighter budget stops a
// stuck retry loop turning into a mailbox full of the same approval.
// damage.report and waitlist.accept raise a request (and mail the manager) through the same
// path, so they draw on the same budget — otherwise the loop just picks a different door.
if (["request.create", "damage.report", "waitlist.accept"].includes(op) && !allow("staff-request:" + sess.staffId, 12, 60 * 60 * 1000)) {
return NextResponse.json({ error: "That's a lot of requests in an hour — talk to the linen room." }, { status: 429 });
}
const ip = clientIp(req.headers);
try {
const payload = (body.payload || {}) as Record<string, unknown>;
const result = await runStaffOp(sess, op, payload);
// The same discipline as /api/mutate, and for the same reason: a uniform issued to a ward is
// authorised here as often as it is in the linen room, and "who approved this" is the question
// the trail exists to answer. Recorded only after the op actually succeeded.
recordStaffAudit(sess, op, payload, ip, result);
// Handed back so the screen that made this change does not bounce again when it next polls.
const rev = await bumpRev(sess.facilityId);
return NextResponse.json({ ok: true, result, rev });
} catch (e) {
if (e instanceof StaffOpError) return NextResponse.json({ error: e.message }, { status: e.status });
report({ error: e, where: "server", url: "/api/staff/mutate", tags: { op } });
console.error(`[staff mutate ${op}]`, e, "ip=", ip);
return NextResponse.json({ error: "Something went wrong — nothing was saved." }, { status: 500 });
}
}
+72
View File
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from "next/server";
import { allow, clientIp } from "@/lib/ratelimit";
import { sameOriginJson } from "@/lib/csrf";
import { verifyTurnstile } from "@/lib/turnstile";
export const dynamic = "force-dynamic";
/* Newsletter sign-up.
*
* ThreadCount keeps two promises that shape this endpoint. The contact form says, at the point of
* collection, "No mailing list, no follow-up sequence" — so nothing that arrives through the
* contact form ever reaches this list, and the two paths share no code and no storage. And the
* list is double opt-in: this handler only ever creates an *unconfirmed* subscriber, and Listmonk
* emails a confirmation link that the person has to click before they can be sent anything.
*
* It posts to ThreadCount's own Listmonk (lists.threadcount.tech), which is a separate instance
* from ClearAudit's: Listmonk has a single global from-address, so sharing one would have sent
* ThreadCount's confirmation emails from ClearAudit and failed SPF/DKIM alignment for this domain.
*
* The list uuid is not a secret — it is designed to sit in a public subscription form — so it is
* committed rather than left to an env var that a build could forget. */
const LIST_UUID = process.env.LISTMONK_LIST_UUID || "734e9011-5fd5-48a7-b0ae-4ea0e1deb972";
const LISTMONK = process.env.LISTMONK_URL || "https://lists.threadcount.tech";
const str = (v: unknown, max: number) => String(v ?? "").trim().slice(0, max);
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("subscribe:" + ip, 5, 60 * 60 * 1000) || !allow("subscribe-day:" + ip, 20, 24 * 60 * 60 * 1000)) {
return NextResponse.json({ error: "That's a few attempts in a short time. Try again later." }, { status: 429 });
}
let b: Record<string, unknown>;
try { b = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
// Honeypot, same as the contact form: a real person never fills this in.
if (str(b.company, 100)) return NextResponse.json({ ok: true });
const email = str(b.email, 160).toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return NextResponse.json({ error: "That email address doesn't look right." }, { status: 400 });
}
const cfErr = await verifyTurnstile(b.cfToken, ip);
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
// Listmonk's public subscription handler. It creates the subscriber as unconfirmed and sends the
// opt-in email itself, which is why this endpoint never needs an admin token.
const form = new URLSearchParams({ email, name: "", l: LIST_UUID });
try {
const r = await fetch(`${LISTMONK}/subscription/form`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: form.toString(),
redirect: "manual", // success is a 302 back to a thank-you page
signal: AbortSignal.timeout(8000),
});
if (r.status >= 500) {
return NextResponse.json({ error: "Sign-up is unavailable for a moment — please try again shortly." }, { status: 502 });
}
} catch {
return NextResponse.json({ error: "Sign-up is unavailable for a moment — please try again shortly." }, { status: 502 });
}
// Deliberately the same answer whether or not the address was already on the list: otherwise
// this endpoint would confirm to a stranger who is subscribed.
return NextResponse.json({ ok: true });
}