Add the ability to delete users

This commit is contained in:
Moshfegh Hamedani 2026-02-18 11:34:27 -08:00
parent b43a2e9239
commit 96925df60c
17 changed files with 414 additions and 17 deletions

View file

@ -45,7 +45,7 @@ The client proxies `/api/*` requests to the server via Vite config (target is co
- Define shared Zod schemas in the `core` package under `core/schemas/` (e.g. `core/schemas/users.ts`) and import them in both client and server (e.g. `import { createUserSchema } from "core/schemas/users"`)
- Use Zod for validation (import from `zod/v4`)
- Do not wrap async route handlers in try/catch — Express 5 automatically catches rejected promises
- Use Prisma-generated enums (e.g. `Role`) instead of hardcoded strings (import from `./generated/prisma/enums`)
- Use the shared `Role` constant instead of hardcoded `"admin"` / `"agent"` strings (import from `core/constants/role.ts`, e.g. `import { Role } from "core/constants/role.ts"`)
- Use React Hook Form with Zod resolver for client-side form validation (`useForm` + `zodResolver` from `@hookform/resolvers/zod`)
- Use Axios for HTTP requests (not `fetch`)
- Use TanStack React Query (`useQuery`, `useMutation`) for server state management (not `useEffect` + `useState`)

View file

@ -19,6 +19,7 @@
"better-auth": "^1.4.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"core": "workspace:*",
"lucide-react": "^0.563.0",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
@ -27,7 +28,6 @@
"react-router": "^7.13.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"core": "workspace:*",
"zod": "^4.3.6"
},
"devDependencies": {

View file

@ -1,4 +1,5 @@
import { Navigate, Outlet } from "react-router";
import { Role } from "core/constants/role.ts";
import { useSession } from "../lib/auth-client";
export default function AdminRoute() {
@ -12,7 +13,7 @@ export default function AdminRoute() {
);
}
if (session?.user?.role !== "admin") {
if (session?.user?.role !== Role.admin) {
return <Navigate to="/" replace />;
}

View file

@ -1,4 +1,5 @@
import { Link, Outlet, useNavigate } from "react-router";
import { Role } from "core/constants/role.ts";
import { signOut, useSession } from "../lib/auth-client";
export default function Layout() {
@ -15,7 +16,7 @@ export default function Layout() {
<nav className="flex items-center justify-between bg-background border-b px-6 h-14">
<div className="flex items-center gap-6">
<Link to="/" className="text-lg font-bold hover:text-foreground transition-colors">Helpdesk</Link>
{session?.user?.role === "admin" && (
{session?.user?.role === Role.admin && (
<Link to="/users" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
Users
</Link>

View file

@ -0,0 +1,194 @@
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View file

@ -2,6 +2,7 @@ import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import axios from "axios";
import { Role } from "core/constants/role.ts";
import { renderWithQuery } from "@/test/render";
import UsersPage from "./UsersPage";
@ -13,14 +14,14 @@ const mockUsers = [
id: "1",
name: "Alice Admin",
email: "alice@example.com",
role: "admin" as const,
role: Role.admin,
createdAt: "2025-01-15T00:00:00.000Z",
},
{
id: "2",
name: "Bob Agent",
email: "bob@example.com",
role: "agent" as const,
role: Role.agent,
createdAt: "2025-02-20T00:00:00.000Z",
},
];
@ -185,4 +186,88 @@ describe("UsersPage", () => {
expect(screen.getByLabelText("Email")).toHaveValue("alice@example.com");
expect(screen.getByLabelText("Password")).toHaveValue("");
});
it("should show delete button for agent rows but not admin rows", async () => {
mockedAxios.get.mockResolvedValue({ data: { users: mockUsers } });
renderWithQuery(<UsersPage />);
await waitFor(() => {
expect(screen.getByText("Alice Admin")).toBeInTheDocument();
});
expect(screen.queryByRole("button", { name: "Delete Alice Admin" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Delete Bob Agent" })).toBeInTheDocument();
});
it("should open confirmation dialog when clicking delete", async () => {
mockedAxios.get.mockResolvedValue({ data: { users: mockUsers } });
const user = userEvent.setup();
renderWithQuery(<UsersPage />);
await waitFor(() => {
expect(screen.getByText("Bob Agent")).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Delete Bob Agent" }));
expect(screen.getByRole("alertdialog")).toBeInTheDocument();
expect(screen.getByText(/Are you sure you want to delete Bob Agent/)).toBeInTheDocument();
});
it("should close confirmation dialog when clicking Cancel", async () => {
mockedAxios.get.mockResolvedValue({ data: { users: mockUsers } });
const user = userEvent.setup();
renderWithQuery(<UsersPage />);
await waitFor(() => {
expect(screen.getByText("Bob Agent")).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Delete Bob Agent" }));
expect(screen.getByRole("alertdialog")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => {
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
});
expect(mockedAxios.delete).not.toHaveBeenCalled();
});
it("should call axios.delete when clicking Confirm", async () => {
mockedAxios.get.mockResolvedValue({ data: { users: mockUsers } });
mockedAxios.delete.mockResolvedValue({ data: { message: "User deleted" } });
const user = userEvent.setup();
renderWithQuery(<UsersPage />);
await waitFor(() => {
expect(screen.getByText("Bob Agent")).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Delete Bob Agent" }));
await user.click(screen.getByRole("button", { name: "Confirm" }));
await waitFor(() => {
expect(mockedAxios.delete).toHaveBeenCalledWith("/api/users/2");
});
});
it("should refresh users list after successful deletion", async () => {
mockedAxios.get.mockResolvedValue({ data: { users: mockUsers } });
mockedAxios.delete.mockResolvedValue({ data: { message: "User deleted" } });
const user = userEvent.setup();
renderWithQuery(<UsersPage />);
await waitFor(() => {
expect(screen.getByText("Bob Agent")).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Delete Bob Agent" }));
await user.click(screen.getByRole("button", { name: "Confirm" }));
await waitFor(() => {
expect(mockedAxios.get).toHaveBeenCalledTimes(2);
});
});
});

View file

@ -1,4 +1,6 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -6,7 +8,18 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Plus } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertCircle, Plus } from "lucide-react";
import UserForm from "./UserForm";
import UsersTable from "./UsersTable";
@ -16,13 +29,28 @@ interface EditingUser {
email: string;
}
interface DeletingUser {
id: string;
name: string;
}
type DialogState = { mode: "create" } | { mode: "edit"; user: EditingUser } | null;
export default function UsersPage() {
const [dialog, setDialog] = useState<DialogState>(null);
const [deletingUser, setDeletingUser] = useState<DeletingUser | null>(null);
const queryClient = useQueryClient();
const close = () => setDialog(null);
const deleteMutation = useMutation({
mutationFn: (id: string) => axios.delete(`/api/users/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
setDeletingUser(null);
},
});
return (
<div>
<div className="flex items-center justify-between mb-6">
@ -32,7 +60,10 @@ export default function UsersPage() {
New User
</Button>
</div>
<UsersTable onEdit={(user) => setDialog({ mode: "edit", user })} />
<UsersTable
onEdit={(user) => setDialog({ mode: "edit", user })}
onDelete={(user) => setDeletingUser(user)}
/>
<Dialog open={dialog !== null} onOpenChange={(open) => { if (!open) close(); }}>
<DialogContent>
<DialogHeader>
@ -47,6 +78,31 @@ export default function UsersPage() {
/>
</DialogContent>
</Dialog>
<AlertDialog open={deletingUser !== null} onOpenChange={(open) => { if (!open) { setDeletingUser(null); deleteMutation.reset(); } }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete User</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {deletingUser?.name}? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
{deleteMutation.isError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>Failed to delete user</AlertDescription>
</Alert>
)}
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deletingUser && deleteMutation.mutate(deletingUser.id)}
className="bg-destructive text-white hover:bg-destructive/90"
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { Role } from "core/constants/role.ts";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -12,21 +13,22 @@ import {
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
import { AlertCircle, Pencil } from "lucide-react";
import { AlertCircle, Pencil, Trash2 } from "lucide-react";
interface User {
id: string;
name: string;
email: string;
role: "admin" | "agent";
role: Role;
createdAt: string;
}
interface UsersTableProps {
onEdit: (user: User) => void;
onDelete: (user: User) => void;
}
export default function UsersTable({ onEdit }: UsersTableProps) {
export default function UsersTable({ onEdit, onDelete }: UsersTableProps) {
const {
data: users,
isLoading,
@ -86,7 +88,7 @@ export default function UsersTable({ onEdit }: UsersTableProps) {
<TableCell>{user.email}</TableCell>
<TableCell>
<Badge
variant={user.role === "admin" ? "default" : "secondary"}
variant={user.role === Role.admin ? "default" : "secondary"}
>
{user.role}
</Badge>
@ -103,6 +105,16 @@ export default function UsersTable({ onEdit }: UsersTableProps) {
>
<Pencil className="h-4 w-4" />
</Button>
{user.role !== Role.admin && (
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(user)}
aria-label={`Delete ${user.name}`}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
))}

6
core/constants/role.ts Normal file
View file

@ -0,0 +1,6 @@
export const Role = {
admin: "admin",
agent: "agent",
} as const;
export type Role = (typeof Role)[keyof typeof Role];

View file

@ -3,7 +3,8 @@
"private": true,
"type": "module",
"exports": {
"./schemas/*": "./schemas/*"
"./schemas/*": "./schemas/*",
"./constants/*": "./constants/*"
},
"peerDependencies": {
"zod": "^4"

View file

@ -1,4 +1,5 @@
import { Page, expect } from "@playwright/test";
import { Role } from "core/constants/role.ts";
/**
* Test credentials based on seeded data from server/prisma/seed.ts
@ -8,7 +9,7 @@ export const TEST_USERS = {
email: "admin@example.com",
password: "password123",
name: "Admin",
role: "admin",
role: Role.admin,
},
// Currently only admin is seeded, but we can add agent users here if seeded
} as const;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "user" ADD COLUMN "deletedAt" TIMESTAMP(3);

View file

@ -27,6 +27,7 @@ model User {
role Role @default(agent)
createdAt DateTime
updatedAt DateTime
deletedAt DateTime?
sessions Session[]
accounts Account[]

View file

@ -1,5 +1,6 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { Role } from "core/constants/role.ts";
import prisma from "../db";
export const auth = betterAuth({
@ -16,8 +17,13 @@ export const auth = betterAuth({
additionalFields: {
role: {
type: "string",
required: true,
defaultValue: Role.agent,
input: false,
},
deletedAt: {
type: "date",
required: false,
defaultValue: "agent",
input: false,
},
},

View file

@ -1,7 +1,8 @@
import type { RequestHandler } from "express";
import { Role } from "core/constants/role.ts";
export const requireAdmin: RequestHandler = (req, res, next) => {
if (req.user?.role !== "admin") {
if (req.user?.role !== Role.admin) {
res.status(403).json({ error: "Forbidden" });
return;
}

View file

@ -12,6 +12,11 @@ export const requireAuth: RequestHandler = async (req, res, next) => {
return;
}
if (session.user.deletedAt) {
res.status(401).json({ error: "Unauthorized" });
return;
}
req.user = session.user;
req.session = session.session;
next();

View file

@ -2,7 +2,7 @@ import { Router, type Response } from "express";
import type { ZodType } from "zod/v4";
import { hashPassword } from "better-auth/crypto";
import { createUserSchema, updateUserSchema } from "core/schemas/users.ts";
import { Role } from "../generated/prisma/enums";
import { Role } from "core/constants/role.ts";
import { requireAuth } from "../middleware/require-auth";
import { requireAdmin } from "../middleware/require-admin";
import prisma from "../db";
@ -20,6 +20,7 @@ const router = Router();
router.get("/", requireAuth, requireAdmin, async (req, res) => {
const users = await prisma.user.findMany({
where: { deletedAt: null },
select: { id: true, name: true, email: true, role: true, createdAt: true },
orderBy: { createdAt: "asc" },
});
@ -110,4 +111,28 @@ router.put("/:id", requireAuth, requireAdmin, async (req, res) => {
res.json({ user });
});
router.delete("/:id", requireAuth, requireAdmin, async (req, res) => {
const id = req.params.id as string;
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
if (user.role === Role.admin) {
res.status(403).json({ error: "Admin users cannot be deleted" });
return;
}
await prisma.user.update({
where: { id },
data: { deletedAt: new Date() },
});
await prisma.session.deleteMany({ where: { userId: id } });
res.json({ message: "User deleted" });
});
export default router;