126 lines
No EOL
4 KiB
TypeScript
126 lines
No EOL
4 KiB
TypeScript
"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 };
|
|
} |