diff --git a/apps/web/src/actions/users.test.ts b/apps/web/src/actions/users.test.ts new file mode 100644 index 0000000..a493efc --- /dev/null +++ b/apps/web/src/actions/users.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { + requireAdminMock, + findUserMock, + findManyAdminsMock, + insertReturningMock, + updateMock, + deleteMock, + checkRateLimitMock, + revalidateMock, +} = vi.hoisted(() => ({ + requireAdminMock: vi.fn(), + findUserMock: vi.fn(), + findManyAdminsMock: vi.fn(), + insertReturningMock: vi.fn(), + updateMock: vi.fn(), + deleteMock: vi.fn(), + checkRateLimitMock: vi.fn(), + revalidateMock: vi.fn(), +})); + +vi.mock("@/lib/auth", async () => { + const actual = await vi.importActual("@/lib/auth"); + return { + ...actual, + requireAdmin: () => requireAdminMock(), + }; +}); +vi.mock("@/lib/db", () => ({ + db: { + query: { + operators: { + findFirst: (...a: unknown[]) => findUserMock(...a), + findMany: (...a: unknown[]) => findManyAdminsMock(...a), + }, + }, + insert: () => ({ values: () => ({ returning: async () => insertReturningMock() }) }), + update: () => ({ set: () => ({ where: async (...a: unknown[]) => updateMock(...a) }) }), + delete: () => ({ where: async (...a: unknown[]) => deleteMock(...a) }), + }, +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: (...a: unknown[]) => checkRateLimitMock(...a), +})); +vi.mock("next/cache", () => ({ revalidatePath: revalidateMock })); +vi.mock("next/headers", () => ({ + headers: async () => ({ get: () => "127.0.0.1" }), +})); + +beforeEach(() => { + requireAdminMock.mockReset(); + findUserMock.mockReset(); + findManyAdminsMock.mockReset(); + insertReturningMock.mockReset(); + updateMock.mockReset(); + deleteMock.mockReset(); + checkRateLimitMock.mockReset(); + revalidateMock.mockReset(); + checkRateLimitMock.mockResolvedValue({ limited: false, count: 1 }); +}); + +const ADMIN = { + id: "11111111-1111-1111-1111-111111111111", + username: "admin", + role: "admin" as const, +}; +const OTHER_ADMIN = { ...ADMIN, id: "22222222-2222-2222-2222-222222222222", username: "alice" }; +const USER = { ...ADMIN, id: "33333333-3333-3333-3333-333333333333", username: "bob", role: "user" as const }; + +import { + createUserAction, + setUserRoleAction, + resetUserPasswordAction, + deleteUserAction, +} from "./users"; + +describe("createUserAction", () => { + it("admin can create a user with role 'user'", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + insertReturningMock.mockResolvedValue([{ id: USER.id }]); + const r = await createUserAction({ + username: "bob", + password: "longenoughpw", + role: "user", + }); + expect(r).toEqual({ ok: true, userId: USER.id }); + }); + + it("rejects username/password under length limits", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + const r = await createUserAction({ username: "a", password: "shortpw", role: "user" }); + expect(r.ok).toBe(false); + }); +}); + +describe("setUserRoleAction — self-demote guard", () => { + it("admin demoting themselves is rejected", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + findUserMock.mockResolvedValue(ADMIN); + const r = await setUserRoleAction({ userId: ADMIN.id, role: "user" }); + expect(r).toEqual({ + ok: false, + error: "You can't demote your own account.", + }); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it("admin demoting another admin is allowed when others remain", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + findUserMock.mockResolvedValue(OTHER_ADMIN); + findManyAdminsMock.mockResolvedValue([ADMIN, OTHER_ADMIN]); // 2 admins + const r = await setUserRoleAction({ userId: OTHER_ADMIN.id, role: "user" }); + expect(r).toEqual({ ok: true }); + }); + + it("admin demoting the last remaining admin is rejected", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + findUserMock.mockResolvedValue(OTHER_ADMIN); + findManyAdminsMock.mockResolvedValue([OTHER_ADMIN]); // only OTHER_ADMIN is an admin + const r = await setUserRoleAction({ userId: OTHER_ADMIN.id, role: "user" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/last admin/i); + }); +}); + +describe("deleteUserAction", () => { + it("admin deleting themselves is rejected", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + findUserMock.mockResolvedValue(ADMIN); + const r = await deleteUserAction({ userId: ADMIN.id }); + expect(r).toEqual({ ok: false, error: "You can't delete your own account." }); + expect(deleteMock).not.toHaveBeenCalled(); + }); + + it("admin deleting another user is allowed", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + findUserMock.mockResolvedValue(USER); + findManyAdminsMock.mockResolvedValue([ADMIN, OTHER_ADMIN]); + const r = await deleteUserAction({ userId: USER.id }); + expect(r).toEqual({ ok: true }); + }); + + it("admin deleting the last admin is rejected", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + findUserMock.mockResolvedValue(OTHER_ADMIN); + findManyAdminsMock.mockResolvedValue([OTHER_ADMIN]); // 1 admin total + const r = await deleteUserAction({ userId: OTHER_ADMIN.id }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/last admin/i); + }); +}); + +describe("resetUserPasswordAction", () => { + it("admin can reset another user's password", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + findUserMock.mockResolvedValue(USER); + const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "longenoughpw" }); + expect(r).toEqual({ ok: true }); + expect(updateMock).toHaveBeenCalled(); + }); + + it("rejects too-short passwords", async () => { + requireAdminMock.mockResolvedValue(ADMIN); + findUserMock.mockResolvedValue(USER); + const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "short" }); + expect(r.ok).toBe(false); + }); +}); diff --git a/apps/web/src/actions/users.ts b/apps/web/src/actions/users.ts new file mode 100644 index 0000000..1498ca0 --- /dev/null +++ b/apps/web/src/actions/users.ts @@ -0,0 +1,145 @@ +"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"; + +const MIN_PASSWORD_LEN = 10; +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." }; + } + if (!input.password || input.password.length < MIN_PASSWORD_LEN || input.password.length > MAX_FIELD_LEN) { + return { ok: false, error: `Password must be at least ${MIN_PASSWORD_LEN} chars.` }; + } + 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." }; + if ( + !input.newPassword || + input.newPassword.length < MIN_PASSWORD_LEN || + input.newPassword.length > MAX_FIELD_LEN + ) { + return { ok: false, error: `Password must be at least ${MIN_PASSWORD_LEN} chars.` }; + } + 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 }; +} diff --git a/apps/web/src/app/settings/users/page.tsx b/apps/web/src/app/settings/users/page.tsx new file mode 100644 index 0000000..dff2c2b --- /dev/null +++ b/apps/web/src/app/settings/users/page.tsx @@ -0,0 +1,32 @@ +import { requireAdmin } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { PageShell } from "@/components/page-shell"; +import { Card, CardContent } from "@/components/ui/card"; +import { UserRowClient } from "./user-row-client"; + +export default async function UsersPage() { + const me = await requireAdmin(); + const rows = await db.query.operators.findMany({ + orderBy: (o, { asc }) => [asc(o.username)], + }); + + return ( + + + + {rows.map((u) => ( + + ))} + + + + ); +} diff --git a/apps/web/src/app/settings/users/user-row-client.tsx b/apps/web/src/app/settings/users/user-row-client.tsx new file mode 100644 index 0000000..ada13d7 --- /dev/null +++ b/apps/web/src/app/settings/users/user-row-client.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Loader2Icon, Trash2Icon, KeyIcon, ArrowUpDownIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + setUserRoleAction, + resetUserPasswordAction, + deleteUserAction, +} from "@/actions/users"; + +interface UserRowClientProps { + user: { id: string; username: string; role: "admin" | "user" }; + isSelf: boolean; +} + +export function UserRowClient({ user, isSelf }: UserRowClientProps) { + const [pending, start] = useTransition(); + const [error, setError] = useState(null); + const [resetVisible, setResetVisible] = useState(false); + const [resetPw, setResetPw] = useState(""); + + function run(promise: Promise) { + start(async () => { + setError(null); + const r = await promise; + if (!r.ok) setError(r.error ?? "Failed"); + }); + } + + return ( +
+
+
+

{user.username}

+

{user.role}{isSelf && " · you"}

+
+
+ + + +
+
+ {resetVisible && ( +
+ setResetPw(e.target.value)} + maxLength={256} + /> + +
+ )} + {error &&

{error}

} +
+ ); +}