"use server"; import { revalidatePath } from "next/cache"; import { getAdminPb } from "@/lib/pb/admin"; import { requireSession } from "@/lib/auth/session"; import { generateInitialPassword } from "@/lib/auth/initial-password"; import type { UserRecord } from "@/lib/pb/types"; /** * Account management. * * These are the only actions in the product that use a superuser connection * for their main work, and the reason is deliberate: `users.createRule` and * `users.updateRule` are null — superuser-only — because `role` is an ordinary * field on that collection. Any rule permitting a request to write its own * user record permits it to write role "owner", and an owner reads every * client's board. Both the signup and the self-promotion routes were live and * verified exploitable before they were closed. * * So: do NOT relax the users rules to let these run on the caller's token. * tests/integration/user-rules.test.ts fails if you do, and that test is what * is actually protecting the product. * * Because the superuser bypasses every rule, the owner check below is the * ONLY thing standing between a caller and account creation. A Server Action * is a public HTTP endpoint; not rendering the form protects nothing. */ async function requireOwner() { const session = await requireSession(); if (session.role !== "owner") throw new Error("Not found"); return session; } /** * Issue a client-manager account. * * Returns the generated password ONCE. It is not stored anywhere in readable * form and cannot be retrieved again — the caller must show it to the owner * immediately. If it is lost, issue a new one rather than trying to recover it. */ export async function createClientManager(input: { name: string; email: string; clientId: string; }): Promise<{ email: string; password: string }> { await requireOwner(); const name = input.name.trim().slice(0, 120); const email = input.email.trim().toLowerCase(); if (!name) throw new Error("A name is required"); if (!email.includes("@")) throw new Error("A valid email is required"); if (!input.clientId) throw new Error("A client is required"); const pb = await getAdminPb(); const clash = await pb .collection("users") .getFirstListItem(`email = "${email}"`) .catch(() => null); if (clash) throw new Error("An account with that email already exists"); const password = generateInitialPassword(); await pb.collection("users").create({ email, password, passwordConfirm: password, name, role: "client_manager", client: input.clientId, active: true, verified: true, }); revalidatePath("/admin/users"); return { email, password }; } export async function setUserActive(input: { userId: string; active: boolean; }): Promise { const session = await requireOwner(); // Locking yourself out of your own product is not a recoverable mistake // through this UI — it would need the PocketBase admin console. if (input.userId === session.userId) { throw new Error("You cannot deactivate your own account"); } const pb = await getAdminPb(); const user = await pb.collection("users").getOne(input.userId); if (user.role === "owner") { throw new Error("Owner accounts cannot be deactivated here"); } await pb.collection("users").update(input.userId, { active: input.active }); revalidatePath("/admin/users"); } /** Issue a fresh one-time password. Returns it once, as with creation. */ export async function resetUserPassword(input: { userId: string; }): Promise<{ password: string }> { await requireOwner(); const pb = await getAdminPb(); const user = await pb.collection("users").getOne(input.userId); if (user.role === "owner") { throw new Error("Owner passwords cannot be reset here"); } const password = generateInitialPassword(); await pb.collection("users").update(input.userId, { password, passwordConfirm: password, }); revalidatePath("/admin/users"); return { password }; }