refactor: move unified Person+User form to /people/new, admin-only
This commit is contained in:
@@ -1,24 +1,5 @@
|
|||||||
import { getI18n } from "@/i18n/server"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
import NewUserForm from "../_components/new.user.form"
|
export default function NewUserPage() {
|
||||||
|
redirect("/people/new")
|
||||||
export default async function NewUserPage() {
|
|
||||||
const { dictionary } = await getI18n()
|
|
||||||
const copy = dictionary.admin.users
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<h1 className="text-2xl font-bold">{copy.new.title}</h1>
|
|
||||||
</div>
|
|
||||||
<NewUserForm
|
|
||||||
formCopy={copy.form}
|
|
||||||
schemaCopy={{ ...copy.schema, ...dictionary.inventory.people.schema }}
|
|
||||||
roleLabels={copy.roles}
|
|
||||||
departmentCopy={dictionary.inventory.people.departments}
|
|
||||||
fallbackCopy={dictionary.inventory.people.fallback}
|
|
||||||
submitButtonCopy={dictionary.common.submitButton}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import type { UseFormRegisterReturn } from "react-hook-form"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { createPersonUserAction } from "@/actions/person.actions"
|
||||||
|
import {
|
||||||
|
SubmitButton,
|
||||||
|
type SubmitButtonCopy,
|
||||||
|
} from "@/components/forms/submitButton"
|
||||||
|
import { PERSON_DEPARTMENTS } from "@/lib/constants"
|
||||||
|
import {
|
||||||
|
buildUnifiedCreateSchema,
|
||||||
|
type UnifiedCreateFormType,
|
||||||
|
type UnifiedSchemaCopy,
|
||||||
|
} from "@/schemas/user.schema"
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatPersonDepartment,
|
||||||
|
type PersonDepartmentCopy,
|
||||||
|
type PersonFallbackCopy,
|
||||||
|
type UserFormCopy,
|
||||||
|
type UserRoleCopy,
|
||||||
|
} from "./user.copy"
|
||||||
|
|
||||||
|
export default function NewUserForm({
|
||||||
|
formCopy,
|
||||||
|
schemaCopy,
|
||||||
|
roleLabels,
|
||||||
|
departmentCopy,
|
||||||
|
fallbackCopy,
|
||||||
|
submitButtonCopy,
|
||||||
|
}: {
|
||||||
|
formCopy: UserFormCopy
|
||||||
|
schemaCopy: UnifiedSchemaCopy
|
||||||
|
roleLabels: UserRoleCopy
|
||||||
|
departmentCopy: PersonDepartmentCopy
|
||||||
|
fallbackCopy: PersonFallbackCopy
|
||||||
|
submitButtonCopy: SubmitButtonCopy
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const schema = useMemo(
|
||||||
|
() => buildUnifiedCreateSchema(schemaCopy),
|
||||||
|
[schemaCopy],
|
||||||
|
)
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setError,
|
||||||
|
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||||
|
} = useForm<UnifiedCreateFormType>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
role: "STAFF",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedRole = watch("role")
|
||||||
|
const showPassword = selectedRole !== "NO_USER"
|
||||||
|
|
||||||
|
const onSubmit = async (formData: UnifiedCreateFormType) => {
|
||||||
|
const response = await createPersonUserAction(formData)
|
||||||
|
|
||||||
|
if (response?.errors) {
|
||||||
|
Object.entries(response.errors).forEach(([fieldName, messages]) => {
|
||||||
|
messages.forEach((message: string) => {
|
||||||
|
setError(fieldName as keyof UnifiedCreateFormType, {
|
||||||
|
type: "server",
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
toast.error(message)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response?.success) {
|
||||||
|
toast.success(response.message)
|
||||||
|
router.push("/people")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<UserTextInput
|
||||||
|
error={errors.firstName?.message}
|
||||||
|
id="firstName"
|
||||||
|
label={formCopy.firstNameLabel}
|
||||||
|
placeholder={formCopy.firstNamePlaceholder}
|
||||||
|
register={register("firstName")}
|
||||||
|
/>
|
||||||
|
<UserTextInput
|
||||||
|
error={errors.lastName?.message}
|
||||||
|
id="lastName"
|
||||||
|
label={formCopy.lastNameLabel}
|
||||||
|
placeholder={formCopy.lastNamePlaceholder}
|
||||||
|
register={register("lastName")}
|
||||||
|
/>
|
||||||
|
<DepartmentSelect
|
||||||
|
error={errors.department?.message}
|
||||||
|
formCopy={formCopy}
|
||||||
|
departmentCopy={departmentCopy}
|
||||||
|
fallbackCopy={fallbackCopy}
|
||||||
|
register={register("department")}
|
||||||
|
/>
|
||||||
|
<UserTextInput
|
||||||
|
error={errors.email?.message}
|
||||||
|
id="email"
|
||||||
|
label={formCopy.emailLabel}
|
||||||
|
placeholder={formCopy.emailPlaceholder}
|
||||||
|
register={register("email")}
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
<UserTextInput
|
||||||
|
error={errors.phone?.message}
|
||||||
|
id="phone"
|
||||||
|
label={formCopy.phoneLabel}
|
||||||
|
placeholder={formCopy.phonePlaceholder}
|
||||||
|
register={register("phone")}
|
||||||
|
/>
|
||||||
|
<RoleSelect
|
||||||
|
register={register("role")}
|
||||||
|
roleLabel={formCopy.roleLabel}
|
||||||
|
roleLabels={roleLabels}
|
||||||
|
/>
|
||||||
|
{showPassword && (
|
||||||
|
<UserTextInput
|
||||||
|
error={errors.password?.message}
|
||||||
|
id="password"
|
||||||
|
label={formCopy.passwordLabel}
|
||||||
|
placeholder={formCopy.passwordPlaceholder}
|
||||||
|
register={register("password")}
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SubmitButton
|
||||||
|
copy={submitButtonCopy}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
isSubmitSuccessful={isSubmitSuccessful}
|
||||||
|
>
|
||||||
|
{formCopy.createSubmit}
|
||||||
|
</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,
|
||||||
|
roleLabel,
|
||||||
|
roleLabels,
|
||||||
|
}: {
|
||||||
|
register: UseFormRegisterReturn
|
||||||
|
roleLabel: string
|
||||||
|
roleLabels: UserRoleCopy
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="role" className="mb-2 block text-lg">
|
||||||
|
{roleLabel}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="role"
|
||||||
|
{...register}
|
||||||
|
className="w-full rounded-lg border px-4 py-2"
|
||||||
|
>
|
||||||
|
<option value="ADMIN">{roleLabels.ADMIN}</option>
|
||||||
|
<option value="MANAGER">{roleLabels.MANAGER}</option>
|
||||||
|
<option value="STAFF">{roleLabels.STAFF}</option>
|
||||||
|
<option value="VIEWER">{roleLabels.VIEWER}</option>
|
||||||
|
<option value="NO_USER">{roleLabels.NO_USER}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DepartmentSelect({
|
||||||
|
error,
|
||||||
|
formCopy,
|
||||||
|
departmentCopy,
|
||||||
|
fallbackCopy,
|
||||||
|
register,
|
||||||
|
}: {
|
||||||
|
error?: string
|
||||||
|
formCopy: UserFormCopy
|
||||||
|
departmentCopy: PersonDepartmentCopy
|
||||||
|
fallbackCopy: PersonFallbackCopy
|
||||||
|
register: UseFormRegisterReturn
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="department" className="mb-2 block text-lg">
|
||||||
|
{formCopy.departmentLabel}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="department"
|
||||||
|
{...register}
|
||||||
|
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
|
||||||
|
>
|
||||||
|
<option value="">{formCopy.departmentPlaceholder}</option>
|
||||||
|
{Object.keys(PERSON_DEPARTMENTS).map((department) => (
|
||||||
|
<option key={department} value={department}>
|
||||||
|
{formatPersonDepartment(department, departmentCopy, fallbackCopy)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{error && <p className="text-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Dictionary } from "@/i18n/dictionaries"
|
||||||
|
|
||||||
|
export type UserFormCopy = Dictionary["admin"]["users"]["form"]
|
||||||
|
export type UserRoleCopy = Dictionary["admin"]["users"]["roles"]
|
||||||
|
export type UserStatusCopy = Dictionary["admin"]["users"]["status"]
|
||||||
|
export type UserFallbackCopy = Dictionary["admin"]["users"]["fallback"]
|
||||||
|
export type UserResetPasswordCopy =
|
||||||
|
Dictionary["admin"]["users"]["resetPassword"]
|
||||||
|
export type PersonDepartmentCopy =
|
||||||
|
Dictionary["inventory"]["people"]["departments"]
|
||||||
|
export type PersonFallbackCopy = Dictionary["inventory"]["people"]["fallback"]
|
||||||
|
|
||||||
|
export function formatUserRole(
|
||||||
|
role: string,
|
||||||
|
roleCopy: UserRoleCopy,
|
||||||
|
fallbackCopy: UserFallbackCopy,
|
||||||
|
): string {
|
||||||
|
return role in roleCopy
|
||||||
|
? roleCopy[role as keyof UserRoleCopy]
|
||||||
|
: fallbackCopy.unknownRole
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPersonDepartment(
|
||||||
|
department: string | null | undefined,
|
||||||
|
departmentCopy: PersonDepartmentCopy,
|
||||||
|
fallbackCopy: PersonFallbackCopy,
|
||||||
|
): string {
|
||||||
|
if (!department) {
|
||||||
|
return fallbackCopy.unknownDepartment
|
||||||
|
}
|
||||||
|
|
||||||
|
return department in departmentCopy
|
||||||
|
? departmentCopy[department as keyof PersonDepartmentCopy]
|
||||||
|
: fallbackCopy.unknownDepartment
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
|
import { requireRole } from "@/services/auth.service"
|
||||||
|
|
||||||
|
export default async function PeopleLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
await requireRole("ADMIN")
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -1,5 +1,24 @@
|
|||||||
import { redirect } from "next/navigation"
|
import { getI18n } from "@/i18n/server"
|
||||||
|
|
||||||
export default function NewPersonPage() {
|
import NewPersonForm from "../_components/new.person.form"
|
||||||
redirect("/admin/users/new")
|
|
||||||
|
export default async function NewUserPage() {
|
||||||
|
const { dictionary } = await getI18n()
|
||||||
|
const copy = dictionary.admin.users
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h1 className="text-2xl font-bold">{copy.new.title}</h1>
|
||||||
|
</div>
|
||||||
|
<NewPersonForm
|
||||||
|
formCopy={copy.form}
|
||||||
|
schemaCopy={{ ...copy.schema, ...dictionary.inventory.people.schema }}
|
||||||
|
roleLabels={copy.roles}
|
||||||
|
departmentCopy={dictionary.inventory.people.departments}
|
||||||
|
fallbackCopy={dictionary.inventory.people.fallback}
|
||||||
|
submitButtonCopy={dictionary.common.submitButton}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const items: { key: keyof AddMenuCopy; href: string }[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "person",
|
key: "person",
|
||||||
href: "/admin/users/new",
|
href: "/people/new",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "assignment",
|
key: "assignment",
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ export const en = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
new: {
|
new: {
|
||||||
title: "New User",
|
title: "New Person",
|
||||||
},
|
},
|
||||||
edit: {
|
edit: {
|
||||||
title: "Edit User",
|
title: "Edit User",
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ export const es = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
new: {
|
new: {
|
||||||
title: "Nuevo usuario",
|
title: "Nueva persona",
|
||||||
},
|
},
|
||||||
edit: {
|
edit: {
|
||||||
title: "Editar usuario",
|
title: "Editar usuario",
|
||||||
|
|||||||
@@ -44,16 +44,6 @@ describe("person pages", () => {
|
|||||||
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
||||||
})
|
})
|
||||||
|
|
||||||
it("redirects /people/new to /admin/users/new", async () => {
|
|
||||||
const { default: NewPersonPage } = await import(
|
|
||||||
"@/app/(dashboard)/people/new/page"
|
|
||||||
)
|
|
||||||
|
|
||||||
await NewPersonPage()
|
|
||||||
|
|
||||||
expect(mocks.redirect).toHaveBeenCalledWith("/admin/users/new")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("renders the edit person page with Person heading and no username", async () => {
|
it("renders the edit person page with Person heading and no username", async () => {
|
||||||
const { default: PersonEditPage } = await import(
|
const { default: PersonEditPage } = await import(
|
||||||
"@/app/(dashboard)/people/[personId]/edit/page"
|
"@/app/(dashboard)/people/[personId]/edit/page"
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ describe("unified creation form page", () => {
|
|||||||
|
|
||||||
it("renders unified form with Person fields, email, password, role, and NO_USER option in Spanish", async () => {
|
it("renders unified form with Person fields, email, password, role, and NO_USER option in Spanish", async () => {
|
||||||
const { default: NewUserPage } = await import(
|
const { default: NewUserPage } = await import(
|
||||||
"@/app/(dashboard)/admin/users/new/page"
|
"@/app/(dashboard)/people/new/page"
|
||||||
)
|
)
|
||||||
|
|
||||||
const html = renderToStaticMarkup(await NewUserPage())
|
const html = renderToStaticMarkup(await NewUserPage())
|
||||||
@@ -78,7 +78,7 @@ describe("unified creation form page", () => {
|
|||||||
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
||||||
|
|
||||||
const { default: NewUserPage } = await import(
|
const { default: NewUserPage } = await import(
|
||||||
"@/app/(dashboard)/admin/users/new/page"
|
"@/app/(dashboard)/people/new/page"
|
||||||
)
|
)
|
||||||
|
|
||||||
const html = renderToStaticMarkup(await NewUserPage())
|
const html = renderToStaticMarkup(await NewUserPage())
|
||||||
@@ -100,7 +100,7 @@ describe("unified creation form page", () => {
|
|||||||
|
|
||||||
it("renders Person field placeholders from the unified form dictionary", async () => {
|
it("renders Person field placeholders from the unified form dictionary", async () => {
|
||||||
const { default: NewUserPage } = await import(
|
const { default: NewUserPage } = await import(
|
||||||
"@/app/(dashboard)/admin/users/new/page"
|
"@/app/(dashboard)/people/new/page"
|
||||||
)
|
)
|
||||||
|
|
||||||
const html = renderToStaticMarkup(await NewUserPage())
|
const html = renderToStaticMarkup(await NewUserPage())
|
||||||
@@ -114,7 +114,7 @@ describe("unified creation form page", () => {
|
|||||||
|
|
||||||
it("renders department select with all PERSON_DEPARTMENTS values", async () => {
|
it("renders department select with all PERSON_DEPARTMENTS values", async () => {
|
||||||
const { default: NewUserPage } = await import(
|
const { default: NewUserPage } = await import(
|
||||||
"@/app/(dashboard)/admin/users/new/page"
|
"@/app/(dashboard)/people/new/page"
|
||||||
)
|
)
|
||||||
|
|
||||||
const html = renderToStaticMarkup(await NewUserPage())
|
const html = renderToStaticMarkup(await NewUserPage())
|
||||||
|
|||||||
@@ -60,13 +60,13 @@ describe("new user form localization", () => {
|
|||||||
|
|
||||||
it("renders new user page with localized title and unified form labels in Spanish", async () => {
|
it("renders new user page with localized title and unified form labels in Spanish", async () => {
|
||||||
const { default: NewUserPage } = await import(
|
const { default: NewUserPage } = await import(
|
||||||
"@/app/(dashboard)/admin/users/new/page"
|
"@/app/(dashboard)/people/new/page"
|
||||||
)
|
)
|
||||||
|
|
||||||
const html = renderToStaticMarkup(await NewUserPage())
|
const html = renderToStaticMarkup(await NewUserPage())
|
||||||
|
|
||||||
// Title
|
// Title
|
||||||
expect(html).toContain("Nuevo usuario")
|
expect(html).toContain("Nueva persona")
|
||||||
|
|
||||||
// Person field labels
|
// Person field labels
|
||||||
expect(html).toContain("Nombre")
|
expect(html).toContain("Nombre")
|
||||||
@@ -94,12 +94,12 @@ describe("new user form localization", () => {
|
|||||||
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
||||||
|
|
||||||
const { default: NewUserPage } = await import(
|
const { default: NewUserPage } = await import(
|
||||||
"@/app/(dashboard)/admin/users/new/page"
|
"@/app/(dashboard)/people/new/page"
|
||||||
)
|
)
|
||||||
|
|
||||||
const html = renderToStaticMarkup(await NewUserPage())
|
const html = renderToStaticMarkup(await NewUserPage())
|
||||||
|
|
||||||
expect(html).toContain("New User")
|
expect(html).toContain("New Person")
|
||||||
|
|
||||||
// Person fields
|
// Person fields
|
||||||
expect(html).toContain("First Name")
|
expect(html).toContain("First Name")
|
||||||
@@ -122,7 +122,7 @@ describe("new user form localization", () => {
|
|||||||
|
|
||||||
it("keeps canonical role values in option value attributes including NO_USER, not localized labels", async () => {
|
it("keeps canonical role values in option value attributes including NO_USER, not localized labels", async () => {
|
||||||
const { default: NewUserPage } = await import(
|
const { default: NewUserPage } = await import(
|
||||||
"@/app/(dashboard)/admin/users/new/page"
|
"@/app/(dashboard)/people/new/page"
|
||||||
)
|
)
|
||||||
|
|
||||||
const html = renderToStaticMarkup(await NewUserPage())
|
const html = renderToStaticMarkup(await NewUserPage())
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe("admin users dictionary", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(users.new).toEqual({ title: "New User" })
|
expect(users.new).toEqual({ title: "New Person" })
|
||||||
expect(users.edit).toEqual({ title: "Edit User" })
|
expect(users.edit).toEqual({ title: "Edit User" })
|
||||||
|
|
||||||
expect(users.form).toEqual({
|
expect(users.form).toEqual({
|
||||||
@@ -114,7 +114,7 @@ describe("admin users dictionary", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(users.new).toEqual({ title: "Nuevo usuario" })
|
expect(users.new).toEqual({ title: "Nueva persona" })
|
||||||
expect(users.edit).toEqual({ title: "Editar usuario" })
|
expect(users.edit).toEqual({ title: "Editar usuario" })
|
||||||
|
|
||||||
expect(users.form).toEqual({
|
expect(users.form).toEqual({
|
||||||
|
|||||||
Reference in New Issue
Block a user