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"`) - 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`) - Use Zod for validation (import from `zod/v4`)
- Do not wrap async route handlers in try/catch — Express 5 automatically catches rejected promises - 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 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 Axios for HTTP requests (not `fetch`)
- Use TanStack React Query (`useQuery`, `useMutation`) for server state management (not `useEffect` + `useState`) - Use TanStack React Query (`useQuery`, `useMutation`) for server state management (not `useEffect` + `useState`)

View file

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

View file

@ -1,4 +1,5 @@
import { Navigate, Outlet } from "react-router"; import { Navigate, Outlet } from "react-router";
import { Role } from "core/constants/role.ts";
import { useSession } from "../lib/auth-client"; import { useSession } from "../lib/auth-client";
export default function AdminRoute() { 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 />; return <Navigate to="/" replace />;
} }

View file

@ -1,4 +1,5 @@
import { Link, Outlet, useNavigate } from "react-router"; import { Link, Outlet, useNavigate } from "react-router";
import { Role } from "core/constants/role.ts";
import { signOut, useSession } from "../lib/auth-client"; import { signOut, useSession } from "../lib/auth-client";
export default function Layout() { 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"> <nav className="flex items-center justify-between bg-background border-b px-6 h-14">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<Link to="/" className="text-lg font-bold hover:text-foreground transition-colors">Helpdesk</Link> <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"> <Link to="/users" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
Users Users
</Link> </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 userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import axios from "axios"; import axios from "axios";
import { Role } from "core/constants/role.ts";
import { renderWithQuery } from "@/test/render"; import { renderWithQuery } from "@/test/render";
import UsersPage from "./UsersPage"; import UsersPage from "./UsersPage";
@ -13,14 +14,14 @@ const mockUsers = [
id: "1", id: "1",
name: "Alice Admin", name: "Alice Admin",
email: "alice@example.com", email: "alice@example.com",
role: "admin" as const, role: Role.admin,
createdAt: "2025-01-15T00:00:00.000Z", createdAt: "2025-01-15T00:00:00.000Z",
}, },
{ {
id: "2", id: "2",
name: "Bob Agent", name: "Bob Agent",
email: "bob@example.com", email: "bob@example.com",
role: "agent" as const, role: Role.agent,
createdAt: "2025-02-20T00:00:00.000Z", createdAt: "2025-02-20T00:00:00.000Z",
}, },
]; ];
@ -185,4 +186,88 @@ describe("UsersPage", () => {
expect(screen.getByLabelText("Email")).toHaveValue("alice@example.com"); expect(screen.getByLabelText("Email")).toHaveValue("alice@example.com");
expect(screen.getByLabelText("Password")).toHaveValue(""); 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 { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -6,7 +8,18 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } 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 UserForm from "./UserForm";
import UsersTable from "./UsersTable"; import UsersTable from "./UsersTable";
@ -16,13 +29,28 @@ interface EditingUser {
email: string; email: string;
} }
interface DeletingUser {
id: string;
name: string;
}
type DialogState = { mode: "create" } | { mode: "edit"; user: EditingUser } | null; type DialogState = { mode: "create" } | { mode: "edit"; user: EditingUser } | null;
export default function UsersPage() { export default function UsersPage() {
const [dialog, setDialog] = useState<DialogState>(null); const [dialog, setDialog] = useState<DialogState>(null);
const [deletingUser, setDeletingUser] = useState<DeletingUser | null>(null);
const queryClient = useQueryClient();
const close = () => setDialog(null); const close = () => setDialog(null);
const deleteMutation = useMutation({
mutationFn: (id: string) => axios.delete(`/api/users/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
setDeletingUser(null);
},
});
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
@ -32,7 +60,10 @@ export default function UsersPage() {
New User New User
</Button> </Button>
</div> </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(); }}> <Dialog open={dialog !== null} onOpenChange={(open) => { if (!open) close(); }}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@ -47,6 +78,31 @@ export default function UsersPage() {
/> />
</DialogContent> </DialogContent>
</Dialog> </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> </div>
); );
} }

View file

@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { Role } from "core/constants/role.ts";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -12,21 +13,22 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { AlertCircle, Pencil } from "lucide-react"; import { AlertCircle, Pencil, Trash2 } from "lucide-react";
interface User { interface User {
id: string; id: string;
name: string; name: string;
email: string; email: string;
role: "admin" | "agent"; role: Role;
createdAt: string; createdAt: string;
} }
interface UsersTableProps { interface UsersTableProps {
onEdit: (user: User) => void; onEdit: (user: User) => void;
onDelete: (user: User) => void;
} }
export default function UsersTable({ onEdit }: UsersTableProps) { export default function UsersTable({ onEdit, onDelete }: UsersTableProps) {
const { const {
data: users, data: users,
isLoading, isLoading,
@ -86,7 +88,7 @@ export default function UsersTable({ onEdit }: UsersTableProps) {
<TableCell>{user.email}</TableCell> <TableCell>{user.email}</TableCell>
<TableCell> <TableCell>
<Badge <Badge
variant={user.role === "admin" ? "default" : "secondary"} variant={user.role === Role.admin ? "default" : "secondary"}
> >
{user.role} {user.role}
</Badge> </Badge>
@ -103,6 +105,16 @@ export default function UsersTable({ onEdit }: UsersTableProps) {
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </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> </TableCell>
</TableRow> </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, "private": true,
"type": "module", "type": "module",
"exports": { "exports": {
"./schemas/*": "./schemas/*" "./schemas/*": "./schemas/*",
"./constants/*": "./constants/*"
}, },
"peerDependencies": { "peerDependencies": {
"zod": "^4" "zod": "^4"

View file

@ -1,4 +1,5 @@
import { Page, expect } from "@playwright/test"; import { Page, expect } from "@playwright/test";
import { Role } from "core/constants/role.ts";
/** /**
* Test credentials based on seeded data from server/prisma/seed.ts * Test credentials based on seeded data from server/prisma/seed.ts
@ -8,7 +9,7 @@ export const TEST_USERS = {
email: "admin@example.com", email: "admin@example.com",
password: "password123", password: "password123",
name: "Admin", name: "Admin",
role: "admin", role: Role.admin,
}, },
// Currently only admin is seeded, but we can add agent users here if seeded // Currently only admin is seeded, but we can add agent users here if seeded
} as const; } 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) role Role @default(agent)
createdAt DateTime createdAt DateTime
updatedAt DateTime updatedAt DateTime
deletedAt DateTime?
sessions Session[] sessions Session[]
accounts Account[] accounts Account[]

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@ import { Router, type Response } from "express";
import type { ZodType } from "zod/v4"; import type { ZodType } from "zod/v4";
import { hashPassword } from "better-auth/crypto"; import { hashPassword } from "better-auth/crypto";
import { createUserSchema, updateUserSchema } from "core/schemas/users.ts"; 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 { requireAuth } from "../middleware/require-auth";
import { requireAdmin } from "../middleware/require-admin"; import { requireAdmin } from "../middleware/require-admin";
import prisma from "../db"; import prisma from "../db";
@ -20,6 +20,7 @@ const router = Router();
router.get("/", requireAuth, requireAdmin, async (req, res) => { router.get("/", requireAuth, requireAdmin, async (req, res) => {
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
where: { deletedAt: null },
select: { id: true, name: true, email: true, role: true, createdAt: true }, select: { id: true, name: true, email: true, role: true, createdAt: true },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}); });
@ -110,4 +111,28 @@ router.put("/:id", requireAuth, requireAdmin, async (req, res) => {
res.json({ user }); 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; export default router;