mirror of
https://github.com/mosh-hamedani/helpdesk.git
synced 2026-05-21 11:58:19 +02:00
Add the ability to create users
This commit is contained in:
parent
fbca3ca0f3
commit
f8ff41ac01
|
|
@ -15,6 +15,7 @@ A ticket management system that uses AI to classify, respond to, and route suppo
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
/core - Shared code (Zod schemas, types) — Bun workspace package
|
||||||
/client - React frontend (Vite)
|
/client - React frontend (Vite)
|
||||||
/server - Express backend
|
/server - Express backend
|
||||||
/e2e - Playwright E2E tests
|
/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 shadcn/ui components for all UI (import from `@/components/ui/*`)
|
||||||
- Use the `@/` path alias for imports (maps to `./src/`)
|
- 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
|
- 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 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`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
"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": {
|
||||||
|
|
|
||||||
156
client/src/components/ui/dialog.tsx
Normal file
156
client/src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
106
client/src/pages/CreateUserForm.tsx
Normal file
106
client/src/pages/CreateUserForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,106 +1,39 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useState } from "react";
|
||||||
import axios from "axios";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Dialog,
|
||||||
TableBody,
|
DialogContent,
|
||||||
TableCell,
|
DialogHeader,
|
||||||
TableHead,
|
DialogTitle,
|
||||||
TableHeader,
|
DialogTrigger,
|
||||||
TableRow,
|
} from "@/components/ui/dialog";
|
||||||
} from "@/components/ui/table";
|
import { Plus } from "lucide-react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import CreateUserForm from "./CreateUserForm";
|
||||||
import { AlertCircle } from "lucide-react";
|
import UsersTable from "./UsersTable";
|
||||||
|
|
||||||
interface User {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
role: "admin" | "agent";
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const { data: users, isLoading, error } = useQuery({
|
const [open, setOpen] = useState(false);
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold mb-6">Users</h1>
|
<div className="flex items-center justify-between mb-6">
|
||||||
<Table>
|
<h1 className="text-2xl font-bold">Users</h1>
|
||||||
<TableHeader>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<TableRow>
|
<DialogTrigger asChild>
|
||||||
<TableHead>Name</TableHead>
|
<Button>
|
||||||
<TableHead>Email</TableHead>
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
<TableHead>Role</TableHead>
|
New User
|
||||||
<TableHead>Created</TableHead>
|
</Button>
|
||||||
</TableRow>
|
</DialogTrigger>
|
||||||
</TableHeader>
|
<DialogContent>
|
||||||
<TableBody>
|
<DialogHeader>
|
||||||
{users?.map((user) => (
|
<DialogTitle>Create User</DialogTitle>
|
||||||
<TableRow key={user.id}>
|
</DialogHeader>
|
||||||
<TableCell>{user.name}</TableCell>
|
<CreateUserForm onSuccess={() => setOpen(false)} />
|
||||||
<TableCell>{user.email}</TableCell>
|
</DialogContent>
|
||||||
<TableCell>
|
</Dialog>
|
||||||
<Badge
|
</div>
|
||||||
variant={user.role === "admin" ? "default" : "secondary"}
|
<UsersTable />
|
||||||
>
|
|
||||||
{user.role}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{new Date(user.createdAt).toLocaleDateString()}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
93
client/src/pages/UsersTable.tsx
Normal file
93
client/src/pages/UsersTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -25,7 +25,8 @@
|
||||||
"noUncheckedSideEffectImports": true,
|
"noUncheckedSideEffectImports": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"],
|
||||||
|
"core/*": ["../core/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"],
|
||||||
|
"core/*": ["../core/*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
core/package.json
Normal file
11
core/package.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"name": "core",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./schemas/*": "./schemas/*"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^4"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
core/schemas/users.ts
Normal file
9
core/schemas/users.ts
Normal 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>;
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "helpdesk",
|
"name": "helpdesk",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"workspaces": ["client", "server", "core"],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.2.1",
|
"express-rate-limit": "^8.2.1",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,12 @@
|
||||||
"@prisma/adapter-pg": "^7.3.0",
|
"@prisma/adapter-pg": "^7.3.0",
|
||||||
"@prisma/client": "^7.3.0",
|
"@prisma/client": "^7.3.0",
|
||||||
"better-auth": "^1.4.18",
|
"better-auth": "^1.4.18",
|
||||||
|
"core": "workspace:*",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.2.4",
|
"dotenv": "^17.2.4",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.2.1",
|
"express-rate-limit": "^8.2.1",
|
||||||
"helmet": "^8.1.0"
|
"helmet": "^8.1.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@ import rateLimit from "express-rate-limit";
|
||||||
import { toNodeHandler } from "better-auth/node";
|
import { toNodeHandler } from "better-auth/node";
|
||||||
import { auth } from "./lib/auth";
|
import { auth } from "./lib/auth";
|
||||||
import { requireAuth } from "./middleware/require-auth";
|
import { requireAuth } from "./middleware/require-auth";
|
||||||
import { requireAdmin } from "./middleware/require-admin";
|
import usersRouter from "./routes/users";
|
||||||
import prisma from "./db";
|
|
||||||
|
|
||||||
if (!process.env.BETTER_AUTH_SECRET) {
|
if (!process.env.BETTER_AUTH_SECRET) {
|
||||||
throw new Error("BETTER_AUTH_SECRET environment variable is required");
|
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 } });
|
res.json({ user: { id, name, email, role } });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/users", requireAuth, requireAdmin, async (req, res) => {
|
app.use("/api/users", usersRouter);
|
||||||
const users = await prisma.user.findMany({
|
|
||||||
select: { id: true, name: true, email: true, role: true, createdAt: true },
|
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
});
|
|
||||||
res.json({ users });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Server running on http://localhost:${port}`);
|
console.log(`Server running on http://localhost:${port}`);
|
||||||
|
|
|
||||||
71
server/src/routes/users.ts
Normal file
71
server/src/routes/users.ts
Normal 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;
|
||||||
|
|
@ -24,6 +24,10 @@
|
||||||
// Some stricter flags (disabled by default)
|
// Some stricter flags (disabled by default)
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
|
||||||
|
"paths": {
|
||||||
|
"core/*": ["../core/*"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue