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:
parent
b77a9d106d
commit
67091c294a
169
apps/web/src/actions/users.test.ts
Normal file
169
apps/web/src/actions/users.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
145
apps/web/src/actions/users.ts
Normal file
145
apps/web/src/actions/users.ts
Normal 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 };
|
||||
}
|
||||
32
apps/web/src/app/settings/users/page.tsx
Normal file
32
apps/web/src/app/settings/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
apps/web/src/app/settings/users/user-row-client.tsx
Normal file
105
apps/web/src/app/settings/users/user-row-client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user