MEGAINFRA-WEBAPP/src/app/api/admin/users/route.ts
DEls 8acc27466e feat(users): welcome and password-reset emails
Admins can email a new user a welcome with a one-time link to choose
their password, and send a reset link from the users list. The sign-in
page gets "Forgot password?". Mail goes out through Resend; the emails
show the person's sign-in address.

- password_links collection (migration applied): only the SHA-256 of each
  token is stored; invites last 7 days, resets 1 hour, one use each
- /set-password and /forgot-password pages and their /api/auth routes
- forgot-password never reveals whether an account exists, throttles
  repeat mails, and builds links only from APP_URL
- env: RESEND_API_KEY, EMAIL_FROM, EMAIL_REPLY_TO, APP_URL (docs/setup/email.md)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:39:38 +02:00

63 lines
3.3 KiB
TypeScript

import { canManageUsers, loadManagedUsers } from "@/lib/auth/user-management";
import { requireApiUser } from "@/lib/auth/guard";
import { parseNewUser } from "@/lib/auth/user-input";
import { pbAdmin } from "@/lib/pb";
import { authMode } from "@/lib/auth/session";
import { sendUserEmail } from "@/lib/auth/user-emails";
import { appUrl } from "@/lib/email/send";
export const dynamic = "force-dynamic";
export async function GET(): Promise<Response> {
if (authMode() !== "users") return Response.json({ error: "Not found" }, { status: 404 });
const actor = await requireApiUser("dashboard");
if (actor instanceof Response) return actor;
if (!canManageUsers(actor)) return Response.json({ error: "Not allowed" }, { status: 403 });
const pb = await pbAdmin();
const { users, sites } = await loadManagedUsers(pb, actor);
return Response.json({ users, sites });
}
export async function POST(request: Request): Promise<Response> {
if (authMode() !== "users") return Response.json({ error: "Not found" }, { status: 404 });
const actor = await requireApiUser("dashboard");
if (actor instanceof Response) return actor;
if (!canManageUsers(actor)) return Response.json({ error: "Not allowed" }, { status: 403 });
let body: unknown;
try { body = await request.json(); }
catch { return Response.json({ error: "Invalid user details" }, { status: 400 }); }
if (actor.role === "site_user" && (!body || typeof body !== "object" || (body as Record<string, unknown>).role !== "clock_only")) {
return Response.json({ error: "Site managers can only add clock users" }, { status: 403 });
}
const pb = await pbAdmin();
const sites = await pb.collection("sites").getFullList({ fields: "id" });
const payload = parseNewUser(body, new Set(sites.filter((site) => actor.role === "admin" || actor.siteIds.includes(site.id)).map((site) => site.id)));
if (!payload) return Response.json({ error: "Enter a valid email, name, role, password, and site selection" }, { status: 400 });
try {
const created = await pb.collection("users").create(payload);
// The invite is a courtesy: a user who never receives the mail still exists.
const wantsInvite = (body as Record<string, unknown>).send_invite === true;
let emailed = false;
let emailError: string | undefined;
if (wantsInvite) {
try {
await sendUserEmail(pb, {
id: created.id, email: payload.email, name: payload.name, role: payload.role, is_active: true,
}, "welcome", appUrl(request));
emailed = true;
} catch (error) {
emailError = error instanceof Error ? error.message : "Could not send the welcome email";
}
}
return Response.json({ user: {
id: created.id, email: payload.email, name: payload.name, role: payload.role,
sites: payload.sites, is_active: true, must_change_password: true,
modules: payload.modules, all_sites: payload.all_sites,
}, emailed, ...(emailError ? { emailError } : {}) }, { status: 201 });
} catch (error) {
const code = (error as { response?: { data?: { email?: { code?: string } } } })?.response?.data?.email?.code;
return Response.json({ error: code === "validation_not_unique" ? "This email is already in use" : "Could not create user" }, {
status: code === "validation_not_unique" ? 409 : 500,
});
}
}