feat(actions): issue and deactivate client-manager accounts

This commit is contained in:
Dave 2026-09-16 11:27:25 +02:00
parent 294d2aa11e
commit 101112f566

126
src/app/actions/users.ts Normal file
View file

@ -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<void> {
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<UserRecord>(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<UserRecord>(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 };
}