refactor: consolidate admin/users management under /people

This commit is contained in:
2026-06-17 09:32:26 +02:00
parent 4f370eee70
commit d6b42d78e7
31 changed files with 1928 additions and 855 deletions
+46
View File
@@ -11,12 +11,16 @@ import {
} from "@/schemas/person.schema"
import {
buildUnifiedCreateSchema,
buildUnifiedUpdateSchema,
type UnifiedCreateFormType,
type UnifiedSchemaCopy,
type UnifiedUpdateFormType,
} from "@/schemas/user.schema"
import {
createPersonUseCase,
createPersonUserUseCase,
updatePersonUseCase,
updatePersonUserUseCase,
} from "@/use-cases/person.use-cases"
import { localizePersonFieldErrors } from "./person.messages"
@@ -144,3 +148,45 @@ export async function updatePerson(formData: UpdatePersonFormType) {
}
}
}
export async function updatePersonUserAction(formData: UnifiedUpdateFormType) {
const { dictionary } = await getI18n()
const userCopy = dictionary.admin.users
const personCopy = dictionary.inventory.people
const schemaCopy: UnifiedSchemaCopy = {
...userCopy.schema,
...personCopy.schema,
}
const validatedFields =
buildUnifiedUpdateSchema(schemaCopy).safeParse(formData)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
try {
const result = await updatePersonUserUseCase(validatedFields.data)
if (!result.success) {
return {
...result,
errors: localizeUnifiedCreateFieldErrors(
result.errors,
userCopy.actions,
schemaCopy,
),
message: personCopy.actions.updateFailure,
}
}
revalidatePath("/people")
return { success: true, message: personCopy.actions.updateSuccess }
} catch (error) {
console.error("Database error:", error)
return { success: false, message: personCopy.actions.updateFailure }
}
}
@@ -1,10 +1,6 @@
import { notFound } from "next/navigation"
import { redirect } from "next/navigation"
import { getI18n } from "@/i18n/server"
import { getUserProfileById } from "@/services/user.service"
import EditUserForm from "../../_components/edit.user.form"
import ResetUserPasswordForm from "../../_components/reset.user.password.form"
import prisma from "@/lib/prisma"
export default async function EditUserPage({
params,
@@ -12,35 +8,15 @@ export default async function EditUserPage({
params: Promise<{ userId: string }>
}) {
const { userId } = await params
const user = await getUserProfileById(userId)
const { dictionary } = await getI18n()
const copy = dictionary.admin.users
if (!user) {
notFound()
const person = await prisma.person.findFirst({
where: { userId },
select: { id: true },
})
if (!person) {
redirect("/people")
}
return (
<div className="flex flex-col gap-8">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">{copy.edit.title}</h1>
</div>
<EditUserForm
formCopy={copy.form}
schemaCopy={copy.schema}
roleLabels={copy.roles}
submitButtonCopy={dictionary.common.submitButton}
user={user}
/>
<section className="flex flex-col gap-4 border-t pt-6">
<h2 className="text-xl font-semibold">{copy.resetPassword.title}</h2>
<ResetUserPasswordForm
formCopy={copy.resetPassword}
schemaCopy={copy.schema}
submitButtonCopy={dictionary.common.submitButton}
userId={user.id}
/>
</section>
</div>
)
redirect(`/people/${person.id}/edit`)
}
+3 -109
View File
@@ -1,111 +1,5 @@
import { Pencil } from "lucide-react"
import Link from "next/link"
import { redirect } from "next/navigation"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { getUsers } from "@/services/user.service"
import {
formatUserRole,
type UserFallbackCopy,
type UserRoleCopy,
type UserStatusCopy,
} from "./_components/user.copy"
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,
})
const { dictionary } = await getI18n()
const copy = dictionary.admin.users
return (
<div className="flex flex-col gap-4">
<PageHeader
title={copy.list.title}
addLabel={copy.list.addLabel}
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">
{copy.list.empty}
</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">
{copy.list.columns.name}
</th>
<th scope="col" className="p-4">
{copy.list.columns.email}
</th>
<th scope="col" className="p-4">
{copy.list.columns.role}
</th>
<th scope="col" className="p-4">
{copy.list.columns.status}
</th>
<th scope="col" className="p-4">
{copy.list.columns.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.email}</td>
<td className="p-4">
{formatUserRole(
user.role,
copy.roles as UserRoleCopy,
copy.fallback as UserFallbackCopy,
)}
</td>
<td className="p-4">
{user.isActive
? (copy.status as UserStatusCopy).active
: (copy.status as UserStatusCopy).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>
)
export default function UsersPage() {
redirect("/people")
}
+1 -6
View File
@@ -4,22 +4,17 @@ import Navbar from "@/components/layout/navbar"
import AppSidebar from "@/components/layout/sidebar"
import { SidebarProvider } from "@/components/ui/sidebar"
import { getI18n } from "@/i18n/server"
import { auth } from "@/lib/auth"
export default async function LayoutDashboard({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
const { dictionary } = await getI18n()
return (
<SidebarProvider>
<AppSidebar
copy={dictionary.layout.sidebar}
userRole={session?.user.role}
/>
<AppSidebar copy={dictionary.layout.sidebar} />
<main className="w-full">
<Navbar />
<div className="flex-1 p-6">{children}</div>
@@ -1,7 +1,7 @@
import { getI18n } from "@/i18n/server"
import { PersonService } from "@/services/person.service"
import PersonForm from "../../_components/person.form"
import EditPersonForm from "../../_components/edit.person.form"
export default async function PersonEditPage({
params,
@@ -10,25 +10,27 @@ export default async function PersonEditPage({
}) {
const { personId } = await params
const { dictionary } = await getI18n()
const copy = dictionary.inventory.people
const person = await PersonService.findById(personId)
const personCopy = dictionary.inventory.people
const userCopy = dictionary.admin.users
const person = await PersonService.findByIdWithUser(personId)
if (!person) {
return <div>{copy.edit.notFound}</div>
return <div>{personCopy.edit.notFound}</div>
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">{copy.edit.title}</h1>
<h1 className="text-2xl font-bold">{personCopy.edit.title}</h1>
</div>
<PersonForm
initialData={person}
mode="edit"
formCopy={copy.form}
schemaCopy={copy.schema}
departmentCopy={copy.departments}
fallbackCopy={copy.fallback}
<EditPersonForm
person={person}
formCopy={userCopy.form}
schemaCopy={{ ...userCopy.schema, ...personCopy.schema }}
roleLabels={userCopy.roles}
userFallbackCopy={userCopy.fallback}
departmentCopy={personCopy.departments}
fallbackCopy={personCopy.fallback}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
+38 -1
View File
@@ -4,6 +4,11 @@ import { AssignmentService } from "@/services/assignment.service"
import { PersonService } from "@/services/person.service"
import { formatPersonDepartment } from "../_components/person.copy"
import {
formatUserRole,
type UserFallbackCopy,
type UserRoleCopy,
} from "../_components/user.copy"
export default async function PersonInfoPage({
params,
@@ -14,7 +19,8 @@ export default async function PersonInfoPage({
const { dictionary } = await getI18n()
const copy = dictionary.inventory.people
const assignmentCopy = dictionary.inventory.assignments
const person = await PersonService.findById(personId)
const userCopy = dictionary.admin.users
const person = await PersonService.findByIdWithUser(personId)
const assignments = await AssignmentService.findAllByPerson(personId)
if (!person) {
@@ -49,6 +55,37 @@ export default async function PersonInfoPage({
)}
</span>
</div>
{person.user ? (
<>
<div className="flex justify-between">
<span className="text-gray-600">
{copy.detail.labels.role}
</span>
<span>
{formatUserRole(
person.user.role,
userCopy.roles as UserRoleCopy,
userCopy.fallback as UserFallbackCopy,
)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">
{copy.detail.labels.status}
</span>
<span>
{person.user.isActive
? userCopy.status.active
: userCopy.status.inactive}
</span>
</div>
</>
) : (
<div className="col-span-2 flex justify-between">
<span className="text-gray-600">{copy.detail.labels.role}</span>
<span>{copy.detail.labels.noUser}</span>
</div>
)}
</div>
</CardContent>
</Card>
@@ -0,0 +1,274 @@
"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 { updatePersonUserAction } from "@/actions/person.actions"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import { PERSON_DEPARTMENTS } from "@/lib/constants"
import {
buildUnifiedUpdateSchema,
type UnifiedSchemaCopy,
type UnifiedUpdateFormType,
} from "@/schemas/user.schema"
import type { PersonWithUser } from "@/services/person.service"
import {
formatPersonDepartment,
formatUserRole,
type PersonDepartmentCopy,
type PersonFallbackCopy,
type UserFallbackCopy,
type UserFormCopy,
type UserRoleCopy,
} from "./user.copy"
export default function EditPersonForm({
person,
formCopy,
schemaCopy,
roleLabels,
userFallbackCopy,
departmentCopy,
fallbackCopy,
submitButtonCopy,
}: {
person: PersonWithUser
formCopy: UserFormCopy
schemaCopy: UnifiedSchemaCopy
roleLabels: UserRoleCopy
userFallbackCopy: UserFallbackCopy
departmentCopy: PersonDepartmentCopy
fallbackCopy: PersonFallbackCopy
submitButtonCopy: SubmitButtonCopy
}) {
const router = useRouter()
const schema = useMemo(
() => buildUnifiedUpdateSchema(schemaCopy),
[schemaCopy],
)
const hasUser = Boolean(person.userId && person.user)
const user = person.user
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<UnifiedUpdateFormType>({
resolver: zodResolver(schema),
defaultValues: {
id: person.id,
firstName: person.firstName,
lastName: person.lastName,
department: person.department ?? "OTHER",
email: person.email ?? "",
phone: person.phone ?? "",
...(hasUser && user ? { role: user.role, isActive: user.isActive } : {}),
},
})
const onSubmit = async (formData: UnifiedUpdateFormType) => {
const response = await updatePersonUserAction(formData)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((message: string) => {
setError(fieldName as keyof UnifiedUpdateFormType, {
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)}>
<input type="hidden" {...register("id")} />
<TextInput
error={errors.firstName?.message}
id="firstName"
label={formCopy.firstNameLabel}
placeholder={formCopy.firstNamePlaceholder}
register={register("firstName")}
/>
<TextInput
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")}
/>
<TextInput
error={errors.email?.message}
id="email"
label={formCopy.emailLabel}
placeholder={formCopy.emailPlaceholder}
register={register("email")}
type="email"
/>
<TextInput
error={errors.phone?.message}
id="phone"
label={formCopy.phoneLabel}
placeholder={formCopy.phonePlaceholder}
register={register("phone")}
/>
{hasUser && (
<section
className="flex flex-col gap-4 border-t pt-4"
aria-labelledby="user-account-heading"
>
<h2 id="user-account-heading" className="text-xl font-semibold">
{formCopy.userAccountHeading}
</h2>
<RoleSelect
register={register("role")}
roleLabel={formCopy.roleLabel}
roleLabels={roleLabels}
/>
<label className="flex items-center gap-2">
<input type="checkbox" {...register("isActive")} />
{formCopy.activeLabel}
</label>
<TextInput
error={errors.password?.message}
id="password"
label={formCopy.newPasswordLabel}
placeholder={formCopy.newPasswordPlaceholder}
register={register("password")}
type="password"
/>
</section>
)}
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
{formCopy.updateSubmit}
</SubmitButton>
</form>
)
}
function TextInput({
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>
</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>
)
}
// Re-export for tests that need to verify the data shape passed to this form.
export { formatUserRole }
@@ -1,188 +0,0 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useRouter } from "next/navigation"
import { useMemo } from "react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { createNewPerson, updatePerson } from "@/actions/person.actions"
import {
SubmitButton,
type SubmitButtonCopy,
} from "@/components/forms/submitButton"
import { PERSON_DEPARTMENTS } from "@/lib/constants"
import {
buildCreatePersonSchema,
buildUpdatePersonSchema,
type CreatePersonFormType,
type PersonSchemaCopy,
type UpdatePersonFormType,
} from "@/schemas/person.schema"
import type { Person } from "@/types"
import {
formatPersonDepartment,
type PersonDepartmentCopy,
type PersonFallbackCopy,
type PersonFormCopy,
} from "./person.copy"
interface PersonFormProps {
initialData?: Person
mode?: "create" | "edit"
formCopy: PersonFormCopy
schemaCopy: PersonSchemaCopy
departmentCopy: PersonDepartmentCopy
fallbackCopy: PersonFallbackCopy
submitButtonCopy: SubmitButtonCopy
}
export default function PersonForm({
initialData,
mode = "create",
formCopy,
schemaCopy,
departmentCopy,
fallbackCopy,
submitButtonCopy,
}: PersonFormProps) {
const router = useRouter()
const schema = useMemo(
() =>
mode === "create"
? buildCreatePersonSchema(schemaCopy)
: buildUpdatePersonSchema(schemaCopy),
[mode, schemaCopy],
)
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<CreatePersonFormType>({
resolver: zodResolver(schema),
defaultValues: {
id: initialData?.id || "",
firstName: initialData?.firstName || "",
lastName: initialData?.lastName || "",
department: initialData?.department || "OTHER",
email: initialData?.email || "",
phone: initialData?.phone || "",
},
})
const onSubmit = async (formData: CreatePersonFormType) => {
const response =
mode === "create"
? await createNewPerson(formData)
: await updatePerson(formData as UpdatePersonFormType)
if (response?.errors) {
Object.entries(response.errors).forEach(([fieldName, messages]) => {
messages.forEach((msg: string) => {
setError(fieldName as keyof CreatePersonFormType, {
type: "server",
message: msg,
})
toast.error(msg)
})
})
return
}
if (response?.success) {
toast.success(response.message)
router.push("/people")
}
}
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} />
<div>
<label htmlFor="firstName" className="mb-2 block text-lg">
{formCopy.firstNameLabel}
</label>
<input
type="text"
id="firstName"
placeholder={formCopy.firstNamePlaceholder}
{...register("firstName")}
className={`w-full rounded-lg border px-4 py-2`}
/>
{errors?.firstName && (
<p className="text-error">{errors.firstName.message}</p>
)}
</div>
<div>
<label htmlFor="lastName" className="mb-2 block text-lg">
{formCopy.lastNameLabel}
</label>
<input
type="text"
id="lastName"
placeholder={formCopy.lastNamePlaceholder}
{...register("lastName")}
className={`w-full rounded-lg border px-4 py-2`}
/>
{errors?.lastName && (
<p className="text-error">{errors.lastName.message}</p>
)}
</div>
<div>
<label htmlFor="department" className="mb-2 block text-lg">
{formCopy.departmentLabel}
</label>
<select
id="department"
{...register("department")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">{formCopy.departmentPlaceholder}</option>
{Object.keys(PERSON_DEPARTMENTS).map((department) => (
<option key={department} value={department}>
{formatPersonDepartment(department, departmentCopy, fallbackCopy)}
</option>
))}
</select>
{errors?.department && (
<p className="text-error">{errors.department.message}</p>
)}
</div>
<div>
<label htmlFor="email" className="mb-2 block text-lg">
{formCopy.emailLabel}
</label>
<input
type="text"
id="email"
placeholder={formCopy.emailPlaceholder}
{...register("email")}
className={`w-full rounded-lg border px-4 py-2`}
/>
{errors?.email && <p className="text-error">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="phone" className="mb-2 block text-lg">
{formCopy.phoneLabel}
</label>
<input
type="text"
id="phone"
placeholder={formCopy.phonePlaceholder}
{...register("phone")}
className={`w-full rounded-lg border px-4 py-2`}
/>
{errors?.phone && <p className="text-error">{errors.phone.message}</p>}
</div>
<SubmitButton
copy={submitButtonCopy}
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
{mode === "create" ? formCopy.createSubmit : formCopy.updateSubmit}
</SubmitButton>
</form>
)
}
+42 -6
View File
@@ -4,11 +4,19 @@ import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import type { Person } from "@/generated/prisma/client"
import { getI18n } from "@/i18n/server"
import { PersonService } from "@/services/person.service"
import { formatPersonDepartment } from "./_components/person.copy"
import {
formatPersonDepartment,
type PersonDepartmentCopy,
type PersonFallbackCopy,
} from "./_components/person.copy"
import {
formatUserRole,
type UserFallbackCopy,
type UserRoleCopy,
} from "./_components/user.copy"
export default async function PeoplePage(props: {
searchParams?: Promise<{
@@ -26,6 +34,12 @@ export default async function PeoplePage(props: {
})
const { dictionary } = await getI18n()
const copy = dictionary.inventory.people
const userCopy = dictionary.admin.users
const userStatusCopy = userCopy.status
const userRoleLabels = userCopy.roles as UserRoleCopy
const userFallbackCopy = userCopy.fallback as UserFallbackCopy
const departmentCopy = copy.departments as PersonDepartmentCopy
const personFallbackCopy = copy.fallback as PersonFallbackCopy
return (
<div className="flex flex-col gap-4">
@@ -54,13 +68,19 @@ export default async function PeoplePage(props: {
<th scope="col" className="p-4">
{copy.list.columns.department}
</th>
<th scope="col" className="p-4">
{copy.list.columns.role}
</th>
<th scope="col" className="p-4">
{copy.list.columns.status}
</th>
<th scope="col" className="p-4">
{copy.list.columns.actions}
</th>
</tr>
</thead>
<tbody>
{people.map((person: Person) => (
{people.map((person) => (
<tr key={person.id} className="border-b">
<td className="p-4">
{`${person.firstName} ${person.lastName}`}
@@ -70,10 +90,26 @@ export default async function PeoplePage(props: {
<td className="p-4">
{formatPersonDepartment(
person.department,
copy.departments,
copy.fallback,
departmentCopy,
personFallbackCopy,
)}
</td>
<td className="p-4">
{person.user
? formatUserRole(
person.user.role,
userRoleLabels,
userFallbackCopy,
)
: "—"}
</td>
<td className="p-4">
{person.user
? person.user.isActive
? userStatusCopy.active
: userStatusCopy.inactive
: "—"}
</td>
<td className="flex items-center gap-2 p-4">
<Link href={`/people/${person.id}`} passHref>
<Button
@@ -100,7 +136,7 @@ export default async function PeoplePage(props: {
</tbody>
<tfoot className="border-t">
<tr>
<td colSpan={5} className="p-4 text-center text-sm">
<td colSpan={7} className="p-4 text-center text-sm">
<PaginationButtons totalPages={totalPages} />
</td>
</tr>
+1 -17
View File
@@ -5,7 +5,6 @@ import {
Clipboard,
Home,
Package,
Shield,
ShoppingCart,
User,
} from "lucide-react"
@@ -21,7 +20,6 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
import type { UserRole } from "@/generated/prisma/client"
import type { Dictionary } from "@/i18n/dictionaries"
import { SidebarSection } from "./sidebar/sidebarSection"
@@ -94,25 +92,11 @@ const items: SidebarItem[] = [
export default function AppSidebar({
copy,
userRole,
...props
}: React.ComponentProps<typeof Sidebar> & {
copy: SidebarCopy
userRole?: UserRole
}) {
const pathname = usePathname()
const visibleItems =
userRole === "ADMIN"
? [
...items,
{
type: "item",
labelKey: "users",
url: "/admin/users",
icon: Shield,
} satisfies SidebarItem,
]
: items
return (
<Sidebar {...props}>
@@ -126,7 +110,7 @@ export default function AppSidebar({
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{visibleItems.map((item) => {
{items.map((item) => {
if (item.type === "item") {
const isActive =
item.url === "/"
+15
View File
@@ -328,6 +328,8 @@ export const en = {
email: "Email",
phone: "Phone",
department: "Department",
role: "Role",
status: "Status",
actions: "Actions",
},
actions: {
@@ -341,6 +343,9 @@ export const en = {
email: "Email",
phone: "Phone",
department: "Department",
role: "Role",
status: "Status",
noUser: "No user account",
},
},
new: {
@@ -361,11 +366,17 @@ export const en = {
emailPlaceholder: "Email",
phoneLabel: "Phone",
phonePlaceholder: "Phone",
roleLabel: "Role",
activeLabel: "Active user",
newPasswordLabel: "New password",
newPasswordPlaceholder: "Leave blank to keep current password",
userAccountHeading: "User account",
createSubmit: "Create Person",
updateSubmit: "Update Person",
},
fallback: {
unknownDepartment: "Unknown department",
unknownStatus: "Unknown status",
},
departments: {
IT: "IT",
@@ -383,6 +394,7 @@ export const en = {
updateSuccess: "Person updated successfully",
updateFailure: "Failed to update person",
duplicateEmail: "Email already exists",
notFound: "Person not found",
},
schema: {
firstNameRequired: "First name is required",
@@ -465,8 +477,11 @@ export const en = {
phonePlaceholder: "Phone",
passwordLabel: "Password",
passwordPlaceholder: "Minimum 8 characters",
newPasswordLabel: "New password",
newPasswordPlaceholder: "Leave blank to keep current password",
roleLabel: "Role",
activeLabel: "Active user",
userAccountHeading: "User account",
createSubmit: "Create User",
updateSubmit: "Update User",
},
+17
View File
@@ -333,6 +333,8 @@ export const es = {
email: "Correo electrónico",
phone: "Teléfono",
department: "Departamento",
role: "Rol",
status: "Estado",
actions: "Acciones",
},
actions: {
@@ -346,6 +348,9 @@ export const es = {
email: "Correo electrónico",
phone: "Teléfono",
department: "Departamento",
role: "Rol",
status: "Estado",
noUser: "Sin cuenta de usuario",
},
},
new: {
@@ -366,11 +371,18 @@ export const es = {
emailPlaceholder: "Correo electrónico",
phoneLabel: "Teléfono",
phonePlaceholder: "Teléfono",
roleLabel: "Rol",
activeLabel: "Usuario activo",
newPasswordLabel: "Nueva contraseña",
newPasswordPlaceholder:
"Déjalo vacío para mantener la contraseña actual",
userAccountHeading: "Cuenta de usuario",
createSubmit: "Crear persona",
updateSubmit: "Actualizar persona",
},
fallback: {
unknownDepartment: "Departamento desconocido",
unknownStatus: "Estado desconocido",
},
departments: {
IT: "IT",
@@ -388,6 +400,7 @@ export const es = {
updateSuccess: "Persona actualizada correctamente",
updateFailure: "Error al actualizar la persona",
duplicateEmail: "El correo electrónico ya existe",
notFound: "Persona no encontrada",
},
schema: {
firstNameRequired: "El nombre es obligatorio",
@@ -470,8 +483,12 @@ export const es = {
phonePlaceholder: "Teléfono",
passwordLabel: "Contraseña",
passwordPlaceholder: "Mínimo 8 caracteres",
newPasswordLabel: "Nueva contraseña",
newPasswordPlaceholder:
"Déjalo vacío para mantener la contraseña actual",
roleLabel: "Rol",
activeLabel: "Usuario activo",
userAccountHeading: "Cuenta de usuario",
createSubmit: "Crear usuario",
updateSubmit: "Actualizar usuario",
},
+44
View File
@@ -80,6 +80,50 @@ export const unifiedFormRoleSchema = z.enum([
"NO_USER",
])
export const unifiedUpdateRoleSchema = z.enum([
"ADMIN",
"MANAGER",
"STAFF",
"VIEWER",
])
export function buildUnifiedUpdateSchema(copy: UnifiedSchemaCopy) {
return z
.object({
id: z.string().nonempty(copy.idRequired),
firstName: z.string().trim().min(1, { error: copy.firstNameRequired }),
lastName: z.string().trim().min(1, { error: copy.lastNameRequired }),
department: z.enum(personDepartments, {
error: copy.departmentRequired,
}),
email: z
.union([z.email({ error: copy.emailInvalid }), z.literal(""), z.null()])
.optional(),
phone: z.string().optional().nullable(),
role: unifiedUpdateRoleSchema.optional(),
isActive: z.boolean().optional(),
password: z.string().optional(),
})
.superRefine((data, ctx) => {
if (
data.role &&
data.password !== undefined &&
data.password !== "" &&
data.password.length < 8
) {
ctx.addIssue({
code: "custom",
message: copy.passwordMinLength,
path: ["password"],
})
}
})
}
export type UnifiedUpdateFormType = z.infer<
ReturnType<typeof buildUnifiedUpdateSchema>
>
export function buildUnifiedCreateSchema(copy: UnifiedSchemaCopy) {
return z
.object({
+20 -1
View File
@@ -2,6 +2,14 @@ import type { Person, Prisma } from "@/generated/prisma/client"
import { paginate } from "@/lib/paginate"
import prisma from "@/lib/prisma"
const personWithUserSelect = {
include: { user: true },
} as const
export type PersonWithUser = Prisma.PersonGetPayload<
typeof personWithUserSelect
>
export const PersonService = {
findAll: async (): Promise<Person[]> => {
return prisma.person.findMany({
@@ -19,10 +27,11 @@ export const PersonService = {
pageSize?: number
search?: string
}) => {
return paginate<Person>({
return paginate<PersonWithUser>({
model: prisma.person,
page,
pageSize,
include: personWithUserSelect.include,
where: {
...(search
? {
@@ -47,6 +56,16 @@ export const PersonService = {
return db.person.findUnique({ where: { id } })
},
findByIdWithUser: async (
id: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<PersonWithUser | null> => {
return db.person.findUnique({
where: { id },
include: personWithUserSelect.include,
})
},
findByEmail: async (
email: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
+84 -1
View File
@@ -5,7 +5,10 @@ import type {
CreatePersonFormType,
UpdatePersonFormType,
} from "@/schemas/person.schema"
import type { UnifiedCreateFormType } from "@/schemas/user.schema"
import type {
UnifiedCreateFormType,
UnifiedUpdateFormType,
} from "@/schemas/user.schema"
import { PersonService } from "@/services/person.service"
import { getUserByEmail } from "@/services/user.service"
@@ -217,3 +220,83 @@ export async function createPersonUserUseCase(
throw error
}
}
export async function updatePersonUserUseCase(
input: UnifiedUpdateFormType,
): Promise<PersonUseCaseResult> {
const {
id,
firstName,
lastName,
department,
email,
phone,
role,
isActive,
password,
} = input
try {
return await prisma.$transaction(async (tx) => {
const existing = await tx.person.findUnique({
where: { id },
include: { user: true },
})
if (!existing) {
return personError({ id: ["Person not found"] })
}
if (email) {
const existingPersonEmail = await PersonService.findByEmail(email, tx)
if (existingPersonEmail && existingPersonEmail.id !== id) {
return personError({ email: ["Email already exists"] })
}
}
await PersonService.update(
id,
{
firstName,
lastName,
department,
email: email || null,
phone: phone || null,
},
tx,
)
// If the person has a linked user, update User fields.
if (existing.userId && existing.user) {
const userData: Prisma.UserUpdateInput = {}
if (role !== undefined) {
userData.role = role
}
if (isActive !== undefined) {
userData.isActive = isActive
}
if (password && password.length >= 8) {
userData.password = await getPasswordHash(password)
}
if (Object.keys(userData).length > 0) {
await tx.user.update({
where: { id: existing.userId },
data: userData,
})
}
}
return { success: true }
})
} catch (error) {
const errors = uniqueErrorFor(error)
if (errors) {
return personError(errors)
}
throw error
}
}
@@ -0,0 +1,61 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import type { PrismaClient } from "@/generated/prisma/client"
import { createTestPerson, createTestUser } from "../helpers/factories"
import {
resetIntegrationTestDatabase,
startIntegrationTestDatabase,
stopIntegrationTestDatabase,
} from "../helpers/test-db"
let prisma: PrismaClient
beforeAll(async () => {
await startIntegrationTestDatabase()
const prismaModule = await import("@/lib/prisma")
prisma = prismaModule.prisma
})
beforeEach(async () => {
await resetIntegrationTestDatabase(prisma)
})
afterAll(async () => {
await prisma?.$disconnect()
await stopIntegrationTestDatabase()
})
describe("/admin/users -> /people redirect routes", () => {
it("does not have a /admin/users list page (route is consolidated into /people)", async () => {
const fs = await import("node:fs/promises")
const path = await import("node:path")
// /admin/users/page.tsx must still exist (as a redirect stub) — verify it's just a redirect.
const adminUsersPage = path.join(
process.cwd(),
"src/app/(dashboard)/admin/users/page.tsx",
)
const contents = await fs.readFile(adminUsersPage, "utf-8")
expect(contents).toMatch(/redirect\s*\(\s*["']\/people["']\s*\)/)
})
it("resolves a userId back to its linked personId", async () => {
// Build a Person<->User link to verify the redirect can find the person by userId.
const user = await createTestUser(prisma, {
email: "linked@example.test",
})
const person = await createTestPerson(prisma, {
email: "linked@example.test",
})
await prisma.person.update({
where: { id: person.id },
data: { userId: user.id },
})
const found = await prisma.person.findFirst({
where: { userId: user.id },
select: { id: true },
})
expect(found?.id).toBe(person.id)
})
})
@@ -0,0 +1,247 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import type { PrismaClient } from "@/generated/prisma/client"
import { getPasswordHash } from "@/lib/security"
import { createTestPerson, createTestUser } from "../helpers/factories"
import {
resetIntegrationTestDatabase,
startIntegrationTestDatabase,
stopIntegrationTestDatabase,
} from "../helpers/test-db"
let prisma: PrismaClient
let updatePersonUserUseCase: typeof import("@/use-cases/person.use-cases").updatePersonUserUseCase
beforeAll(async () => {
await startIntegrationTestDatabase()
const prismaModule = await import("@/lib/prisma")
const personUseCases = await import("@/use-cases/person.use-cases")
prisma = prismaModule.prisma
updatePersonUserUseCase = personUseCases.updatePersonUserUseCase
})
beforeEach(async () => {
await resetIntegrationTestDatabase(prisma)
})
afterAll(async () => {
await prisma?.$disconnect()
await stopIntegrationTestDatabase()
})
describe("updatePersonUserUseCase", () => {
describe("person-only update", () => {
it("updates only the Person when person has no linked User", async () => {
const person = await createTestPerson(prisma, {
firstName: "Old",
lastName: "Name",
email: "old@example.test",
})
const result = await updatePersonUserUseCase({
id: person.id,
firstName: "New",
lastName: "Name",
department: "IT",
email: "new@example.test",
phone: "1234",
})
expect(result).toEqual({ success: true })
const updated = await prisma.person.findUniqueOrThrow({
where: { id: person.id },
})
expect(updated).toMatchObject({
firstName: "New",
lastName: "Name",
department: "IT",
email: "new@example.test",
phone: "1234",
userId: null,
})
})
it("normalizes empty email to null when person has no User", async () => {
const person = await createTestPerson(prisma, { email: null })
const result = await updatePersonUserUseCase({
id: person.id,
firstName: "Empty",
lastName: "Email",
department: "OTHER",
email: "",
phone: null,
})
expect(result).toEqual({ success: true })
const updated = await prisma.person.findUniqueOrThrow({
where: { id: person.id },
})
expect(updated.email).toBeNull()
})
})
describe("person+user update", () => {
it("updates Person fields and User role/isActive when person has a User linked", async () => {
const user = await createTestUser(prisma, {
email: "user-update@example.test",
name: "Old Name",
role: "STAFF",
isActive: true,
})
const person = await createTestPerson(prisma, {
firstName: "Linked",
lastName: "Person",
email: "user-update@example.test",
})
await prisma.person.update({
where: { id: person.id },
data: { userId: user.id },
})
const result = await updatePersonUserUseCase({
id: person.id,
firstName: "Linked",
lastName: "Person",
department: "ENGINEERING",
email: "user-update@example.test",
phone: null,
role: "ADMIN",
isActive: false,
})
expect(result).toEqual({ success: true })
const updatedPerson = await prisma.person.findUniqueOrThrow({
where: { id: person.id },
include: { user: true },
})
expect(updatedPerson.department).toBe("ENGINEERING")
expect(updatedPerson.user).toMatchObject({
id: user.id,
role: "ADMIN",
isActive: false,
})
})
it("resets the User password when password is provided", async () => {
const user = await createTestUser(prisma, {
email: "pw-reset@example.test",
})
const person = await createTestPerson(prisma, {
email: "pw-reset@example.test",
})
await prisma.person.update({
where: { id: person.id },
data: { userId: user.id },
})
const result = await updatePersonUserUseCase({
id: person.id,
firstName: person.firstName,
lastName: person.lastName,
department: "OTHER",
email: "pw-reset@example.test",
phone: null,
role: "STAFF",
isActive: true,
password: "new-password-1",
})
expect(result).toEqual({ success: true })
const updated = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
})
const { verifyPassword } = await import("@/lib/security")
await expect(
verifyPassword("new-password-1", updated.password),
).resolves.toBe(true)
})
it("does not change the password when password is not provided", async () => {
const originalHash = await getPasswordHash("original-password-1")
const user = await prisma.user.create({
data: {
email: "no-pw@example.test",
name: "No PW",
password: originalHash,
role: "STAFF",
isActive: true,
},
})
const person = await createTestPerson(prisma, {
email: "no-pw@example.test",
})
await prisma.person.update({
where: { id: person.id },
data: { userId: user.id },
})
const result = await updatePersonUserUseCase({
id: person.id,
firstName: person.firstName,
lastName: person.lastName,
department: "OTHER",
email: "no-pw@example.test",
phone: null,
role: "STAFF",
isActive: true,
})
expect(result).toEqual({ success: true })
const updated = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
})
const { verifyPassword: verify } = await import("@/lib/security")
await expect(
verify("original-password-1", updated.password),
).resolves.toBe(true)
})
})
describe("validation errors", () => {
it("returns error when person is not found", async () => {
const result = await updatePersonUserUseCase({
id: "nonexistent-id",
firstName: "Ghost",
lastName: "Person",
department: "OTHER",
email: "ghost@example.test",
phone: null,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.errors.id).toBeDefined()
}
})
it("rejects duplicate email in Person table", async () => {
const person = await createTestPerson(prisma, {
email: "mine@example.test",
})
await createTestPerson(prisma, {
email: "theirs@example.test",
})
const result = await updatePersonUserUseCase({
id: person.id,
firstName: "Mine",
lastName: "Person",
department: "OTHER",
email: "theirs@example.test",
phone: null,
})
expect(result).toEqual({
success: false,
errors: { email: ["Email already exists"] },
})
})
})
})
@@ -8,6 +8,7 @@ const actionCopy = {
updateSuccess: "Persona actualizada correctamente",
updateFailure: "Error al actualizar la persona",
duplicateEmail: "El correo electrónico ya existe",
notFound: "Persona no encontrada",
}
describe("person action message localization", () => {
@@ -0,0 +1,218 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { en } from "@/i18n/dictionaries/en"
import { es } from "@/i18n/dictionaries/es"
const mocks = vi.hoisted(() => ({
revalidatePath: vi.fn(),
getI18n: vi.fn(),
requireRole: vi.fn(),
updatePersonUserUseCase: vi.fn(),
}))
vi.mock("next/cache", () => ({
revalidatePath: mocks.revalidatePath,
}))
vi.mock("@/i18n/server", () => ({
getI18n: mocks.getI18n,
}))
vi.mock("@/services/auth.service", () => ({
requireRole: mocks.requireRole,
}))
vi.mock("@/use-cases/person.use-cases", () => ({
updatePersonUserUseCase: mocks.updatePersonUserUseCase,
}))
import { updatePersonUserAction } from "@/actions/person.actions"
describe("updatePersonUserAction", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
mocks.requireRole.mockResolvedValue({ user: { id: "admin-1" } })
})
describe("schema validation", () => {
it("rejects empty id with localized idRequired error", async () => {
const result = await updatePersonUserAction({
id: "",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
email: "ada@example.test",
phone: null,
})
expect(result).toEqual({
success: false,
errors: {
id: [es.inventory.people.schema.idRequired],
},
})
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
})
it("rejects invalid email with localized emailInvalid error", async () => {
const result = await updatePersonUserAction({
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
email: "not-an-email",
phone: null,
})
expect(result).toEqual({
success: false,
errors: {
email: [es.inventory.people.schema.emailInvalid],
},
})
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
})
it("rejects short password when role is provided", async () => {
const result = await updatePersonUserAction({
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
email: "ada@example.test",
phone: null,
role: "ADMIN",
isActive: true,
password: "corta",
})
expect(result).toEqual({
success: false,
errors: {
password: [es.admin.users.schema.passwordMinLength],
},
})
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
})
it("rejects NO_USER role on update", async () => {
const result = await updatePersonUserAction({
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
email: "ada@example.test",
phone: null,
role: "NO_USER" as unknown as "ADMIN",
isActive: true,
})
expect(result.success).toBe(false)
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
})
it("rejects invalid role enum on update", async () => {
const result = await updatePersonUserAction({
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
email: "ada@example.test",
phone: null,
role: "SUPER_ADMIN" as unknown as "ADMIN",
isActive: true,
})
expect(result.success).toBe(false)
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
})
})
describe("use-case failure", () => {
it("localizes duplicate email errors from the use case", async () => {
mocks.updatePersonUserUseCase.mockResolvedValue({
success: false,
errors: { email: ["Email already exists"] },
})
const result = await updatePersonUserAction({
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
email: "duplicate@example.test",
phone: null,
})
expect(result).toEqual({
success: false,
errors: {
email: [es.inventory.people.actions.duplicateEmail],
},
message: es.inventory.people.actions.updateFailure,
})
})
it("localizes person-not-found error from the use case", async () => {
mocks.updatePersonUserUseCase.mockResolvedValue({
success: false,
errors: { id: ["Person not found"] },
})
const result = await updatePersonUserAction({
id: "missing",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
email: "ada@example.test",
phone: null,
})
expect(result.success).toBe(false)
if (!result.success && result.errors) {
expect(result.errors.id).toBeDefined()
}
})
})
describe("success", () => {
it("returns success and revalidates /people on happy path", async () => {
mocks.updatePersonUserUseCase.mockResolvedValue({ success: true })
const result = await updatePersonUserAction({
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
email: "ada@example.test",
phone: null,
})
expect(result).toEqual({
success: true,
message: es.inventory.people.actions.updateSuccess,
})
expect(mocks.revalidatePath).toHaveBeenCalledWith("/people")
})
it("uses English copy in en locale", async () => {
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
mocks.updatePersonUserUseCase.mockResolvedValue({ success: true })
const result = await updatePersonUserAction({
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
email: "ada@example.test",
phone: null,
})
expect(result).toEqual({
success: true,
message: en.inventory.people.actions.updateSuccess,
})
})
})
})
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { en } from "@/i18n/dictionaries/en"
import { es } from "@/i18n/dictionaries/es"
const mocks = vi.hoisted(() => ({
getI18n: vi.fn(),
getUsers: vi.fn(),
getUserProfileById: vi.fn(),
findFirst: vi.fn(),
redirect: vi.fn((url: string) => {
throw new Error(`REDIRECT:${url}`)
}),
}))
vi.mock("@/i18n/server", () => ({
getI18n: mocks.getI18n,
}))
vi.mock("@/services/user.service", () => ({
getUsers: mocks.getUsers,
getUserProfileById: mocks.getUserProfileById,
}))
vi.mock("@/lib/prisma", () => ({
prisma: {
person: {
findFirst: mocks.findFirst,
},
},
default: {
person: {
findFirst: mocks.findFirst,
},
},
}))
vi.mock("next/navigation", () => ({
redirect: mocks.redirect,
}))
describe("/admin/users routes redirect to /people", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
})
it("redirects /admin/users to /people", async () => {
const { default: UsersPage } = await import(
"@/app/(dashboard)/admin/users/page"
)
expect(() => UsersPage()).toThrow("REDIRECT:/people")
expect(mocks.getUsers).not.toHaveBeenCalled()
})
it("redirects /admin/users/[userId]/edit to /people/[personId]/edit when person is found", async () => {
mocks.findFirst.mockResolvedValue({ id: "person-99" })
const { default: EditUserPage } = await import(
"@/app/(dashboard)/admin/users/[userId]/edit/page"
)
await expect(
EditUserPage({ params: Promise.resolve({ userId: "user-1" }) }),
).rejects.toThrow("REDIRECT:/people/person-99/edit")
expect(mocks.getUserProfileById).not.toHaveBeenCalled()
})
it("redirects /admin/users/[userId]/edit to /people when person is not found", async () => {
mocks.findFirst.mockResolvedValue(null)
const { default: EditUserPage } = await import(
"@/app/(dashboard)/admin/users/[userId]/edit/page"
)
await expect(
EditUserPage({ params: Promise.resolve({ userId: "orphan-user" }) }),
).rejects.toThrow("REDIRECT:/people")
})
it("still honors Spanish locale when redirecting (does not require dictionary lookups)", async () => {
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
const { default: UsersPage } = await import(
"@/app/(dashboard)/admin/users/page"
)
expect(() => UsersPage()).toThrow("REDIRECT:/people")
})
})
@@ -0,0 +1,170 @@
import { createElement } from "react"
import { renderToStaticMarkup } from "react-dom/server"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { en } from "@/i18n/dictionaries/en"
import { es } from "@/i18n/dictionaries/es"
import type { PersonWithUser } from "@/services/person.service"
const mocks = vi.hoisted(() => ({
getI18n: vi.fn(),
findByIdWithUser: vi.fn(),
findById: vi.fn(),
personForm: vi.fn(),
push: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock("@/i18n/server", () => ({
getI18n: mocks.getI18n,
}))
vi.mock("@/services/person.service", () => ({
PersonService: {
findByIdWithUser: mocks.findByIdWithUser,
findById: mocks.findById,
},
}))
vi.mock("@/app/(dashboard)/people/_components/edit.person.form", () => ({
default: (props: unknown) => {
mocks.personForm(props)
return createElement("div", null, "Edit person form")
},
}))
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mocks.push }),
redirect: vi.fn(),
}))
vi.mock("@/actions/person.actions", () => ({
updatePersonUserAction: vi.fn(),
}))
vi.mock("sonner", () => ({
toast: {
error: mocks.toastError,
success: mocks.toastSuccess,
},
}))
const basePerson: PersonWithUser = {
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
email: "ada@example.test",
phone: "1234",
userId: null,
isActive: true,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
user: null,
}
const personWithUser: PersonWithUser = {
...basePerson,
id: "person-2",
userId: "user-1",
user: {
id: "user-1",
name: "Ada Lovelace",
email: "ada@example.test",
role: "ADMIN",
isActive: true,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
password: "hashed",
},
}
describe("edit person page wiring", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
})
it("loads the person without user, passes PersonWithoutUser to the edit form", async () => {
mocks.findByIdWithUser.mockResolvedValue({ ...basePerson, user: null })
const { default: PersonEditPage } = await import(
"@/app/(dashboard)/people/[personId]/edit/page"
)
renderToStaticMarkup(
await PersonEditPage({
params: Promise.resolve({ personId: "person-1" }),
}),
)
expect(mocks.findByIdWithUser).toHaveBeenCalledWith("person-1")
expect(mocks.personForm).toHaveBeenCalledWith(
expect.objectContaining({
person: expect.objectContaining({
id: "person-1",
user: null,
}),
formCopy: en.admin.users.form,
schemaCopy: {
...en.admin.users.schema,
...en.inventory.people.schema,
},
roleLabels: en.admin.users.roles,
}),
)
})
it("passes Spanish copy in es locale and passes the linked User to the form", async () => {
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
mocks.findByIdWithUser.mockResolvedValue(personWithUser)
const { default: PersonEditPage } = await import(
"@/app/(dashboard)/people/[personId]/edit/page"
)
renderToStaticMarkup(
await PersonEditPage({
params: Promise.resolve({ personId: "person-2" }),
}),
)
expect(mocks.personForm).toHaveBeenCalledWith(
expect.objectContaining({
person: expect.objectContaining({
id: "person-2",
user: expect.objectContaining({
id: "user-1",
role: "ADMIN",
isActive: true,
}),
}),
formCopy: es.admin.users.form,
roleLabels: es.admin.users.roles,
departmentCopy: es.inventory.people.departments,
fallbackCopy: expect.objectContaining({
unknownDepartment: es.inventory.people.fallback.unknownDepartment,
}),
}),
)
})
it("renders 'Person not found' in Spanish when person does not exist", async () => {
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
mocks.findByIdWithUser.mockResolvedValue(null)
const { default: PersonEditPage } = await import(
"@/app/(dashboard)/people/[personId]/edit/page"
)
const html = renderToStaticMarkup(
await PersonEditPage({
params: Promise.resolve({ personId: "missing" }),
}),
)
expect(html).toContain("Persona no encontrada")
expect(mocks.personForm).not.toHaveBeenCalled()
})
})
@@ -5,8 +5,10 @@ import { es } from "@/i18n/dictionaries/es"
const mocks = vi.hoisted(() => ({
getI18n: vi.fn(),
findByIdWithUser: vi.fn(),
findById: vi.fn(),
redirect: vi.fn(),
personForm: vi.fn(),
}))
vi.mock("@/i18n/server", () => ({
@@ -15,6 +17,7 @@ vi.mock("@/i18n/server", () => ({
vi.mock("@/services/person.service", () => ({
PersonService: {
findByIdWithUser: mocks.findByIdWithUser,
findById: mocks.findById,
},
}))
@@ -29,6 +32,14 @@ vi.mock("next/navigation", () => ({
vi.mock("@/actions/person.actions", () => ({
createNewPerson: vi.fn(),
updatePerson: vi.fn(),
updatePersonUserAction: vi.fn(),
}))
vi.mock("@/app/(dashboard)/people/_components/edit.person.form", () => ({
default: (props: unknown) => {
mocks.personForm(props)
return null
},
}))
vi.mock("sonner", () => ({
@@ -44,18 +55,23 @@ describe("person pages", () => {
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
})
it("renders the edit person page with Person heading and no username", async () => {
it("renders the edit person page with Person heading and passes person to unified form", async () => {
const { default: PersonEditPage } = await import(
"@/app/(dashboard)/people/[personId]/edit/page"
)
mocks.findById.mockResolvedValue({
mocks.findByIdWithUser.mockResolvedValue({
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
email: "ada@example.test",
phone: "1234",
department: "ENGINEERING",
userId: null,
isActive: true,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
user: null,
})
const html = renderToStaticMarkup(
@@ -65,8 +81,15 @@ describe("person pages", () => {
)
expect(html).toContain("Editar persona")
expect(html).toContain("Actualizar persona")
expect(html).not.toContain("Usuario")
expect(mocks.personForm).toHaveBeenCalledWith(
expect.objectContaining({
person: expect.objectContaining({
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
}),
}),
)
})
it("renders a Person not-found message on edit page", async () => {
@@ -74,7 +97,7 @@ describe("person pages", () => {
"@/app/(dashboard)/people/[personId]/edit/page"
)
mocks.findById.mockResolvedValue(null)
mocks.findByIdWithUser.mockResolvedValue(null)
const html = renderToStaticMarkup(
await PersonEditPage({
@@ -1,63 +0,0 @@
import { createElement } from "react"
import { renderToStaticMarkup } from "react-dom/server"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { en } from "@/i18n/dictionaries/en"
const mocks = vi.hoisted(() => ({
getI18n: vi.fn(),
findById: vi.fn(),
personForm: vi.fn(),
}))
vi.mock("@/i18n/server", () => ({
getI18n: mocks.getI18n,
}))
vi.mock("@/services/person.service", () => ({
PersonService: {
findById: mocks.findById,
},
}))
vi.mock("@/app/(dashboard)/people/_components/person.form", () => ({
default: (props: unknown) => {
mocks.personForm(props)
return createElement("div", null, "Person form")
},
}))
describe("person form schema wiring", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("passes server-resolved Person schema copy into the edit person form boundary", async () => {
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
mocks.findById.mockResolvedValue({
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
department: "ENGINEERING",
email: "ada@example.test",
phone: "1234",
})
const { default: PersonEditPage } = await import(
"@/app/(dashboard)/people/[personId]/edit/page"
)
renderToStaticMarkup(
await PersonEditPage({
params: Promise.resolve({ personId: "person-1" }),
}),
)
expect(mocks.personForm).toHaveBeenCalledWith(
expect.objectContaining({
mode: "edit",
schemaCopy: en.inventory.people.schema,
}),
)
})
})
+155 -2
View File
@@ -7,6 +7,7 @@ import { en } from "@/i18n/dictionaries/en"
const mocks = vi.hoisted(() => ({
findAllPaginated: vi.fn(),
findById: vi.fn(),
findByIdWithUser: vi.fn(),
findAllByPerson: vi.fn(),
getI18n: vi.fn(),
}))
@@ -19,6 +20,7 @@ vi.mock("@/services/person.service", () => ({
PersonService: {
findAllPaginated: mocks.findAllPaginated,
findById: mocks.findById,
findByIdWithUser: mocks.findByIdWithUser,
},
}))
@@ -62,6 +64,11 @@ describe("person pages", () => {
email: "ada@example.test",
phone: "1234",
department: "ENGINEERING",
userId: null,
isActive: true,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
user: null,
},
],
totalPages: 1,
@@ -86,6 +93,72 @@ describe("person pages", () => {
expect(html).toContain("/people/person-1/edit")
})
it("renders role and status columns for people with linked users", async () => {
const { default: PeoplePage } = await import(
"@/app/(dashboard)/people/page"
)
mocks.findAllPaginated.mockResolvedValue({
data: [
{
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
email: "ada@example.test",
phone: "1234",
department: "ENGINEERING",
userId: "user-1",
isActive: true,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
user: {
id: "user-1",
name: "Ada Lovelace",
email: "ada@example.test",
role: "ADMIN",
isActive: true,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
password: "hashed",
movements: [],
assignments: [],
person: null,
},
},
{
id: "person-2",
firstName: "Bob",
lastName: "Jones",
email: "bob@example.test",
phone: null,
department: "IT",
userId: null,
isActive: true,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
user: null,
},
],
totalPages: 1,
})
const html = renderToStaticMarkup(
await PeoplePage({ searchParams: Promise.resolve({}) }),
)
// Column headers from inventory.people.list.columns
expect(html).toContain("Role")
expect(html).toContain("Status")
// Person with linked user: role label + active label
expect(html).toContain("Admin")
expect(html).toContain("Active")
// Person without user: no canonical enum leaks, just placeholder
expect(html).not.toContain(">STAFF<")
expect(html).not.toContain(">ADMIN<")
})
it("renders the person list empty state from Person copy", async () => {
const { default: PeoplePage } = await import(
"@/app/(dashboard)/people/page"
@@ -108,13 +181,18 @@ describe("person pages", () => {
"@/app/(dashboard)/people/[personId]/page"
)
mocks.findById.mockResolvedValue({
mocks.findByIdWithUser.mockResolvedValue({
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
email: "ada@example.test",
phone: "1234",
department: "DRIVER",
userId: null,
isActive: true,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
user: null,
})
mocks.findAllByPerson.mockResolvedValue([
{
@@ -144,12 +222,87 @@ describe("person pages", () => {
expect(html).toContain("Laptop")
})
it("renders person detail User role and status when person has linked User", async () => {
const { default: PersonInfoPage } = await import(
"@/app/(dashboard)/people/[personId]/page"
)
mocks.findByIdWithUser.mockResolvedValue({
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
email: "ada@example.test",
phone: "1234",
department: "DRIVER",
userId: "user-1",
isActive: true,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
user: {
id: "user-1",
name: "Ada Lovelace",
email: "ada@example.test",
role: "ADMIN",
isActive: true,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
password: "hashed",
movements: [],
assignments: [],
person: null,
},
})
mocks.findAllByPerson.mockResolvedValue([])
const html = renderToStaticMarkup(
await PersonInfoPage({
params: Promise.resolve({ personId: "person-1" }),
}),
)
expect(html).toContain("Role")
expect(html).toContain("Status")
expect(html).toContain("Admin")
expect(html).toContain("Active")
// Canonical enum value must not leak into display
expect(html).not.toContain(">ADMIN<")
})
it("renders 'No user account' placeholder for person without linked User", async () => {
const { default: PersonInfoPage } = await import(
"@/app/(dashboard)/people/[personId]/page"
)
mocks.findByIdWithUser.mockResolvedValue({
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
email: "ada@example.test",
phone: "1234",
department: "DRIVER",
userId: null,
isActive: true,
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
user: null,
})
mocks.findAllByPerson.mockResolvedValue([])
const html = renderToStaticMarkup(
await PersonInfoPage({
params: Promise.resolve({ personId: "person-1" }),
}),
)
expect(html).toContain("No user account")
})
it("renders person detail not-found from Person copy", async () => {
const { default: PersonInfoPage } = await import(
"@/app/(dashboard)/people/[personId]/page"
)
mocks.findById.mockResolvedValue(null)
mocks.findByIdWithUser.mockResolvedValue(null)
mocks.findAllByPerson.mockResolvedValue([])
const html = renderToStaticMarkup(
@@ -1,227 +0,0 @@
import { renderToStaticMarkup } from "react-dom/server"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { en } from "@/i18n/dictionaries/en"
import { es } from "@/i18n/dictionaries/es"
const mocks = vi.hoisted(() => ({
createPersonUser: vi.fn(),
getUserProfileById: vi.fn(),
getI18n: vi.fn(),
push: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock("@/i18n/server", () => ({
getI18n: mocks.getI18n,
}))
vi.mock("@/actions/person.actions", () => ({
createPersonUserAction: mocks.createPersonUser,
}))
vi.mock("@/actions/user.actions", () => ({
updateUserAction: vi.fn(),
resetUserPasswordAction: vi.fn(),
}))
vi.mock("@/services/person.service", () => ({
PersonService: {
findById: vi.fn(),
},
}))
vi.mock("@/services/user.service", () => ({
getUserProfileById: mocks.getUserProfileById,
}))
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: mocks.push,
}),
notFound: () => {
throw new Error("NOT_FOUND")
},
}))
vi.mock("sonner", () => ({
toast: {
error: mocks.toastError,
success: mocks.toastSuccess,
},
}))
describe("new user form localization", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
})
it("renders new user page with localized title and unified form labels in Spanish", async () => {
const { default: NewUserPage } = await import(
"@/app/(dashboard)/people/new/page"
)
const html = renderToStaticMarkup(await NewUserPage())
// Title
expect(html).toContain("Nueva persona")
// Person field labels
expect(html).toContain("Nombre")
expect(html).toContain("Apellido")
expect(html).toContain("Departamento")
expect(html).toContain("Teléfono")
// User field labels
expect(html).toContain("Correo electrónico")
expect(html).toContain("Contraseña")
expect(html).toContain("Rol")
// Role labels (display) with canonical values
expect(html).toContain("Administrador")
expect(html).toContain("Gerente")
expect(html).toContain("Personal")
expect(html).toContain("Visor")
expect(html).toContain("Sin cuenta de usuario")
// Submit button text
expect(html).toContain("Crear usuario")
})
it("renders new user page with English unified form labels in English locale", async () => {
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
const { default: NewUserPage } = await import(
"@/app/(dashboard)/people/new/page"
)
const html = renderToStaticMarkup(await NewUserPage())
expect(html).toContain("New Person")
// Person fields
expect(html).toContain("First Name")
expect(html).toContain("Last Name")
expect(html).toContain("Department")
expect(html).toContain("Phone")
// User fields
expect(html).toContain("Email")
expect(html).toContain("Password")
expect(html).toContain("Role")
expect(html).toContain("Create User")
expect(html).toContain("Admin")
expect(html).toContain("Manager")
expect(html).toContain("Staff")
expect(html).toContain("Viewer")
expect(html).toContain("No user account")
})
it("keeps canonical role values in option value attributes including NO_USER, not localized labels", async () => {
const { default: NewUserPage } = await import(
"@/app/(dashboard)/people/new/page"
)
const html = renderToStaticMarkup(await NewUserPage())
// Canonical values must be in value attributes
expect(html).toContain('value="ADMIN"')
expect(html).toContain('value="MANAGER"')
expect(html).toContain('value="STAFF"')
expect(html).toContain('value="VIEWER"')
expect(html).toContain('value="NO_USER"')
})
})
describe("edit user form localization", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
mocks.getUserProfileById.mockResolvedValue({
id: "user-1",
name: "Ada Lovelace",
email: "ada@example.test",
role: "ADMIN",
isActive: true,
})
})
it("renders edit user page with localized title, form labels, and reset-password section in Spanish", async () => {
const { default: EditUserPage } = await import(
"@/app/(dashboard)/admin/users/[userId]/edit/page"
)
const html = renderToStaticMarkup(
await EditUserPage({
params: Promise.resolve({ userId: "user-1" }),
}),
)
// Title
expect(html).toContain("Editar usuario")
// Form labels
expect(html).toContain("Nombre")
expect(html).toContain("Nueva contraseña")
// Role labels with canonical values
expect(html).toContain("Administrador")
expect(html).toContain('value="ADMIN"')
// Active user checkbox label
expect(html).toContain("Usuario activo")
// Submit button
expect(html).toContain("Actualizar usuario")
// Reset password section
expect(html).toContain("Restablecer contraseña")
expect(html).toContain("Nueva contraseña")
expect(html).toContain("Mínimo 8 caracteres")
})
it("renders edit user page with English labels in English locale", async () => {
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
const { default: EditUserPage } = await import(
"@/app/(dashboard)/admin/users/[userId]/edit/page"
)
const html = renderToStaticMarkup(
await EditUserPage({
params: Promise.resolve({ userId: "user-1" }),
}),
)
expect(html).toContain("Edit User")
expect(html).toContain("Active user")
expect(html).toContain("Update User")
expect(html).toContain("Reset password")
expect(html).toContain("New password")
expect(html).toContain("Reset Password")
})
it("renders edit user form with role option values as canonical enums regardless of locale", async () => {
const { default: EditUserPage } = await import(
"@/app/(dashboard)/admin/users/[userId]/edit/page"
)
const html = renderToStaticMarkup(
await EditUserPage({
params: Promise.resolve({ userId: "user-1" }),
}),
)
// Canonical role values
expect(html).toContain('value="ADMIN"')
expect(html).toContain('value="MANAGER"')
expect(html).toContain('value="STAFF"')
expect(html).toContain('value="VIEWER"')
// Spanish labels for roles
expect(html).toContain("Administrador")
})
})
-183
View File
@@ -1,183 +0,0 @@
import { createElement } from "react"
import { renderToStaticMarkup } from "react-dom/server"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { en } from "@/i18n/dictionaries/en"
import { es } from "@/i18n/dictionaries/es"
import type { getUsers as _getUsers } from "@/services/user.service"
type UserData = Awaited<ReturnType<typeof _getUsers>>["data"][number]
const mocks = vi.hoisted(() => ({
getUsers: vi.fn(),
getI18n: vi.fn(),
}))
vi.mock("@/i18n/server", () => ({
getI18n: mocks.getI18n,
}))
vi.mock("@/services/user.service", () => ({
getUsers: mocks.getUsers,
}))
vi.mock("@/components/common/pageheader", () => ({
default: ({ title, addLabel }: { title?: string; addLabel?: string }) =>
createElement(
"header",
null,
[title, addLabel].filter(Boolean).join(" | "),
),
}))
vi.mock("@/components/common/pagination", () => ({
default: ({ totalPages }: { totalPages: number }) =>
createElement("nav", { "aria-label": "Pagination" }, totalPages),
}))
describe("user pages localization", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
})
it("renders the user list in Spanish with localized headers, status/role labels, and unchanged user data", async () => {
const { default: UsersPage } = await import(
"@/app/(dashboard)/admin/users/page"
)
mocks.getUsers.mockResolvedValue({
data: [
{
id: "user-1",
name: "Ada Lovelace",
email: "ada@example.test",
role: "ADMIN",
isActive: true,
},
{
id: "user-2",
name: "Grace Hopper",
email: "grace@example.test",
role: "STAFF",
isActive: false,
},
],
totalPages: 1,
})
const html = renderToStaticMarkup(
await UsersPage({ searchParams: Promise.resolve({}) }),
)
// Title and add label
expect(html).toContain("Usuarios")
expect(html).toContain("Agregar usuario")
// Table headers from dictionary
expect(html).toContain("Nombre")
expect(html).toContain("Correo electrónico")
expect(html).toContain("Rol")
expect(html).toContain("Estado")
expect(html).toContain("Acciones")
// Status labels from dictionary (display-only, not canonical)
expect(html).toContain("Activo")
expect(html).toContain("Inactivo")
// Role labels from dictionary (display-only, not canonical)
expect(html).toContain("Administrador")
expect(html).toContain("Personal")
// User data is never translated
expect(html).toContain("Ada Lovelace")
expect(html).toContain("ada@example.test")
expect(html).toContain("Grace Hopper")
expect(html).toContain("grace@example.test")
// Canonical role values must NOT appear as display text
expect(html).not.toContain(">ADMIN<")
expect(html).not.toContain(">STAFF<")
})
it("renders the localized user empty state when no users exist", async () => {
const { default: UsersPage } = await import(
"@/app/(dashboard)/admin/users/page"
)
mocks.getUsers.mockResolvedValue({
data: [],
totalPages: 0,
})
const html = renderToStaticMarkup(
await UsersPage({ searchParams: Promise.resolve({}) }),
)
expect(html).toContain("No se encontraron usuarios.")
})
it("renders the user list in English with English dictionary labels", async () => {
const { default: UsersPage } = await import(
"@/app/(dashboard)/admin/users/page"
)
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
mocks.getUsers.mockResolvedValue({
data: [
{
id: "user-1",
name: "Ada Lovelace",
email: "ada@example.test",
role: "MANAGER",
isActive: true,
},
],
totalPages: 1,
})
const html = renderToStaticMarkup(
await UsersPage({ searchParams: Promise.resolve({}) }),
)
// English dictionary labels
expect(html).toContain("Users")
expect(html).toContain("Add User")
expect(html).toContain("Name")
expect(html).toContain("Email")
expect(html).toContain("Role")
expect(html).toContain("Status")
expect(html).toContain("Actions")
expect(html).toContain("Active")
expect(html).toContain("Manager")
// Canonical enum value must NOT appear as display text
expect(html).not.toContain(">MANAGER<")
})
it("renders unknown role via fallback when role is not in dictionary", async () => {
const { default: UsersPage } = await import(
"@/app/(dashboard)/admin/users/page"
)
mocks.getUsers.mockResolvedValue({
data: [
{
id: "user-1",
name: "Test User",
email: "test@example.test",
role: "UNKNOWN_ROLE",
isActive: true,
} as unknown as UserData,
],
totalPages: 1,
})
const html = renderToStaticMarkup(
await UsersPage({ searchParams: Promise.resolve({}) }),
)
// Unknown role should use fallback
expect(html).toContain("Rol desconocido")
})
})
+1
View File
@@ -52,6 +52,7 @@ describe("formatPersonDepartment helper", () => {
const fallbackCopy = {
unknownDepartment: "Departamento desconocido",
unknownStatus: "Estado desconocido",
}
it("formats known department values with localized labels", () => {
@@ -0,0 +1,116 @@
import { createElement } from "react"
import { renderToStaticMarkup } from "react-dom/server"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { en } from "@/i18n/dictionaries/en"
import { es } from "@/i18n/dictionaries/es"
const mocks = vi.hoisted(() => ({
usePathname: vi.fn(() => "/people"),
Link: ({ href, children }: { href: string; children: React.ReactNode }) =>
createElement("a", { href }, children),
}))
vi.mock("next/navigation", () => ({
usePathname: mocks.usePathname,
redirect: vi.fn(),
}))
vi.mock("next/link", () => ({
default: mocks.Link,
}))
vi.mock("@/components/ui/sidebar", () => {
const Pass = ({ children }: { children: React.ReactNode }) => children
return {
Sidebar: Pass,
SidebarContent: Pass,
SidebarGroup: Pass,
SidebarGroupContent: Pass,
SidebarHeader: Pass,
SidebarMenu: Pass,
SidebarMenuButton: ({
children: buttonChildren,
}: {
children: React.ReactNode
}) => buttonChildren,
SidebarMenuItem: ({
children: itemChildren,
}: {
children: React.ReactNode
}) => createElement("li", null, itemChildren),
}
})
vi.mock("@/components/layout/sidebar/sidebarSection", () => ({
SidebarSection: ({
title,
items,
}: {
title: string
items: { title: string; url: string }[]
}) =>
createElement(
"section",
null,
title,
...items.map((sub) =>
createElement("a", { key: sub.url, href: sub.url }, sub.title),
),
),
}))
describe("app sidebar (consolidated people management)", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.usePathname.mockReturnValue("/people")
})
it("does NOT render a 'users' link in the sidebar (consolidated under /people)", async () => {
const { default: AppSidebar } = await import("@/components/layout/sidebar")
const html = renderToStaticMarkup(
createElement(AppSidebar, { copy: en.layout.sidebar }),
)
// No /admin/users link must exist anywhere
expect(html).not.toContain("/admin/users")
expect(html).not.toContain(">Users<")
})
it("renders 'People' (not 'Users') in English", async () => {
const { default: AppSidebar } = await import("@/components/layout/sidebar")
const html = renderToStaticMarkup(
createElement(AppSidebar, { copy: en.layout.sidebar }),
)
expect(html).toContain("People")
expect(html).toContain("/people")
})
it("renders 'Personas' (not 'Usuarios') in Spanish", async () => {
const { default: AppSidebar } = await import("@/components/layout/sidebar")
const html = renderToStaticMarkup(
createElement(AppSidebar, { copy: es.layout.sidebar }),
)
expect(html).toContain("Personas")
expect(html).toContain("/people")
expect(html).not.toContain("Usuarios")
})
it("marks the /people entry as active when pathname starts with /people", async () => {
const { default: AppSidebar } = await import("@/components/layout/sidebar")
mocks.usePathname.mockReturnValue("/people/person-1/edit")
const html = renderToStaticMarkup(
createElement(AppSidebar, { copy: en.layout.sidebar }),
)
// The /people link must still be present and reachable
expect(html).toContain("/people")
})
})
@@ -40,8 +40,11 @@ describe("admin users dictionary", () => {
phonePlaceholder: "Phone",
passwordLabel: "Password",
passwordPlaceholder: "Minimum 8 characters",
newPasswordLabel: "New password",
newPasswordPlaceholder: "Leave blank to keep current password",
roleLabel: "Role",
activeLabel: "Active user",
userAccountHeading: "User account",
createSubmit: "Create User",
updateSubmit: "Update User",
})
@@ -132,8 +135,11 @@ describe("admin users dictionary", () => {
phonePlaceholder: "Teléfono",
passwordLabel: "Contraseña",
passwordPlaceholder: "Mínimo 8 caracteres",
newPasswordLabel: "Nueva contraseña",
newPasswordPlaceholder: "Déjalo vacío para mantener la contraseña actual",
roleLabel: "Rol",
activeLabel: "Usuario activo",
userAccountHeading: "Cuenta de usuario",
createSubmit: "Crear usuario",
updateSubmit: "Actualizar usuario",
})
+25
View File
@@ -701,6 +701,8 @@ describe("i18n dictionaries", () => {
email: "Email",
phone: "Phone",
department: "Department",
role: "Role",
status: "Status",
actions: "Actions",
},
actions: {
@@ -714,6 +716,9 @@ describe("i18n dictionaries", () => {
email: "Email",
phone: "Phone",
department: "Department",
role: "Role",
status: "Status",
noUser: "No user account",
},
},
new: {
@@ -734,11 +739,17 @@ describe("i18n dictionaries", () => {
emailPlaceholder: "Email",
phoneLabel: "Phone",
phonePlaceholder: "Phone",
roleLabel: "Role",
activeLabel: "Active user",
newPasswordLabel: "New password",
newPasswordPlaceholder: "Leave blank to keep current password",
userAccountHeading: "User account",
createSubmit: "Create Person",
updateSubmit: "Update Person",
},
fallback: {
unknownDepartment: "Unknown department",
unknownStatus: "Unknown status",
},
departments: {
IT: "IT",
@@ -756,6 +767,7 @@ describe("i18n dictionaries", () => {
updateSuccess: "Person updated successfully",
updateFailure: "Failed to update person",
duplicateEmail: "Email already exists",
notFound: "Person not found",
},
schema: {
firstNameRequired: "First name is required",
@@ -777,6 +789,8 @@ describe("i18n dictionaries", () => {
email: "Correo electrónico",
phone: "Teléfono",
department: "Departamento",
role: "Rol",
status: "Estado",
actions: "Acciones",
},
actions: {
@@ -790,6 +804,9 @@ describe("i18n dictionaries", () => {
email: "Correo electrónico",
phone: "Teléfono",
department: "Departamento",
role: "Rol",
status: "Estado",
noUser: "Sin cuenta de usuario",
},
},
new: {
@@ -810,11 +827,18 @@ describe("i18n dictionaries", () => {
emailPlaceholder: "Correo electrónico",
phoneLabel: "Teléfono",
phonePlaceholder: "Teléfono",
roleLabel: "Rol",
activeLabel: "Usuario activo",
newPasswordLabel: "Nueva contraseña",
newPasswordPlaceholder:
"Déjalo vacío para mantener la contraseña actual",
userAccountHeading: "Cuenta de usuario",
createSubmit: "Crear persona",
updateSubmit: "Actualizar persona",
},
fallback: {
unknownDepartment: "Departamento desconocido",
unknownStatus: "Estado desconocido",
},
departments: {
IT: "IT",
@@ -832,6 +856,7 @@ describe("i18n dictionaries", () => {
updateSuccess: "Persona actualizada correctamente",
updateFailure: "Error al actualizar la persona",
duplicateEmail: "El correo electrónico ya existe",
notFound: "Persona no encontrada",
},
schema: {
firstNameRequired: "El nombre es obligatorio",
@@ -0,0 +1,200 @@
import { describe, expect, it } from "vitest"
import {
buildUnifiedUpdateSchema,
type UnifiedSchemaCopy,
} from "@/schemas/user.schema"
const enCopy: UnifiedSchemaCopy = {
firstNameRequired: "First name is required",
lastNameRequired: "Last name is required",
departmentRequired: "Department is required",
emailInvalid: "Invalid email",
passwordMinLength: "Password must be at least 8 characters",
nameRequired: "Name is required",
userIdRequired: "User id is required",
idRequired: "ID is required",
userIdInvalid: "User ID must be a valid UUID",
}
const esCopy: UnifiedSchemaCopy = {
firstNameRequired: "El nombre es obligatorio",
lastNameRequired: "El apellido es obligatorio",
departmentRequired: "El departamento es obligatorio",
emailInvalid: "Correo electrónico no válido",
passwordMinLength: "La contraseña debe tener al menos 8 caracteres",
nameRequired: "El nombre es obligatorio",
userIdRequired: "El ID de usuario es obligatorio",
idRequired: "El ID es obligatorio",
userIdInvalid: "El ID de usuario debe ser un UUID válido",
}
const validPersonOnly = {
id: "person-1",
firstName: "John",
lastName: "Doe",
department: "IT",
email: "john@example.test",
phone: null,
}
const validPersonWithUser = {
...validPersonOnly,
role: "ADMIN" as const,
isActive: true,
}
describe("buildUnifiedUpdateSchema", () => {
describe("person-only update (no user fields provided)", () => {
it("accepts person fields without role/isActive/password when person has no User", () => {
const schema = buildUnifiedUpdateSchema(enCopy)
const result = schema.safeParse(validPersonOnly)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.id).toBe("person-1")
expect(result.data.firstName).toBe("John")
expect(result.data.role).toBeUndefined()
expect(result.data.isActive).toBeUndefined()
expect(result.data.password).toBeUndefined()
}
})
it("accepts person fields with isActive=false when person has no User", () => {
const schema = buildUnifiedUpdateSchema(enCopy)
const result = schema.safeParse({ ...validPersonOnly, isActive: false })
expect(result.success).toBe(true)
})
it("rejects empty id", () => {
const schema = buildUnifiedUpdateSchema(enCopy)
const result = schema.safeParse({
id: "",
firstName: "John",
lastName: "Doe",
department: "IT",
email: "john@example.test",
phone: null,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.flatten().fieldErrors.id).toContain(
enCopy.idRequired,
)
}
})
})
describe("person+user update (when person has User linked)", () => {
it("accepts person and user fields with role and isActive when no password change", () => {
const schema = buildUnifiedUpdateSchema(enCopy)
const result = schema.safeParse(validPersonWithUser)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.role).toBe("ADMIN")
expect(result.data.isActive).toBe(true)
expect(result.data.password).toBeUndefined()
}
})
it("accepts password reset (>= 8 chars) when role is provided", () => {
const schema = buildUnifiedUpdateSchema(enCopy)
const result = schema.safeParse({
...validPersonWithUser,
password: "newpassword1",
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.password).toBe("newpassword1")
}
})
it("rejects short password when role is provided and password is non-empty", () => {
const schema = buildUnifiedUpdateSchema(enCopy)
const result = schema.safeParse({
...validPersonWithUser,
password: "short",
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.flatten().fieldErrors.password).toContain(
enCopy.passwordMinLength,
)
}
})
it("accepts empty password string as 'do not change' (ignored)", () => {
const schema = buildUnifiedUpdateSchema(enCopy)
const result = schema.safeParse({
...validPersonWithUser,
password: "",
})
expect(result.success).toBe(true)
})
it("rejects invalid role enum", () => {
const schema = buildUnifiedUpdateSchema(enCopy)
const result = schema.safeParse({
...validPersonWithUser,
role: "SUPER_ADMIN",
})
expect(result.success).toBe(false)
})
it("rejects NO_USER role on update (cannot remove user from existing person)", () => {
const schema = buildUnifiedUpdateSchema(enCopy)
const result = schema.safeParse({
...validPersonWithUser,
role: "NO_USER" as unknown as "ADMIN",
})
expect(result.success).toBe(false)
})
it("uses localized password error message", () => {
const schema = buildUnifiedUpdateSchema(esCopy)
const result = schema.safeParse({
...validPersonWithUser,
password: "corta",
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.flatten().fieldErrors.password).toContain(
esCopy.passwordMinLength,
)
}
})
})
describe("email validation", () => {
it("rejects invalid email format", () => {
const schema = buildUnifiedUpdateSchema(enCopy)
const result = schema.safeParse({
...validPersonOnly,
email: "not-an-email",
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.flatten().fieldErrors.email).toContain(
enCopy.emailInvalid,
)
}
})
it("accepts null email (person can have no email)", () => {
const schema = buildUnifiedUpdateSchema(enCopy)
const result = schema.safeParse({ ...validPersonOnly, email: null })
expect(result.success).toBe(true)
})
})
})