From 101112f5663baceabec56e0c58bb9d3496a5f97f Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 11:27:25 +0200 Subject: [PATCH] feat(actions): issue and deactivate client-manager accounts --- src/app/actions/users.ts | 126 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/app/actions/users.ts diff --git a/src/app/actions/users.ts b/src/app/actions/users.ts new file mode 100644 index 0000000..3f09e15 --- /dev/null +++ b/src/app/actions/users.ts @@ -0,0 +1,126 @@ +"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 }; +} \ No newline at end of file