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
|
||||
|
||||
```
|
||||
/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`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
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 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"core/*": ["../core/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"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",
|
||||
"private": true,
|
||||
"workspaces": ["client", "server", "core"],
|
||||
"scripts": {
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
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)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
|
||||
"paths": {
|
||||
"core/*": ["../core/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue