feat(users): add admin user management and bootstrap seed
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { requireRole } from "@/services/auth.service"
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
await requireRole("ADMIN")
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { getUserProfileById } from "@/services/user.service"
|
||||
|
||||
import EditUserForm from "../../_components/edit.user.form"
|
||||
import ResetUserPasswordForm from "../../_components/reset.user.password.form"
|
||||
|
||||
export default async function EditUserPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ userId: string }>
|
||||
}) {
|
||||
const { userId } = await params
|
||||
const user = await getUserProfileById(userId)
|
||||
|
||||
if (!user) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">Edit User</h1>
|
||||
</div>
|
||||
<EditUserForm user={user} />
|
||||
<section className="flex flex-col gap-4 border-t pt-6">
|
||||
<h2 className="text-xl font-semibold">Reset password</h2>
|
||||
<ResetUserPasswordForm userId={user.id} />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import type { UseFormRegisterReturn } from "react-hook-form"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { updateUserAction } from "@/actions/user.actions"
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import {
|
||||
type UpdateUserFormType,
|
||||
updateUserSchema,
|
||||
} from "@/schemas/user.schema"
|
||||
import type { UserWithoutPassword } from "@/services/user.service"
|
||||
|
||||
export default function EditUserForm({ user }: { user: UserWithoutPassword }) {
|
||||
const router = useRouter()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<UpdateUserFormType>({
|
||||
resolver: zodResolver(updateUserSchema),
|
||||
defaultValues: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (formData: UpdateUserFormType) => {
|
||||
const response = await updateUserAction(formData)
|
||||
|
||||
if (response?.errors) {
|
||||
Object.entries(response.errors).forEach(([fieldName, messages]) => {
|
||||
messages.forEach((message: string) => {
|
||||
setError(fieldName as keyof UpdateUserFormType, {
|
||||
type: "server",
|
||||
message,
|
||||
})
|
||||
toast.error(message)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success(response.message)
|
||||
router.push("/admin/users")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<input type="hidden" {...register("id")} />
|
||||
<UserTextInput
|
||||
error={errors.name?.message}
|
||||
id="name"
|
||||
label="Name"
|
||||
placeholder="Full name"
|
||||
register={register("name")}
|
||||
/>
|
||||
<UserTextInput
|
||||
error={errors.username?.message}
|
||||
id="username"
|
||||
label="Username"
|
||||
placeholder="username"
|
||||
register={register("username")}
|
||||
/>
|
||||
<UserTextInput
|
||||
error={errors.email?.message}
|
||||
id="email"
|
||||
label="Email"
|
||||
placeholder="user@example.com"
|
||||
register={register("email")}
|
||||
type="email"
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="role" className="mb-2 block text-lg">
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
id="role"
|
||||
{...register("role")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="ADMIN">Admin</option>
|
||||
<option value="MANAGER">Manager</option>
|
||||
<option value="STAFF">Staff</option>
|
||||
<option value="VIEWER">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" {...register("isActive")} />
|
||||
Active user
|
||||
</label>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
Update User
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function UserTextInput({
|
||||
error,
|
||||
id,
|
||||
label,
|
||||
placeholder,
|
||||
register,
|
||||
type = "text",
|
||||
}: {
|
||||
error?: string
|
||||
id: string
|
||||
label: string
|
||||
placeholder: string
|
||||
register: UseFormRegisterReturn
|
||||
type?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor={id} className="mb-2 block text-lg">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
type={type}
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
{...register}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
|
||||
/>
|
||||
{error && <p className="text-error">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import type { UseFormRegisterReturn } from "react-hook-form"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { createUserAction } from "@/actions/user.actions"
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import {
|
||||
type CreateUserFormType,
|
||||
createUserSchema,
|
||||
} from "@/schemas/user.schema"
|
||||
|
||||
export default function NewUserForm() {
|
||||
const router = useRouter()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<CreateUserFormType>({
|
||||
resolver: zodResolver(createUserSchema),
|
||||
defaultValues: {
|
||||
role: "STAFF",
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (formData: CreateUserFormType) => {
|
||||
const response = await createUserAction(formData)
|
||||
|
||||
if (response?.errors) {
|
||||
Object.entries(response.errors).forEach(([fieldName, messages]) => {
|
||||
messages.forEach((message: string) => {
|
||||
setError(fieldName as keyof CreateUserFormType, {
|
||||
type: "server",
|
||||
message,
|
||||
})
|
||||
toast.error(message)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success(response.message)
|
||||
router.push("/admin/users")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<UserTextInput
|
||||
error={errors.name?.message}
|
||||
id="name"
|
||||
label="Name"
|
||||
placeholder="Full name"
|
||||
register={register("name")}
|
||||
/>
|
||||
<UserTextInput
|
||||
error={errors.username?.message}
|
||||
id="username"
|
||||
label="Username"
|
||||
placeholder="username"
|
||||
register={register("username")}
|
||||
/>
|
||||
<UserTextInput
|
||||
error={errors.email?.message}
|
||||
id="email"
|
||||
label="Email"
|
||||
placeholder="user@example.com"
|
||||
register={register("email")}
|
||||
type="email"
|
||||
/>
|
||||
<UserTextInput
|
||||
error={errors.password?.message}
|
||||
id="password"
|
||||
label="Password"
|
||||
placeholder="Minimum 8 characters"
|
||||
register={register("password")}
|
||||
type="password"
|
||||
/>
|
||||
<RoleSelect register={register("role")} />
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
Create User
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function UserTextInput({
|
||||
error,
|
||||
id,
|
||||
label,
|
||||
placeholder,
|
||||
register,
|
||||
type = "text",
|
||||
}: {
|
||||
error?: string
|
||||
id: string
|
||||
label: string
|
||||
placeholder: string
|
||||
register: UseFormRegisterReturn
|
||||
type?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor={id} className="mb-2 block text-lg">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
type={type}
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
{...register}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
|
||||
/>
|
||||
{error && <p className="text-error">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RoleSelect({ register }: { register: UseFormRegisterReturn }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="role" className="mb-2 block text-lg">
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
id="role"
|
||||
{...register}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="ADMIN">Admin</option>
|
||||
<option value="MANAGER">Manager</option>
|
||||
<option value="STAFF">Staff</option>
|
||||
<option value="VIEWER">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { resetUserPasswordAction } from "@/actions/user.actions"
|
||||
import { SubmitButton } from "@/components/forms/submitButton"
|
||||
import {
|
||||
type ResetUserPasswordFormType,
|
||||
resetUserPasswordSchema,
|
||||
} from "@/schemas/user.schema"
|
||||
|
||||
export default function ResetUserPasswordForm({ userId }: { userId: string }) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<ResetUserPasswordFormType>({
|
||||
resolver: zodResolver(resetUserPasswordSchema),
|
||||
defaultValues: {
|
||||
id: userId,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (formData: ResetUserPasswordFormType) => {
|
||||
const response = await resetUserPasswordAction(formData)
|
||||
|
||||
if (response?.errors) {
|
||||
Object.entries(response.errors).forEach(([fieldName, messages]) => {
|
||||
messages.forEach((message: string) => {
|
||||
setError(fieldName as keyof ResetUserPasswordFormType, {
|
||||
type: "server",
|
||||
message,
|
||||
})
|
||||
toast.error(message)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success(response.message)
|
||||
reset({ id: userId, password: "" })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<input type="hidden" {...register("id")} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="password" className="mb-2 block text-lg">
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
placeholder="Minimum 8 characters"
|
||||
{...register("password")}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${errors.password ? "border-error" : ""}`}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-error">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
Reset Password
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import NewUserForm from "../_components/new.user.form"
|
||||
|
||||
export default function NewUserPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">New User</h1>
|
||||
</div>
|
||||
<NewUserForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Pencil } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import PageHeader from "@/components/common/pageheader"
|
||||
import PaginationButtons from "@/components/common/pagination"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { getUsers } from "@/services/user.service"
|
||||
|
||||
export default async function UsersPage(props: {
|
||||
searchParams?: Promise<{
|
||||
page?: string
|
||||
search?: string
|
||||
}>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
|
||||
const search = searchParams?.search || ""
|
||||
const { data: users, totalPages } = await getUsers({
|
||||
page: currentPage,
|
||||
pageSize: 10,
|
||||
search,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title="Users"
|
||||
link="/admin/users/new"
|
||||
search={search}
|
||||
data={users}
|
||||
/>
|
||||
{users.length === 0 && currentPage === 1 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
No users found.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{users.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="text-muted-foreground w-full text-left text-sm">
|
||||
<thead className="border-b">
|
||||
<tr>
|
||||
<th scope="col" className="p-4">
|
||||
Name
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Username
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Email
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Role
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Status
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b">
|
||||
<td className="p-4">{user.name}</td>
|
||||
<td className="p-4">{user.username}</td>
|
||||
<td className="p-4">{user.email}</td>
|
||||
<td className="p-4">{user.role}</td>
|
||||
<td className="p-4">
|
||||
{user.isActive ? "Active" : "Inactive"}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<Link href={`/admin/users/${user.id}/edit`} passHref>
|
||||
<Button variant="outline" size="icon">
|
||||
<Pencil />
|
||||
</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t">
|
||||
<tr>
|
||||
<td colSpan={6} className="p-4 text-center text-sm">
|
||||
<PaginationButtons totalPages={totalPages} />
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user