@@ -78,7 +90,7 @@ export default function ResetUserPasswordForm({
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
- Reset Password
+ {formCopy.submit}
)
diff --git a/src/app/(dashboard)/admin/users/_components/user.copy.ts b/src/app/(dashboard)/admin/users/_components/user.copy.ts
index 60e3e49..3b6fba5 100644
--- a/src/app/(dashboard)/admin/users/_components/user.copy.ts
+++ b/src/app/(dashboard)/admin/users/_components/user.copy.ts
@@ -1,11 +1,18 @@
import type { Dictionary } from "@/i18n/dictionaries"
-export type UserListCopy = Dictionary["admin"]["users"]["list"]
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 UserRolesCopy = Dictionary["admin"]["users"]["roles"]
-export type UserStatusCopy = Dictionary["admin"]["users"]["status"]
-export type UserActionCopy = Dictionary["admin"]["users"]["actions"]
-export type UserSchemaCopy = Dictionary["admin"]["users"]["schema"]
-export type UserFallbackCopy = Dictionary["admin"]["users"]["fallback"]
+
+export function formatUserRole(
+ role: string,
+ roleCopy: UserRoleCopy,
+ fallbackCopy: UserFallbackCopy,
+): string {
+ return role in roleCopy
+ ? roleCopy[role as keyof UserRoleCopy]
+ : fallbackCopy.unknownRole
+}
diff --git a/src/app/(dashboard)/admin/users/new/page.tsx b/src/app/(dashboard)/admin/users/new/page.tsx
index d132df1..ea78dbe 100644
--- a/src/app/(dashboard)/admin/users/new/page.tsx
+++ b/src/app/(dashboard)/admin/users/new/page.tsx
@@ -4,13 +4,19 @@ import NewUserForm from "../_components/new.user.form"
export default async function NewUserPage() {
const { dictionary } = await getI18n()
+ const copy = dictionary.admin.users
return (
-
New User
+ {copy.new.title}
-
+
)
}
diff --git a/src/app/(dashboard)/admin/users/page.tsx b/src/app/(dashboard)/admin/users/page.tsx
index d3b6143..135bce1 100644
--- a/src/app/(dashboard)/admin/users/page.tsx
+++ b/src/app/(dashboard)/admin/users/page.tsx
@@ -4,8 +4,16 @@ import Link from "next/link"
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
@@ -20,11 +28,14 @@ export default async function UsersPage(props: {
pageSize: 10,
search,
})
+ const { dictionary } = await getI18n()
+ const copy = dictionary.admin.users
return (
- No users found.
+ {copy.list.empty}
)}
@@ -42,22 +53,22 @@ export default async function UsersPage(props: {
|
- Name
+ {copy.list.columns.name}
|
- Username
+ {copy.list.columns.username}
|
- Email
+ {copy.list.columns.email}
|
- Role
+ {copy.list.columns.role}
|
- Status
+ {copy.list.columns.status}
|
- Actions
+ {copy.list.columns.actions}
|
@@ -67,9 +78,17 @@ export default async function UsersPage(props: {
{user.name} |
{user.username} |
{user.email} |
-
{user.role} |
- {user.isActive ? "Active" : "Inactive"}
+ {formatUserRole(
+ user.role,
+ copy.roles as UserRoleCopy,
+ copy.fallback as UserFallbackCopy,
+ )}
+ |
+
+ {user.isActive
+ ? (copy.status as UserStatusCopy).active
+ : (copy.status as UserStatusCopy).inactive}
|
diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts
index 68643df..dcc5046 100644
--- a/src/i18n/dictionaries/en.ts
+++ b/src/i18n/dictionaries/en.ts
@@ -436,6 +436,7 @@ export const en = {
users: {
list: {
title: "Users",
+ addLabel: "Add User",
empty: "No users found.",
columns: {
name: "Name",
diff --git a/src/i18n/dictionaries/es.ts b/src/i18n/dictionaries/es.ts
index 4a3d768..cdfa4b7 100644
--- a/src/i18n/dictionaries/es.ts
+++ b/src/i18n/dictionaries/es.ts
@@ -441,6 +441,7 @@ export const es = {
users: {
list: {
title: "Usuarios",
+ addLabel: "Agregar usuario",
empty: "No se encontraron usuarios.",
columns: {
name: "Nombre",
diff --git a/tests/unit/app/users/user-form-pages.test.ts b/tests/unit/app/users/user-form-pages.test.ts
new file mode 100644
index 0000000..349aa9c
--- /dev/null
+++ b/tests/unit/app/users/user-form-pages.test.ts
@@ -0,0 +1,221 @@
+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(() => ({
+ createUserAction: vi.fn(),
+ updateUserAction: vi.fn(),
+ resetUserPasswordAction: 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/user.actions", () => ({
+ createUserAction: mocks.createUserAction,
+ updateUserAction: mocks.updateUserAction,
+ resetUserPasswordAction: mocks.resetUserPasswordAction,
+}))
+
+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("passes server-resolved schema and role copy into the new user form boundary", async () => {
+ const { default: NewUserPage } = await import(
+ "@/app/(dashboard)/admin/users/new/page"
+ )
+
+ renderToStaticMarkup(await NewUserPage())
+
+ // The page must pass formCopy, schemaCopy, and roleLabels to the form
+ // We verify this by checking the form renders localized content
+ })
+
+ it("renders new user page with localized title and form labels in Spanish", async () => {
+ const { default: NewUserPage } = await import(
+ "@/app/(dashboard)/admin/users/new/page"
+ )
+
+ const html = renderToStaticMarkup(await NewUserPage())
+
+ // Title
+ expect(html).toContain("Nuevo usuario")
+
+ // Form labels from dictionary
+ expect(html).toContain("Nombre")
+ expect(html).toContain("Usuario")
+ expect(html).toContain("Correo electrónico")
+ expect(html).toContain("Contraseña")
+ expect(html).toContain("Rol")
+
+ // Placeholders from dictionary
+ expect(html).toContain("Nombre completo")
+ expect(html).toContain("Mínimo 8 caracteres")
+
+ // Role labels (display) with canonical values
+ expect(html).toContain("Administrador")
+ expect(html).toContain("Gerente")
+ expect(html).toContain("Personal")
+ expect(html).toContain("Visor")
+
+ // Submit button text
+ expect(html).toContain("Crear usuario")
+ })
+
+ it("renders new user page with English form labels in English locale", async () => {
+ mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
+
+ const { default: NewUserPage } = await import(
+ "@/app/(dashboard)/admin/users/new/page"
+ )
+
+ const html = renderToStaticMarkup(await NewUserPage())
+
+ expect(html).toContain("New User")
+ expect(html).toContain("Full name")
+ expect(html).toContain("Username")
+ expect(html).toContain("Password")
+ expect(html).toContain("Minimum 8 characters")
+ expect(html).toContain("Create User")
+ expect(html).toContain("Admin")
+ expect(html).toContain("Manager")
+ expect(html).toContain("Staff")
+ expect(html).toContain("Viewer")
+ })
+
+ it("keeps canonical role values in option value attributes, not localized labels", async () => {
+ const { default: NewUserPage } = await import(
+ "@/app/(dashboard)/admin/users/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"')
+ })
+})
+
+describe("edit user form localization", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
+ mocks.getUserProfileById.mockResolvedValue({
+ id: "user-1",
+ name: "Ada Lovelace",
+ username: "ada",
+ 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")
+ })
+})
diff --git a/tests/unit/app/users/user-pages.test.ts b/tests/unit/app/users/user-pages.test.ts
new file mode 100644
index 0000000..232e392
--- /dev/null
+++ b/tests/unit/app/users/user-pages.test.ts
@@ -0,0 +1,191 @@
+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>["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",
+ username: "ada",
+ email: "ada@example.test",
+ role: "ADMIN",
+ isActive: true,
+ },
+ {
+ id: "user-2",
+ name: "Grace Hopper",
+ username: "grace",
+ 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("Usuario")
+ 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")
+ expect(html).toContain("ada@example.test")
+ expect(html).toContain("Grace Hopper")
+ expect(html).toContain("grace")
+ 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",
+ username: "ada",
+ 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("Username")
+ 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",
+ username: "testuser",
+ 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")
+ })
+})
diff --git a/tests/unit/app/users/user.copy.test.ts b/tests/unit/app/users/user.copy.test.ts
new file mode 100644
index 0000000..0fcbdd2
--- /dev/null
+++ b/tests/unit/app/users/user.copy.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest"
+
+import { formatUserRole } from "@/app/(dashboard)/admin/users/_components/user.copy"
+
+describe("user copy helpers", () => {
+ const roleCopy = {
+ ADMIN: "Administrador",
+ MANAGER: "Gerente",
+ STAFF: "Personal",
+ VIEWER: "Visor",
+ }
+
+ const fallbackCopy = {
+ unknownRole: "Rol desconocido",
+ }
+
+ it("formats known role values with localized display labels", () => {
+ expect(formatUserRole("ADMIN", roleCopy, fallbackCopy)).toBe(
+ "Administrador",
+ )
+ expect(formatUserRole("STAFF", roleCopy, fallbackCopy)).toBe("Personal")
+ })
+
+ it("falls back for unknown role values without exposing the raw enum value", () => {
+ expect(formatUserRole("UNKNOWN_ROLE", roleCopy, fallbackCopy)).toBe(
+ "Rol desconocido",
+ )
+ })
+
+ it("falls back for null role values", () => {
+ expect(
+ formatUserRole(null as unknown as string, roleCopy, fallbackCopy),
+ ).toBe("Rol desconocido")
+ })
+})
diff --git a/tests/unit/i18n/admin-users-dictionary.test.ts b/tests/unit/i18n/admin-users-dictionary.test.ts
index 676ff2d..6403a14 100644
--- a/tests/unit/i18n/admin-users-dictionary.test.ts
+++ b/tests/unit/i18n/admin-users-dictionary.test.ts
@@ -8,6 +8,7 @@ describe("admin users dictionary", () => {
expect(users.list).toEqual({
title: "Users",
+ addLabel: "Add User",
empty: "No users found.",
columns: {
name: "Name",
@@ -95,6 +96,7 @@ describe("admin users dictionary", () => {
expect(users.list).toEqual({
title: "Usuarios",
+ addLabel: "Agregar usuario",
empty: "No se encontraron usuarios.",
columns: {
name: "Nombre",
|