Organize/lib/actions/admin.ts

111 lines
3.8 KiB
TypeScript

"use server";
import bcrypt from "bcryptjs";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db";
import { requireAdmin } from "@/lib/auth-helpers";
import { Role, SignupMode } from "@/lib/generated/prisma/enums";
import { EmailSchema, PasswordSchema } from "@/lib/validation/auth";
import { setSignupMode } from "@/lib/settings";
const ROLE_VALUES: readonly string[] = Object.values(Role);
// CONFIRMED is a listed option (greyed out in the UI) but email
// confirmation isn't built yet -- reject it here too, not just in the UI,
// so it can't be turned on until that's actually implemented.
const SETTABLE_SIGNUP_MODES: readonly string[] = [
SignupMode.OPEN,
SignupMode.APPROVED,
SignupMode.CLOSED,
];
// All of these are modeled as return values rather than thrown errors --
// Next.js redacts thrown Server Action error messages in production, and
// these are all expected, user-facing validation outcomes (bad input,
// "last admin" guard, etc.), not exceptional failures.
export async function updateUserRole(
userId: string,
role: string
): Promise<{ error?: string }> {
const admin = await requireAdmin();
if (!ROLE_VALUES.includes(role)) return { error: "Invalid role." };
// The acting admin is always at least one admin; the only way this
// action could zero out admins is by demoting *themselves* while
// they're the last one.
if (userId === admin.id && role !== Role.ADMIN) {
const adminCount = await prisma.user.count({ where: { role: Role.ADMIN } });
if (adminCount <= 1) {
return { error: "You can't remove the last administrator." };
}
}
const target = await prisma.user.findUnique({ where: { id: userId }, select: { id: true } });
if (!target) return { error: "User not found." };
await prisma.user.update({ where: { id: userId }, data: { role: role as Role } });
revalidatePath("/admin");
return {};
}
export async function updateUserEmail(
userId: string,
email: string
): Promise<{ error?: string }> {
await requireAdmin();
const parsed = EmailSchema.safeParse(email);
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Invalid email." };
const existing = await prisma.user.findUnique({ where: { email: parsed.data } });
if (existing && existing.id !== userId) {
return { error: "Another account already uses that email." };
}
await prisma.user.update({ where: { id: userId }, data: { email: parsed.data } });
revalidatePath("/admin");
return {};
}
export async function updateUserPassword(
userId: string,
password: string
): Promise<{ error?: string }> {
await requireAdmin();
const parsed = PasswordSchema.safeParse(password);
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Invalid password." };
const passwordHash = await bcrypt.hash(parsed.data, 12);
await prisma.user.update({ where: { id: userId }, data: { passwordHash } });
return {};
}
export async function updateSignupMode(mode: string): Promise<{ error?: string }> {
await requireAdmin();
if (!SETTABLE_SIGNUP_MODES.includes(mode)) {
return { error: "That sign-up option isn't available yet." };
}
await setSignupMode(mode as SignupMode);
revalidatePath("/admin");
return {};
}
/**
* Deletes a user's account entirely, cascading through their categories,
* groups, and to-dos (`onDelete: Cascade` all the way down the schema).
*/
export async function deleteUser(userId: string): Promise<{ error?: string }> {
const admin = await requireAdmin();
if (userId === admin.id) {
return { error: "You can't delete your own account." };
}
const target = await prisma.user.findUnique({ where: { id: userId }, select: { id: true } });
if (!target) return { error: "User not found." };
await prisma.user.delete({ where: { id: userId } });
revalidatePath("/admin");
return {};
}