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"`)
|
- 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`)
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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 />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
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,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
"./schemas/*": "./schemas/*"
|
"./schemas/*": "./schemas/*",
|
||||||
|
"./constants/*": "./constants/*"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^4"
|
"zod": "^4"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "user" ADD COLUMN "deletedAt" TIMESTAMP(3);
|
||||||
|
|
@ -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[]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue