7d650e4c10
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from d947f89 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
81 lines
4.4 KiB
TypeScript
81 lines
4.4 KiB
TypeScript
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, html } = resetEmail(user.first, resetUrl(token));
|
|
const sent = await sendTo(email, subject, text, html);
|
|
// 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() });
|
|
}
|