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>
63 lines
3.3 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|