"use server"; import { eq } from "drizzle-orm"; import bcrypt from "bcryptjs"; import { revalidatePath } from "next/cache"; import { headers } from "next/headers"; import { operators } from "@cmbot/db"; import { db } from "@/lib/db"; import { requireAdmin } from "@/lib/auth"; import { checkRateLimit } from "@/lib/rate-limit"; import { validatePassword } from "@/lib/password-policy"; const MAX_FIELD_LEN = 256; async function rateLimit(key: string): Promise<{ limited: boolean }> { const h = await headers(); const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; return checkRateLimit(`${key}:${ip}`, { max: 5, windowSec: 60 }); } export type CreateUserResult = | { ok: true; userId: string } | { ok: false; error: string }; export async function createUserAction(input: { username: string; password: string; role: "admin" | "user"; }): Promise { await requireAdmin(); const rl = await rateLimit("create-user"); if (rl.limited) return { ok: false, error: "Too many attempts. Try again later." }; const u = input.username.trim(); if (u.length < 3 || u.length > MAX_FIELD_LEN) { return { ok: false, error: "Username must be 3..256 chars." }; } const pwCheck = validatePassword(input.password); if (!pwCheck.ok) return pwCheck; if (input.role !== "admin" && input.role !== "user") { return { ok: false, error: "Role must be admin or user." }; } const hash = await bcrypt.hash(input.password, 12); const [row] = await db .insert(operators) .values({ username: u, passwordHash: hash, displayName: u, role: input.role, defaultTimezone: "Asia/Kuala_Lumpur", }) .returning({ id: operators.id }); revalidatePath("/settings/users"); return { ok: true, userId: row!.id }; } export type SetRoleResult = { ok: true } | { ok: false; error: string }; export async function setUserRoleAction(input: { userId: string; role: "admin" | "user"; }): Promise { const me = await requireAdmin(); if (input.userId === me.id && input.role !== "admin") { return { ok: false, error: "You can't demote your own account." }; } const target = await db.query.operators.findFirst({ where: (o, { eq: dEq }) => dEq(o.id, input.userId), }); if (!target) return { ok: false, error: "User not found." }; // If we're demoting an admin, make sure at least one admin remains. if (target.role === "admin" && input.role !== "admin") { const admins = await db.query.operators.findMany({ where: (o, { eq: dEq }) => dEq(o.role, "admin"), }); if (admins.length <= 1) { return { ok: false, error: "Can't demote the last admin. Promote another user first." }; } } await db .update(operators) .set({ role: input.role }) .where(eq(operators.id, input.userId)); revalidatePath("/settings/users"); return { ok: true }; } export type DeleteUserResult = { ok: true } | { ok: false; error: string }; export async function deleteUserAction(input: { userId: string; }): Promise { const me = await requireAdmin(); if (input.userId === me.id) { return { ok: false, error: "You can't delete your own account." }; } const target = await db.query.operators.findFirst({ where: (o, { eq: dEq }) => dEq(o.id, input.userId), }); if (!target) return { ok: false, error: "User not found." }; if (target.role === "admin") { const admins = await db.query.operators.findMany({ where: (o, { eq: dEq }) => dEq(o.role, "admin"), }); if (admins.length <= 1) { return { ok: false, error: "Can't delete the last admin. Promote another user first." }; } } await db.delete(operators).where(eq(operators.id, input.userId)); revalidatePath("/settings/users"); return { ok: true }; } export type ResetPasswordResult = { ok: true } | { ok: false; error: string }; export async function resetUserPasswordAction(input: { userId: string; newPassword: string; }): Promise { await requireAdmin(); const rl = await rateLimit("reset-password"); if (rl.limited) return { ok: false, error: "Too many attempts. Try again later." }; const pwCheck = validatePassword(input.newPassword); if (!pwCheck.ok) return pwCheck; const target = await db.query.operators.findFirst({ where: (o, { eq: dEq }) => dEq(o.id, input.userId), }); if (!target) return { ok: false, error: "User not found." }; const hash = await bcrypt.hash(input.newPassword, 12); await db .update(operators) .set({ passwordHash: hash }) .where(eq(operators.id, input.userId)); revalidatePath("/settings/users"); return { ok: true }; }