ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from e2d6d42 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -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 doesn’t 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 can’t 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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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() });
|
||||
}
|
||||
Reference in New Issue
Block a user