feat(users): add admin user management and bootstrap seed
This commit is contained in:
+8
-1
@@ -13,4 +13,11 @@ NODE_ENV=production
|
||||
DEMO_MODE=false
|
||||
DOMAIN=localhost
|
||||
AUTH_TRUST_HOST="http://localhost"
|
||||
AUTH_SECRET=your_secret_key_here
|
||||
AUTH_SECRET=your_secret_key_here
|
||||
|
||||
# ADMIN BOOTSTRAP
|
||||
ADMIN_BOOTSTRAP_ENABLED=true
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_EMAIL=admin@localhost
|
||||
ADMIN_NAME=Administrator
|
||||
ADMIN_PASSWORD=change-me
|
||||
+1
-1
@@ -39,4 +39,4 @@ COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
EXPOSE ${PORT}
|
||||
|
||||
CMD ["bun", "run", "start"]
|
||||
CMD ["sh", "-c", "bun run db:deploy && bun run db:seed && bun run start"]
|
||||
@@ -37,6 +37,11 @@ services:
|
||||
DOMAIN: ${DOMAIN}
|
||||
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST}
|
||||
AUTH_SECRET: ${AUTH_SECRET}
|
||||
ADMIN_BOOTSTRAP_ENABLED: ${ADMIN_BOOTSTRAP_ENABLED:-"true"}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-"admin"}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-"admin@localhost"}
|
||||
ADMIN_NAME: ${ADMIN_NAME:-"Administrator"}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}?schema=public
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
+2
-1
@@ -16,6 +16,7 @@
|
||||
"db:migrate:reset": "bunx prisma migrate reset",
|
||||
"db:deploy": "bunx prisma migrate deploy",
|
||||
"db:generate": "bunx prisma generate",
|
||||
"db:seed": "bunx --bun prisma db seed",
|
||||
"db:studio": "bunx prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -68,4 +69,4 @@
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
seed: "bun ./prisma/seed.ts",
|
||||
},
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { getPasswordHash } from "@/lib/security"
|
||||
import prisma from "../src/lib/prisma"
|
||||
|
||||
type BootstrapAdminInput = {
|
||||
username: string
|
||||
email: string
|
||||
name: string
|
||||
password: string
|
||||
}
|
||||
|
||||
function getBootstrapAdminInput(): BootstrapAdminInput {
|
||||
const isProduction = process.env.NODE_ENV === "production"
|
||||
|
||||
const username = process.env.ADMIN_USERNAME ?? "admin"
|
||||
const email = process.env.ADMIN_EMAIL ?? "admin@localhost"
|
||||
const name = process.env.ADMIN_NAME ?? "Administrator"
|
||||
const password = process.env.ADMIN_PASSWORD
|
||||
|
||||
if (isProduction && !password) {
|
||||
throw new Error("ADMIN_PASSWORD is required to bootstrap an admin user")
|
||||
}
|
||||
|
||||
return {
|
||||
username,
|
||||
email,
|
||||
name,
|
||||
password: password ?? "admin",
|
||||
}
|
||||
}
|
||||
|
||||
export async function bootstrapAdmin(client: typeof prisma) {
|
||||
const enabled = process.env.ADMIN_BOOTSTRAP_ENABLED !== "false"
|
||||
const existingAdmin = await client.user.findFirst({
|
||||
where: {
|
||||
role: "ADMIN",
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (existingAdmin || !enabled) return
|
||||
|
||||
const admin = getBootstrapAdminInput()
|
||||
|
||||
await client.user.upsert({
|
||||
where: {
|
||||
email: admin.email,
|
||||
},
|
||||
update: {
|
||||
role: "ADMIN",
|
||||
isActive: true,
|
||||
},
|
||||
create: {
|
||||
name: admin.name,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
role: "ADMIN",
|
||||
password: await getPasswordHash(admin.password),
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await bootstrapAdmin(prisma)
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import prisma from "../src/lib/prisma"
|
||||
|
||||
import { bootstrapAdmin } from "./bootstrap-admin"
|
||||
|
||||
async function main() {
|
||||
await bootstrapAdmin(prisma)
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
.catch(async (e) => {
|
||||
console.error(e)
|
||||
await prisma.$disconnect()
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { flattenError } from "zod"
|
||||
|
||||
import {
|
||||
type CreateUserFormType,
|
||||
createUserSchema,
|
||||
type ResetUserPasswordFormType,
|
||||
resetUserPasswordSchema,
|
||||
type SetUserActiveFormType,
|
||||
setUserActiveSchema,
|
||||
type UpdateUserFormType,
|
||||
updateUserSchema,
|
||||
} from "@/schemas/user.schema"
|
||||
import { requireRole } from "@/services/auth.service"
|
||||
import {
|
||||
createUserUseCase,
|
||||
resetUserPasswordUseCase,
|
||||
setUserActiveUseCase,
|
||||
updateUserUseCase,
|
||||
} from "@/use-cases/user.use-cases"
|
||||
|
||||
const USERS_PATH = "/admin/users"
|
||||
|
||||
export async function createUserAction(formData: CreateUserFormType) {
|
||||
await requireRole("ADMIN")
|
||||
|
||||
const validatedFields = createUserSchema.safeParse(formData)
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
success: false,
|
||||
errors: validatedFields.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createUserUseCase(validatedFields.data)
|
||||
|
||||
if (!result.success) {
|
||||
return result
|
||||
}
|
||||
|
||||
revalidatePath(USERS_PATH)
|
||||
|
||||
return { success: true, message: "User created successfully" }
|
||||
} catch (error) {
|
||||
console.error("Database error:", error)
|
||||
return { success: false, message: "Failed to create user" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserAction(formData: UpdateUserFormType) {
|
||||
const session = await requireRole("ADMIN")
|
||||
|
||||
const validatedFields = updateUserSchema.safeParse(formData)
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
success: false,
|
||||
errors: flattenError(validatedFields.error).fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateUserUseCase({
|
||||
...validatedFields.data,
|
||||
actorId: session.user.id,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return result
|
||||
}
|
||||
|
||||
revalidatePath(USERS_PATH)
|
||||
|
||||
return { success: true, message: "User updated successfully" }
|
||||
} catch (error) {
|
||||
console.error("Database error:", error)
|
||||
return { success: false, message: "Failed to update user" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function setUserActiveAction(formData: SetUserActiveFormType) {
|
||||
const session = await requireRole("ADMIN")
|
||||
|
||||
const validatedFields = setUserActiveSchema.safeParse(formData)
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
success: false,
|
||||
errors: flattenError(validatedFields.error).fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await setUserActiveUseCase({
|
||||
...validatedFields.data,
|
||||
actorId: session.user.id,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return result
|
||||
}
|
||||
|
||||
revalidatePath(USERS_PATH)
|
||||
|
||||
return { success: true, message: "User status updated successfully" }
|
||||
} catch (error) {
|
||||
console.error("Database error:", error)
|
||||
return { success: false, message: "Failed to update user status" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetUserPasswordAction(
|
||||
formData: ResetUserPasswordFormType,
|
||||
) {
|
||||
await requireRole("ADMIN")
|
||||
|
||||
const validatedFields = resetUserPasswordSchema.safeParse(formData)
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
success: false,
|
||||
errors: validatedFields.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await resetUserPasswordUseCase(validatedFields.data)
|
||||
|
||||
if (!result.success) {
|
||||
return result
|
||||
}
|
||||
|
||||
revalidatePath(USERS_PATH)
|
||||
|
||||
return { success: true, message: "Password reset successfully" }
|
||||
} catch (error) {
|
||||
console.error("Database error:", error)
|
||||
return { success: false, message: "Failed to reset password" }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const userRoleSchema = z.enum(["ADMIN", "MANAGER", "STAFF", "VIEWER"])
|
||||
|
||||
const passwordSchema = z
|
||||
.string()
|
||||
.min(8, { error: "Password must be at least 8 characters" })
|
||||
|
||||
export const createUserSchema = z.object({
|
||||
username: z.string().trim().min(1, { error: "Username is required" }),
|
||||
name: z.string().trim().min(1, { error: "Name is required" }),
|
||||
email: z.email({ error: "Invalid email" }),
|
||||
password: passwordSchema,
|
||||
role: userRoleSchema,
|
||||
isActive: z.boolean(),
|
||||
})
|
||||
|
||||
export const updateUserSchema = z.object({
|
||||
id: z.string().min(1, { error: "User id is required" }),
|
||||
username: z.string().trim().min(1, { error: "Username is required" }),
|
||||
name: z.string().trim().min(1, { error: "Name is required" }),
|
||||
email: z.email({ error: "Invalid email" }),
|
||||
role: userRoleSchema,
|
||||
isActive: z.boolean(),
|
||||
})
|
||||
|
||||
export const setUserActiveSchema = z.object({
|
||||
id: z.string().min(1, { error: "User id is required" }),
|
||||
isActive: z.boolean(),
|
||||
})
|
||||
|
||||
export const resetUserPasswordSchema = z.object({
|
||||
id: z.string().min(1, { error: "User id is required" }),
|
||||
password: passwordSchema,
|
||||
})
|
||||
|
||||
export type CreateUserFormType = z.infer<typeof createUserSchema>
|
||||
export type UpdateUserFormType = z.infer<typeof updateUserSchema>
|
||||
export type SetUserActiveFormType = z.infer<typeof setUserActiveSchema>
|
||||
export type ResetUserPasswordFormType = z.infer<typeof resetUserPasswordSchema>
|
||||
@@ -1,10 +1,48 @@
|
||||
import { UserRole } from "@/generated/prisma/client"
|
||||
import { type Prisma, UserRole } from "@/generated/prisma/client"
|
||||
import { paginate } from "@/lib/paginate"
|
||||
import prisma from "@/lib/prisma"
|
||||
import { getPasswordHash } from "@/lib/security"
|
||||
import type { User } from "@/lib/types/user"
|
||||
import type { PaginatedResult } from "@/types"
|
||||
import type { User } from "@/types/user"
|
||||
|
||||
export async function createUser({ data }: { data: User }) {
|
||||
const newUser = await prisma.user.create({
|
||||
const userWithoutPasswordSelect = {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} satisfies Prisma.UserSelect
|
||||
|
||||
export type UserWithoutPassword = Prisma.UserGetPayload<{
|
||||
select: typeof userWithoutPasswordSelect
|
||||
}>
|
||||
|
||||
type CreateUserData = Pick<User, "username" | "name" | "email" | "password"> & {
|
||||
role?: UserRole
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
type UpdateUserData = Partial<Pick<User, "username" | "name" | "email">> & {
|
||||
role?: UserRole
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
type GetUsersParams = {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
search?: string
|
||||
role?: UserRole
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export async function createUser(
|
||||
{ data }: { data: CreateUserData },
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
) {
|
||||
const newUser = await db.user.create({
|
||||
data: {
|
||||
username: data.username,
|
||||
name: data.name,
|
||||
@@ -13,14 +51,118 @@ export async function createUser({ data }: { data: User }) {
|
||||
role: data.role ?? UserRole.STAFF,
|
||||
isActive: data.isActive ?? true,
|
||||
},
|
||||
select: userWithoutPasswordSelect,
|
||||
})
|
||||
return newUser
|
||||
}
|
||||
|
||||
export async function getUserById(id: string) {
|
||||
return await prisma.user.findUnique({ where: { id } })
|
||||
export async function getUserById(
|
||||
id: string,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
) {
|
||||
return await db.user.findUnique({ where: { id } })
|
||||
}
|
||||
|
||||
export async function getUserByUsername(username: string) {
|
||||
return await prisma.user.findUnique({ where: { username } })
|
||||
export async function getUserProfileById(
|
||||
id: string,
|
||||
): Promise<UserWithoutPassword | null> {
|
||||
return prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: userWithoutPasswordSelect,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getUserByUsername(
|
||||
username: string,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
) {
|
||||
return await db.user.findUnique({ where: { username } })
|
||||
}
|
||||
|
||||
export async function getUserByEmail(
|
||||
email: string,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
) {
|
||||
return await db.user.findUnique({ where: { email } })
|
||||
}
|
||||
|
||||
export async function getUsers({
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
role,
|
||||
isActive,
|
||||
}: GetUsersParams = {}): Promise<PaginatedResult<UserWithoutPassword>> {
|
||||
return paginate<UserWithoutPassword>({
|
||||
model: prisma.user,
|
||||
page,
|
||||
pageSize,
|
||||
where: {
|
||||
...(typeof isActive === "boolean" ? { isActive } : {}),
|
||||
...(role ? { role } : {}),
|
||||
...(search
|
||||
? {
|
||||
OR: [
|
||||
{ username: { contains: search, mode: "insensitive" } },
|
||||
{ name: { contains: search, mode: "insensitive" } },
|
||||
{ email: { contains: search, mode: "insensitive" } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: userWithoutPasswordSelect,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateUser(
|
||||
id: string,
|
||||
data: UpdateUserData,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<UserWithoutPassword> {
|
||||
return db.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
select: userWithoutPasswordSelect,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateUserRole(
|
||||
id: string,
|
||||
role: UserRole,
|
||||
): Promise<UserWithoutPassword> {
|
||||
return updateUser(id, { role })
|
||||
}
|
||||
|
||||
export async function setUserActive(
|
||||
id: string,
|
||||
isActive: boolean,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<UserWithoutPassword> {
|
||||
return updateUser(id, { isActive }, db)
|
||||
}
|
||||
|
||||
export async function resetUserPassword(
|
||||
id: string,
|
||||
password: string,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<UserWithoutPassword> {
|
||||
return db.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
password: await getPasswordHash(password),
|
||||
},
|
||||
select: userWithoutPasswordSelect,
|
||||
})
|
||||
}
|
||||
|
||||
export async function countActiveAdmins(
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<number> {
|
||||
return db.user.count({
|
||||
where: {
|
||||
role: UserRole.ADMIN,
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type {
|
||||
Assignment,
|
||||
Asset as PrismaAsset,
|
||||
ItemStatus as PrismaItemStatus,
|
||||
} from "@/generated/prisma/client"
|
||||
|
||||
export type Asset = PrismaAsset
|
||||
|
||||
export type ItemStatus = PrismaItemStatus
|
||||
|
||||
export type UpdateAssetStatus = PrismaItemStatus
|
||||
|
||||
export type AssetWithAssignment = Asset & {
|
||||
assignment: Assignment | null
|
||||
}
|
||||
|
||||
export type AssetWithItemAndCategory = {
|
||||
id: string
|
||||
serialNumber: string
|
||||
deliveryNote?: string | null
|
||||
status: ItemStatus
|
||||
item: {
|
||||
id: string
|
||||
name: string
|
||||
category: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
} | null
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Assignment as PrismaAssignment } from "@/generated/prisma/client"
|
||||
|
||||
import type { Asset } from "./asset"
|
||||
import type { Item } from "./item"
|
||||
import type { Recipient } from "./recipient"
|
||||
|
||||
export type Assignment = PrismaAssignment
|
||||
|
||||
export type AssignmentSummary = Pick<Assignment, "id" | "quantity">
|
||||
|
||||
export type AssignmentWithRecipientItemAsset = Assignment & {
|
||||
returnDate: Date | null
|
||||
recipient: Recipient | null
|
||||
item: Item | null
|
||||
asset: Asset | null
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Category as PrismaCategory } from "@/generated/prisma/client"
|
||||
|
||||
export type Category = PrismaCategory
|
||||
|
||||
export type CategorySummary = Pick<Category, "id" | "name">
|
||||
|
||||
export type CategoryWithItemsCount = CategorySummary & {
|
||||
_count: {
|
||||
items: number
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface ImportItem {
|
||||
name: string
|
||||
stock?: number
|
||||
serialNumber?: string
|
||||
categoryId?: string
|
||||
category?: string
|
||||
deliveryNote?: string
|
||||
assigned?: boolean
|
||||
username?: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from "./asset"
|
||||
export * from "./assignment"
|
||||
export * from "./category"
|
||||
export * from "./import"
|
||||
export * from "./item"
|
||||
export * from "./movement"
|
||||
export * from "./paginate"
|
||||
export * from "./recipient"
|
||||
export * from "./user"
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Category, Item as PrismaItem } from "@/generated/prisma/client"
|
||||
|
||||
export type Item = PrismaItem
|
||||
|
||||
export type ItemSummary = Pick<Item, "id" | "name" | "stock"> & {
|
||||
category: Pick<Category, "id" | "name">
|
||||
}
|
||||
|
||||
export type ItemWithoutStock = Pick<Item, "id" | "name">
|
||||
|
||||
export type ItemWithAssetCount = ItemSummary & {
|
||||
_count: {
|
||||
assets: number
|
||||
}
|
||||
}
|
||||
|
||||
export type ItemWithAssetAndMovementCount = ItemWithAssetCount & {
|
||||
_count: {
|
||||
movements: number
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { Movement as PrismaMovement } from "@/generated/prisma/client"
|
||||
|
||||
export type Movement = PrismaMovement
|
||||
@@ -0,0 +1,7 @@
|
||||
export type PaginatedResult<T> = {
|
||||
data: T[]
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
currentPage: number
|
||||
pageSize: number
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { Recipient as PrismaRecipient } from "@/generated/prisma/client"
|
||||
|
||||
export type Recipient = PrismaRecipient
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { User as PrismaUser } from "@/generated/prisma/client"
|
||||
|
||||
export type User = PrismaUser
|
||||
@@ -0,0 +1,241 @@
|
||||
import { Prisma } from "@/generated/prisma/client"
|
||||
import prisma from "@/lib/prisma"
|
||||
import type {
|
||||
CreateUserFormType,
|
||||
ResetUserPasswordFormType,
|
||||
SetUserActiveFormType,
|
||||
UpdateUserFormType,
|
||||
} from "@/schemas/user.schema"
|
||||
import {
|
||||
countActiveAdmins,
|
||||
createUser,
|
||||
getUserByEmail,
|
||||
getUserById,
|
||||
getUserByUsername,
|
||||
resetUserPassword,
|
||||
setUserActive,
|
||||
updateUser,
|
||||
} from "@/services/user.service"
|
||||
|
||||
type FieldErrors = Record<string, string[]>
|
||||
|
||||
type UserUseCaseResult =
|
||||
| {
|
||||
success: true
|
||||
}
|
||||
| {
|
||||
success: false
|
||||
errors: FieldErrors
|
||||
}
|
||||
|
||||
type ActorInput = {
|
||||
actorId: string
|
||||
}
|
||||
|
||||
function userError(errors: FieldErrors): UserUseCaseResult {
|
||||
return {
|
||||
success: false,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueErrorFor(error: unknown): FieldErrors | null {
|
||||
if (
|
||||
!(error instanceof Prisma.PrismaClientKnownRequestError) ||
|
||||
error.code !== "P2002"
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const target = Array.isArray(error.meta?.target) ? error.meta.target : []
|
||||
|
||||
if (target.includes("username")) {
|
||||
return { username: ["Username already exists"] }
|
||||
}
|
||||
|
||||
if (target.includes("email")) {
|
||||
return { email: ["Email already exists"] }
|
||||
}
|
||||
|
||||
return { username: ["Username already exists"] }
|
||||
}
|
||||
|
||||
function isTransactionConflictError(error: unknown) {
|
||||
return (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === "P2034"
|
||||
)
|
||||
}
|
||||
|
||||
async function runSerializableUserTransaction<T>(
|
||||
callback: (tx: Prisma.TransactionClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const maxAttempts = 3
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await prisma.$transaction(callback, {
|
||||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||||
})
|
||||
} catch (error) {
|
||||
if (attempt < maxAttempts && isTransactionConflictError(error)) {
|
||||
continue
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Serializable transaction retry exhausted")
|
||||
}
|
||||
|
||||
async function getAdminAccessLossError(
|
||||
userId: string,
|
||||
db: Prisma.TransactionClient,
|
||||
): Promise<string | null> {
|
||||
const user = await getUserById(userId, db)
|
||||
|
||||
if (!user) {
|
||||
return "User not found"
|
||||
}
|
||||
|
||||
if (
|
||||
user.role === "ADMIN" &&
|
||||
user.isActive &&
|
||||
(await countActiveAdmins(db)) <= 1
|
||||
) {
|
||||
return "Cannot remove access from the last active administrator"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function createUserUseCase(
|
||||
input: CreateUserFormType,
|
||||
): Promise<UserUseCaseResult> {
|
||||
const { username, email } = input
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
if (await getUserByUsername(username, tx)) {
|
||||
return userError({ username: ["Username already exists"] })
|
||||
}
|
||||
|
||||
if (await getUserByEmail(email, tx)) {
|
||||
return userError({ email: ["Email already exists"] })
|
||||
}
|
||||
|
||||
await createUser({ data: input }, tx)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const errors = uniqueErrorFor(error)
|
||||
|
||||
if (errors) {
|
||||
return userError(errors)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserUseCase(
|
||||
input: UpdateUserFormType & ActorInput,
|
||||
): Promise<UserUseCaseResult> {
|
||||
const { actorId, id, username, email, role, isActive, ...data } = input
|
||||
|
||||
try {
|
||||
return await runSerializableUserTransaction(async (tx) => {
|
||||
const existingUser = await getUserById(id, tx)
|
||||
|
||||
if (!existingUser) {
|
||||
return userError({ id: ["User not found"] })
|
||||
}
|
||||
|
||||
if (actorId === id && (!isActive || role !== "ADMIN")) {
|
||||
return userError({
|
||||
id: ["You cannot remove your own administrator access"],
|
||||
})
|
||||
}
|
||||
|
||||
if ((existingUser.role === "ADMIN" && role !== "ADMIN") || !isActive) {
|
||||
const error = await getAdminAccessLossError(id, tx)
|
||||
if (error) return userError({ id: [error] })
|
||||
}
|
||||
|
||||
const existingUsername = await getUserByUsername(username, tx)
|
||||
if (existingUsername && existingUsername.id !== id) {
|
||||
return userError({ username: ["Username already exists"] })
|
||||
}
|
||||
|
||||
const existingEmail = await getUserByEmail(email, tx)
|
||||
if (existingEmail && existingEmail.id !== id) {
|
||||
return userError({ email: ["Email already exists"] })
|
||||
}
|
||||
|
||||
await updateUser(id, { ...data, username, email, role, isActive }, tx)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
const errors = uniqueErrorFor(error)
|
||||
|
||||
if (errors) {
|
||||
return userError(errors)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function setUserActiveUseCase(
|
||||
input: SetUserActiveFormType & ActorInput,
|
||||
): Promise<UserUseCaseResult> {
|
||||
const { actorId, id, isActive } = input
|
||||
|
||||
return runSerializableUserTransaction(async (tx) => {
|
||||
const existingUser = await getUserById(id, tx)
|
||||
|
||||
if (!existingUser) {
|
||||
return userError({ id: ["User not found"] })
|
||||
}
|
||||
|
||||
if (actorId === id && !isActive) {
|
||||
return userError({ id: ["You cannot deactivate your own user"] })
|
||||
}
|
||||
|
||||
if (!isActive) {
|
||||
const error = await getAdminAccessLossError(id, tx)
|
||||
if (error) return userError({ id: [error] })
|
||||
}
|
||||
|
||||
await setUserActive(id, isActive, tx)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function resetUserPasswordUseCase(
|
||||
input: ResetUserPasswordFormType,
|
||||
): Promise<UserUseCaseResult> {
|
||||
const { id, password } = input
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
if (!(await getUserById(id, tx))) {
|
||||
return userError({ id: ["User not found"] })
|
||||
}
|
||||
|
||||
await resetUserPassword(id, password, tx)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user