feat(actions): issue and deactivate client-manager accounts
This commit is contained in:
parent
294d2aa11e
commit
101112f566
1 changed files with 126 additions and 0 deletions
126
src/app/actions/users.ts
Normal file
126
src/app/actions/users.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue