Add the ability to create users

This commit is contained in:
Moshfegh Hamedani 2026-02-17 09:45:46 -08:00
parent fbca3ca0f3
commit f8ff41ac01
17 changed files with 2265 additions and 111 deletions

View file

@ -15,6 +15,7 @@ A ticket management system that uses AI to classify, respond to, and route suppo
## Project Structure
```
/core - Shared code (Zod schemas, types) — Bun workspace package
/client - React frontend (Vite)
/server - Express backend
/e2e - Playwright E2E tests
@ -40,6 +41,12 @@ The client proxies `/api/*` requests to the server via Vite config (target is co
- Use shadcn/ui components for all UI (import from `@/components/ui/*`)
- Use the `@/` path alias for imports (maps to `./src/`)
- Use shadcn's semantic color tokens (e.g. `bg-background`, `text-muted-foreground`, `text-destructive`) instead of hardcoded Tailwind colors
- Organize server endpoints into Express `Router` modules under `server/src/routes/` (e.g. `routes/users.ts`), mounted in `index.ts`
- 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 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`)

1766
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,7 @@
"react-router": "^7.13.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"core": "workspace:*",
"zod": "^4.3.6"
},
"devDependencies": {

View file

@ -0,0 +1,156 @@
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="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 DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
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 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 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View file

@ -0,0 +1,106 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createUserSchema, type CreateUserInput } from "core/schemas/users";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AlertCircle } from "lucide-react";
interface CreateUserFormProps {
onSuccess: () => void;
}
export default function CreateUserForm({ onSuccess }: CreateUserFormProps) {
const queryClient = useQueryClient();
const form = useForm<CreateUserInput>({
resolver: zodResolver(createUserSchema),
defaultValues: { name: "", email: "", password: "" },
});
const createUser = useMutation({
mutationFn: async (payload: CreateUserInput) => {
const { data } = await axios.post("/api/users", payload);
return data.user;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
form.reset();
createUser.reset();
onSuccess();
},
});
const serverError =
createUser.error && axios.isAxiosError(createUser.error)
? createUser.error.response?.data?.error ?? "Failed to create user"
: createUser.error
? "Failed to create user"
: null;
return (
<form
onSubmit={form.handleSubmit((data) => createUser.mutate(data))}
className="space-y-4"
autoComplete="off"
>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
placeholder="Full name"
{...form.register("name")}
/>
{form.formState.errors.name && (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="user@example.com"
autoComplete="off"
{...form.register("email")}
/>
{form.formState.errors.email && (
<p className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Minimum 8 characters"
autoComplete="new-password"
{...form.register("password")}
/>
{form.formState.errors.password && (
<p className="text-sm text-destructive">
{form.formState.errors.password.message}
</p>
)}
</div>
{serverError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{serverError}</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button type="submit" disabled={createUser.isPending}>
{createUser.isPending ? "Creating..." : "Create User"}
</Button>
</div>
</form>
);
}

View file

@ -1,106 +1,39 @@
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
import { AlertCircle } from "lucide-react";
interface User {
id: string;
name: string;
email: string;
role: "admin" | "agent";
createdAt: string;
}
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Plus } from "lucide-react";
import CreateUserForm from "./CreateUserForm";
import UsersTable from "./UsersTable";
export default function UsersPage() {
const { data: users, isLoading, error } = useQuery({
queryKey: ["users"],
queryFn: async () => {
const { data } = await axios.get<{ users: User[] }>("/api/users");
return data.users;
},
});
if (isLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-6">Users</h1>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell><Skeleton className="h-4 w-40" /></TableCell>
<TableCell><Skeleton className="h-5 w-14 rounded-full" /></TableCell>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
if (error) {
return (
<div>
<h1 className="text-2xl font-bold mb-6">Users</h1>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>Failed to fetch users</AlertDescription>
</Alert>
</div>
);
}
const [open, setOpen] = useState(false);
return (
<div>
<h1 className="text-2xl font-bold mb-6">Users</h1>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users?.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Badge
variant={user.role === "admin" ? "default" : "secondary"}
>
{user.role}
</Badge>
</TableCell>
<TableCell>
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Users</h1>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
New User
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create User</DialogTitle>
</DialogHeader>
<CreateUserForm onSuccess={() => setOpen(false)} />
</DialogContent>
</Dialog>
</div>
<UsersTable />
</div>
);
}

View file

@ -0,0 +1,93 @@
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
import { AlertCircle } from "lucide-react";
interface User {
id: string;
name: string;
email: string;
role: "admin" | "agent";
createdAt: string;
}
export default function UsersTable() {
const {
data: users,
isLoading,
error,
} = useQuery({
queryKey: ["users"],
queryFn: async () => {
const { data } = await axios.get<{ users: User[] }>("/api/users");
return data.users;
},
});
if (error) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>Failed to fetch users</AlertDescription>
</Alert>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading
? Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-40" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-14 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
</TableRow>
))
: users?.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Badge
variant={user.role === "admin" ? "default" : "secondary"}
>
{user.role}
</Badge>
</TableCell>
<TableCell>
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}

View file

@ -25,7 +25,8 @@
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"core/*": ["../core/*"]
}
},
"include": ["src"]

View file

@ -7,7 +7,8 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"core/*": ["../core/*"]
}
}
}

11
core/package.json Normal file
View file

@ -0,0 +1,11 @@
{
"name": "core",
"private": true,
"type": "module",
"exports": {
"./schemas/*": "./schemas/*"
},
"peerDependencies": {
"zod": "^4"
}
}

9
core/schemas/users.ts Normal file
View file

@ -0,0 +1,9 @@
import { z } from "zod/v4";
export const createUserSchema = z.object({
name: z.string().trim().min(3, "Name must be at least 3 characters"),
email: z.email("Invalid email address"),
password: z.string().trim().min(8, "Password must be at least 8 characters"),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;

View file

@ -1,6 +1,7 @@
{
"name": "helpdesk",
"private": true,
"workspaces": ["client", "server", "core"],
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",

View file

@ -12,6 +12,7 @@
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"zod": "^4.3.6",
},
"devDependencies": {
"@types/bun": "latest",

View file

@ -19,10 +19,12 @@
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
"better-auth": "^1.4.18",
"core": "workspace:*",
"cors": "^2.8.6",
"dotenv": "^17.2.4",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0"
"helmet": "^8.1.0",
"zod": "^4.3.6"
}
}

View file

@ -5,8 +5,7 @@ import rateLimit from "express-rate-limit";
import { toNodeHandler } from "better-auth/node";
import { auth } from "./lib/auth";
import { requireAuth } from "./middleware/require-auth";
import { requireAdmin } from "./middleware/require-admin";
import prisma from "./db";
import usersRouter from "./routes/users";
if (!process.env.BETTER_AUTH_SECRET) {
throw new Error("BETTER_AUTH_SECRET environment variable is required");
@ -52,13 +51,7 @@ app.get("/api/me", requireAuth, (req, res) => {
res.json({ user: { id, name, email, role } });
});
app.get("/api/users", requireAuth, requireAdmin, async (req, res) => {
const users = await prisma.user.findMany({
select: { id: true, name: true, email: true, role: true, createdAt: true },
orderBy: { createdAt: "asc" },
});
res.json({ users });
});
app.use("/api/users", usersRouter);
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);

View file

@ -0,0 +1,71 @@
import { Router } from "express";
import { hashPassword } from "better-auth/crypto";
import { createUserSchema } from "core/schemas/users.ts";
import { Role } from "../generated/prisma/enums";
import { requireAuth } from "../middleware/require-auth";
import { requireAdmin } from "../middleware/require-admin";
import prisma from "../db";
const router = Router();
router.get("/", requireAuth, requireAdmin, async (req, res) => {
const users = await prisma.user.findMany({
select: { id: true, name: true, email: true, role: true, createdAt: true },
orderBy: { createdAt: "asc" },
});
res.json({ users });
});
router.post("/", requireAuth, requireAdmin, async (req, res) => {
const result = createUserSchema.safeParse(req.body);
if (!result.success) {
res.status(400).json({ error: result.error.issues[0]?.message ?? "Validation failed" });
return;
}
const { name, email, password } = result.data;
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
res.status(409).json({ error: "Email already exists" });
return;
}
const hashedPassword = await hashPassword(password);
const userId = crypto.randomUUID();
const now = new Date();
await prisma.$transaction([
prisma.user.create({
data: {
id: userId,
name,
email,
emailVerified: false,
role: Role.agent,
createdAt: now,
updatedAt: now,
},
}),
prisma.account.create({
data: {
id: crypto.randomUUID(),
accountId: userId,
providerId: "credential",
userId,
password: hashedPassword,
createdAt: now,
updatedAt: now,
},
}),
]);
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true, email: true, role: true, createdAt: true },
});
res.status(201).json({ user });
});
export default router;

View file

@ -24,6 +24,10 @@
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
"noPropertyAccessFromIndexSignature": false,
"paths": {
"core/*": ["../core/*"]
}
}
}