feat(web): user-management surface (admin only)

createUserAction, setUserRoleAction, resetUserPasswordAction,
deleteUserAction — all gated by requireAdmin(). Self-demote and
last-admin guards prevent the operator from accidentally locking
themselves out. /settings/users page lists every operator with
inline Demote/Promote, Reset password, and Delete buttons. 10 unit
tests.
This commit is contained in:
yiekheng 2026-05-10 18:01:09 +08:00
parent b77a9d106d
commit 67091c294a
4 changed files with 451 additions and 0 deletions

View File

@ -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<typeof import("@/lib/auth")>("@/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);
});
});

View File

@ -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<CreateUserResult> {
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<SetRoleResult> {
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<DeleteUserResult> {
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<ResetPasswordResult> {
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 };
}

View File

@ -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 (
<PageShell title="Users">
<Card>
<CardContent className="space-y-3 py-4">
{rows.map((u) => (
<UserRowClient
key={u.id}
user={{
id: u.id,
username: u.username,
role: (u.role === "admin" ? "admin" : "user"),
}}
isSelf={u.id === me.id}
/>
))}
</CardContent>
</Card>
</PageShell>
);
}

View File

@ -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<string | null>(null);
const [resetVisible, setResetVisible] = useState(false);
const [resetPw, setResetPw] = useState("");
function run<T extends { ok: boolean; error?: string }>(promise: Promise<T>) {
start(async () => {
setError(null);
const r = await promise;
if (!r.ok) setError(r.error ?? "Failed");
});
}
return (
<div className="flex flex-col gap-2 rounded-lg border p-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{user.username}</p>
<p className="text-xs text-muted-foreground">{user.role}{isSelf && " · you"}</p>
</div>
<div className="flex gap-1">
<Button
type="button"
size="sm"
variant="ghost"
disabled={pending || isSelf}
onClick={() =>
run(
setUserRoleAction({
userId: user.id,
role: user.role === "admin" ? "user" : "admin",
}),
)
}
>
<ArrowUpDownIcon className="size-3.5" />
{user.role === "admin" ? "Demote" : "Promote"}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
disabled={pending}
onClick={() => setResetVisible((v) => !v)}
>
<KeyIcon className="size-3.5" />
Reset
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-destructive"
disabled={pending || isSelf}
onClick={() => run(deleteUserAction({ userId: user.id }))}
>
{pending ? <Loader2Icon className="size-3.5 animate-spin" /> : <Trash2Icon className="size-3.5" />}
</Button>
</div>
</div>
{resetVisible && (
<div className="flex gap-2">
<Input
type="password"
placeholder="New password (≥10 chars)"
value={resetPw}
onChange={(e) => setResetPw(e.target.value)}
maxLength={256}
/>
<Button
type="button"
size="sm"
disabled={pending}
onClick={() => {
run(resetUserPasswordAction({ userId: user.id, newPassword: resetPw }));
setResetPw("");
setResetVisible(false);
}}
>
Save
</Button>
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}