mirror of
https://github.com/mosh-hamedani/helpdesk.git
synced 2026-05-21 11:58:19 +02:00
Add the ability to delete users
This commit is contained in:
parent
b43a2e9239
commit
96925df60c
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
194
client/src/components/ui/alert-dialog.tsx
Normal file
194
client/src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
6
core/constants/role.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export const Role = {
|
||||
admin: "admin",
|
||||
agent: "agent",
|
||||
} as const;
|
||||
|
||||
export type Role = (typeof Role)[keyof typeof Role];
|
||||
|
|
@ -3,7 +3,8 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./schemas/*": "./schemas/*"
|
||||
"./schemas/*": "./schemas/*",
|
||||
"./constants/*": "./constants/*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "user" ADD COLUMN "deletedAt" TIMESTAMP(3);
|
||||
|
|
@ -27,6 +27,7 @@ model User {
|
|||
role Role @default(agent)
|
||||
createdAt DateTime
|
||||
updatedAt DateTime
|
||||
deletedAt DateTime?
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue