feat(i18n): localize admin users backbone

This commit is contained in:
2026-06-15 15:35:08 +02:00
parent 349559f4e0
commit 0cbbe60299
10 changed files with 1048 additions and 56 deletions
+53 -29
View File
@@ -1,17 +1,17 @@
"use server" "use server"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { flattenError } from "zod"
import { getI18n } from "@/i18n/server"
import { import {
buildCreateUserSchema,
buildResetUserPasswordSchema,
buildSetUserActiveSchema,
buildUpdateUserSchema,
type CreateUserFormType, type CreateUserFormType,
createUserSchema,
type ResetUserPasswordFormType, type ResetUserPasswordFormType,
resetUserPasswordSchema,
type SetUserActiveFormType, type SetUserActiveFormType,
setUserActiveSchema,
type UpdateUserFormType, type UpdateUserFormType,
updateUserSchema,
} from "@/schemas/user.schema" } from "@/schemas/user.schema"
import { requireRole } from "@/services/auth.service" import { requireRole } from "@/services/auth.service"
import { import {
@@ -21,12 +21,14 @@ import {
updateUserUseCase, updateUserUseCase,
} from "@/use-cases/user.use-cases" } from "@/use-cases/user.use-cases"
import { localizeUserFieldErrors } from "./user.messages"
const USERS_PATH = "/admin/users" const USERS_PATH = "/admin/users"
export async function createUserAction(formData: CreateUserFormType) { export async function createUserAction(formData: CreateUserFormType) {
await requireRole("ADMIN") const { dictionary } = await getI18n()
const copy = dictionary.admin.users
const validatedFields = createUserSchema.safeParse(formData) const validatedFields = buildCreateUserSchema(copy.schema).safeParse(formData)
if (!validatedFields.success) { if (!validatedFields.success) {
return { return {
@@ -39,27 +41,32 @@ export async function createUserAction(formData: CreateUserFormType) {
const result = await createUserUseCase(validatedFields.data) const result = await createUserUseCase(validatedFields.data)
if (!result.success) { if (!result.success) {
return result return {
...result,
errors: localizeUserFieldErrors(result.errors, copy.actions),
message: copy.actions.createFailure,
}
} }
revalidatePath(USERS_PATH) revalidatePath(USERS_PATH)
return { success: true, message: "User created successfully" } return { success: true, message: copy.actions.createSuccess }
} catch (error) { } catch (error) {
console.error("Database error:", error) console.error("Database error:", error)
return { success: false, message: "Failed to create user" } return { success: false, message: copy.actions.createFailure }
} }
} }
export async function updateUserAction(formData: UpdateUserFormType) { export async function updateUserAction(formData: UpdateUserFormType) {
const session = await requireRole("ADMIN") const session = await requireRole("ADMIN")
const { dictionary } = await getI18n()
const validatedFields = updateUserSchema.safeParse(formData) const copy = dictionary.admin.users
const validatedFields = buildUpdateUserSchema(copy.schema).safeParse(formData)
if (!validatedFields.success) { if (!validatedFields.success) {
return { return {
success: false, success: false,
errors: flattenError(validatedFields.error).fieldErrors, errors: validatedFields.error.flatten().fieldErrors,
} }
} }
@@ -70,27 +77,34 @@ export async function updateUserAction(formData: UpdateUserFormType) {
}) })
if (!result.success) { if (!result.success) {
return result return {
...result,
errors: localizeUserFieldErrors(result.errors, copy.actions),
message: copy.actions.updateFailure,
}
} }
revalidatePath(USERS_PATH) revalidatePath(USERS_PATH)
return { success: true, message: "User updated successfully" } return { success: true, message: copy.actions.updateSuccess }
} catch (error) { } catch (error) {
console.error("Database error:", error) console.error("Database error:", error)
return { success: false, message: "Failed to update user" } return { success: false, message: copy.actions.updateFailure }
} }
} }
export async function setUserActiveAction(formData: SetUserActiveFormType) { export async function setUserActiveAction(formData: SetUserActiveFormType) {
const session = await requireRole("ADMIN") const session = await requireRole("ADMIN")
const { dictionary } = await getI18n()
const validatedFields = setUserActiveSchema.safeParse(formData) const copy = dictionary.admin.users
const validatedFields = buildSetUserActiveSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) { if (!validatedFields.success) {
return { return {
success: false, success: false,
errors: flattenError(validatedFields.error).fieldErrors, errors: validatedFields.error.flatten().fieldErrors,
} }
} }
@@ -101,24 +115,30 @@ export async function setUserActiveAction(formData: SetUserActiveFormType) {
}) })
if (!result.success) { if (!result.success) {
return result return {
...result,
errors: localizeUserFieldErrors(result.errors, copy.actions),
message: copy.actions.toggleStatusFailure,
}
} }
revalidatePath(USERS_PATH) revalidatePath(USERS_PATH)
return { success: true, message: "User status updated successfully" } return { success: true, message: copy.actions.toggleStatusSuccess }
} catch (error) { } catch (error) {
console.error("Database error:", error) console.error("Database error:", error)
return { success: false, message: "Failed to update user status" } return { success: false, message: copy.actions.toggleStatusFailure }
} }
} }
export async function resetUserPasswordAction( export async function resetUserPasswordAction(
formData: ResetUserPasswordFormType, formData: ResetUserPasswordFormType,
) { ) {
await requireRole("ADMIN") const { dictionary } = await getI18n()
const copy = dictionary.admin.users
const validatedFields = resetUserPasswordSchema.safeParse(formData) const validatedFields = buildResetUserPasswordSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) { if (!validatedFields.success) {
return { return {
@@ -131,14 +151,18 @@ export async function resetUserPasswordAction(
const result = await resetUserPasswordUseCase(validatedFields.data) const result = await resetUserPasswordUseCase(validatedFields.data)
if (!result.success) { if (!result.success) {
return result return {
...result,
errors: localizeUserFieldErrors(result.errors, copy.actions),
message: copy.actions.resetPasswordFailure,
}
} }
revalidatePath(USERS_PATH) revalidatePath(USERS_PATH)
return { success: true, message: "Password reset successfully" } return { success: true, message: copy.actions.resetPasswordSuccess }
} catch (error) { } catch (error) {
console.error("Database error:", error) console.error("Database error:", error)
return { success: false, message: "Failed to reset password" } return { success: false, message: copy.actions.resetPasswordFailure }
} }
} }
+40
View File
@@ -0,0 +1,40 @@
import type { Dictionary } from "@/i18n/dictionaries"
type UserActionCopy = Dictionary["admin"]["users"]["actions"]
type FieldErrors = Record<string, string[]>
const userErrorMessageKeys = {
"Username already exists": "duplicateUsername",
"Email already exists": "duplicateEmail",
"User not found": "notFound",
"Cannot remove access from the last active administrator": "lastActiveAdmin",
"You cannot remove your own administrator access": "selfAdminAccess",
"You cannot deactivate your own user": "selfDeactivate",
} as const satisfies Record<string, keyof UserActionCopy>
function isUserErrorMessage(
message: string,
): message is keyof typeof userErrorMessageKeys {
return message in userErrorMessageKeys
}
function localizeUserMessage(message: string, copy: UserActionCopy): string {
if (!isUserErrorMessage(message)) return message
return copy[userErrorMessageKeys[message]]
}
export function localizeUserFieldErrors(
errors: FieldErrors | undefined,
copy: UserActionCopy,
): FieldErrors | undefined {
if (!errors) return undefined
return Object.fromEntries(
Object.entries(errors).map(([field, messages]) => [
field,
messages.map((message) => localizeUserMessage(message, copy)),
]),
)
}
@@ -0,0 +1,11 @@
import type { Dictionary } from "@/i18n/dictionaries"
export type UserListCopy = Dictionary["admin"]["users"]["list"]
export type UserFormCopy = Dictionary["admin"]["users"]["form"]
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"]
+82
View File
@@ -432,6 +432,88 @@ export const en = {
}, },
}, },
}, },
admin: {
users: {
list: {
title: "Users",
empty: "No users found.",
columns: {
name: "Name",
username: "Username",
email: "Email",
role: "Role",
status: "Status",
actions: "Actions",
},
actions: {
edit: "Edit user",
},
},
new: {
title: "New User",
},
edit: {
title: "Edit User",
},
form: {
nameLabel: "Name",
namePlaceholder: "Full name",
usernameLabel: "Username",
usernamePlaceholder: "Username",
emailLabel: "Email",
emailPlaceholder: "user@example.com",
passwordLabel: "Password",
passwordPlaceholder: "Minimum 8 characters",
roleLabel: "Role",
activeLabel: "Active user",
createSubmit: "Create User",
updateSubmit: "Update User",
},
resetPassword: {
title: "Reset password",
passwordLabel: "New password",
passwordPlaceholder: "Minimum 8 characters",
submit: "Reset Password",
},
roles: {
ADMIN: "Admin",
MANAGER: "Manager",
STAFF: "Staff",
VIEWER: "Viewer",
},
status: {
active: "Active",
inactive: "Inactive",
},
actions: {
createSuccess: "User created successfully",
createFailure: "Failed to create user",
updateSuccess: "User updated successfully",
updateFailure: "Failed to update user",
toggleStatusSuccess: "User status updated successfully",
toggleStatusFailure: "Failed to update user status",
resetPasswordSuccess: "Password reset successfully",
resetPasswordFailure: "Failed to reset password",
duplicateUsername: "Username already exists",
duplicateEmail: "Email already exists",
notFound: "User not found",
lastActiveAdmin:
"Cannot remove access from the last active administrator",
selfAdminAccess: "You cannot remove your own administrator access",
selfDeactivate: "You cannot deactivate your own user",
},
schema: {
usernameRequired: "Username is required",
nameRequired: "Name is required",
emailInvalid: "Invalid email",
passwordMinLength: "Password must be at least 8 characters",
userIdRequired: "User id is required",
},
fallback: {
unknownRole: "Unknown role",
},
},
},
login: { login: {
title: "Sign In", title: "Sign In",
usernameLabel: "Username", usernameLabel: "Username",
+82
View File
@@ -437,6 +437,88 @@ export const es = {
}, },
}, },
}, },
admin: {
users: {
list: {
title: "Usuarios",
empty: "No se encontraron usuarios.",
columns: {
name: "Nombre",
username: "Usuario",
email: "Correo electrónico",
role: "Rol",
status: "Estado",
actions: "Acciones",
},
actions: {
edit: "Editar usuario",
},
},
new: {
title: "Nuevo usuario",
},
edit: {
title: "Editar usuario",
},
form: {
nameLabel: "Nombre",
namePlaceholder: "Nombre completo",
usernameLabel: "Usuario",
usernamePlaceholder: "Usuario",
emailLabel: "Correo electrónico",
emailPlaceholder: "usuario@ejemplo.com",
passwordLabel: "Contraseña",
passwordPlaceholder: "Mínimo 8 caracteres",
roleLabel: "Rol",
activeLabel: "Usuario activo",
createSubmit: "Crear usuario",
updateSubmit: "Actualizar usuario",
},
resetPassword: {
title: "Restablecer contraseña",
passwordLabel: "Nueva contraseña",
passwordPlaceholder: "Mínimo 8 caracteres",
submit: "Restablecer contraseña",
},
roles: {
ADMIN: "Administrador",
MANAGER: "Gerente",
STAFF: "Personal",
VIEWER: "Visor",
},
status: {
active: "Activo",
inactive: "Inactivo",
},
actions: {
createSuccess: "Usuario creado correctamente",
createFailure: "Error al crear el usuario",
updateSuccess: "Usuario actualizado correctamente",
updateFailure: "Error al actualizar el usuario",
toggleStatusSuccess: "Estado del usuario actualizado correctamente",
toggleStatusFailure: "Error al actualizar el estado del usuario",
resetPasswordSuccess: "Contraseña restablecida correctamente",
resetPasswordFailure: "Error al restablecer la contraseña",
duplicateUsername: "El nombre de usuario ya existe",
duplicateEmail: "El correo electrónico ya existe",
notFound: "Usuario no encontrado",
lastActiveAdmin:
"No se puede eliminar el acceso del último administrador activo",
selfAdminAccess: "No puedes eliminar tu propio acceso de administrador",
selfDeactivate: "No puedes desactivar tu propio usuario",
},
schema: {
usernameRequired: "El usuario es obligatorio",
nameRequired: "El nombre es obligatorio",
emailInvalid: "Correo electrónico no válido",
passwordMinLength: "La contraseña debe tener al menos 8 caracteres",
userIdRequired: "El ID de usuario es obligatorio",
},
fallback: {
unknownRole: "Rol desconocido",
},
},
},
login: { login: {
title: "Iniciar sesión", title: "Iniciar sesión",
usernameLabel: "Usuario", usernameLabel: "Usuario",
+59 -27
View File
@@ -1,38 +1,70 @@
import { z } from "zod" import { z } from "zod"
import type { Dictionary } from "@/i18n/dictionaries"
export type UserSchemaCopy = Dictionary["admin"]["users"]["schema"]
export const defaultUserSchemaCopy: UserSchemaCopy = {
usernameRequired: "Username is required",
nameRequired: "Name is required",
emailInvalid: "Invalid email",
passwordMinLength: "Password must be at least 8 characters",
userIdRequired: "User id is required",
}
export const userRoleSchema = z.enum(["ADMIN", "MANAGER", "STAFF", "VIEWER"]) export const userRoleSchema = z.enum(["ADMIN", "MANAGER", "STAFF", "VIEWER"])
const passwordSchema = z function buildUserPasswordSchema(copy: UserSchemaCopy) {
.string() return z.string().min(8, { error: copy.passwordMinLength })
.min(8, { error: "Password must be at least 8 characters" }) }
export const createUserSchema = z.object({ export function buildCreateUserSchema(copy: UserSchemaCopy) {
username: z.string().trim().min(1, { error: "Username is required" }), return z.object({
name: z.string().trim().min(1, { error: "Name is required" }), username: z.string().trim().min(1, { error: copy.usernameRequired }),
email: z.email({ error: "Invalid email" }), name: z.string().trim().min(1, { error: copy.nameRequired }),
password: passwordSchema, email: z.email({ error: copy.emailInvalid }),
role: userRoleSchema, password: buildUserPasswordSchema(copy),
isActive: z.boolean(), role: userRoleSchema,
}) isActive: z.boolean(),
})
}
export const updateUserSchema = z.object({ export const createUserSchema = buildCreateUserSchema(defaultUserSchemaCopy)
id: z.string().min(1, { error: "User id is required" }),
username: z.string().trim().min(1, { error: "Username is required" }),
name: z.string().trim().min(1, { error: "Name is required" }),
email: z.email({ error: "Invalid email" }),
role: userRoleSchema,
isActive: z.boolean(),
})
export const setUserActiveSchema = z.object({ export function buildUpdateUserSchema(copy: UserSchemaCopy) {
id: z.string().min(1, { error: "User id is required" }), return z.object({
isActive: z.boolean(), id: z.string().min(1, { error: copy.userIdRequired }),
}) username: z.string().trim().min(1, { error: copy.usernameRequired }),
name: z.string().trim().min(1, { error: copy.nameRequired }),
email: z.email({ error: copy.emailInvalid }),
role: userRoleSchema,
isActive: z.boolean(),
})
}
export const resetUserPasswordSchema = z.object({ export const updateUserSchema = buildUpdateUserSchema(defaultUserSchemaCopy)
id: z.string().min(1, { error: "User id is required" }),
password: passwordSchema, export function buildSetUserActiveSchema(copy: UserSchemaCopy) {
}) return z.object({
id: z.string().min(1, { error: copy.userIdRequired }),
isActive: z.boolean(),
})
}
export const setUserActiveSchema = buildSetUserActiveSchema(
defaultUserSchemaCopy,
)
export function buildResetUserPasswordSchema(copy: UserSchemaCopy) {
return z.object({
id: z.string().min(1, { error: copy.userIdRequired }),
password: buildUserPasswordSchema(copy),
})
}
export const resetUserPasswordSchema = buildResetUserPasswordSchema(
defaultUserSchemaCopy,
)
export type CreateUserFormType = z.infer<typeof createUserSchema> export type CreateUserFormType = z.infer<typeof createUserSchema>
export type UpdateUserFormType = z.infer<typeof updateUserSchema> export type UpdateUserFormType = z.infer<typeof updateUserSchema>
+306
View File
@@ -0,0 +1,306 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { es } from "@/i18n/dictionaries/es"
const mocks = vi.hoisted(() => ({
revalidatePath: vi.fn(),
getI18n: vi.fn(),
requireRole: vi.fn(),
createUserUseCase: vi.fn(),
updateUserUseCase: vi.fn(),
setUserActiveUseCase: vi.fn(),
resetUserPasswordUseCase: 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/user.use-cases", () => ({
createUserUseCase: mocks.createUserUseCase,
updateUserUseCase: mocks.updateUserUseCase,
setUserActiveUseCase: mocks.setUserActiveUseCase,
resetUserPasswordUseCase: mocks.resetUserPasswordUseCase,
}))
import {
createUserAction,
resetUserPasswordAction,
setUserActiveAction,
updateUserAction,
} from "@/actions/user.actions"
describe("user actions localization", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
mocks.requireRole.mockResolvedValue({
user: { id: "admin-1" },
})
})
describe("createUserAction", () => {
it("returns localized schema validation errors for invalid create input", async () => {
const result = await createUserAction({
username: "",
name: "",
email: "bad",
password: "short",
role: "ADMIN",
isActive: true,
})
expect(mocks.getI18n).toHaveBeenCalledOnce()
expect(mocks.createUserUseCase).not.toHaveBeenCalled()
expect(result).toEqual({
success: false,
errors: {
username: [es.admin.users.schema.usernameRequired],
name: [es.admin.users.schema.nameRequired],
email: [es.admin.users.schema.emailInvalid],
password: [es.admin.users.schema.passwordMinLength],
},
})
})
it("localizes mapped duplicate field errors for create failures", async () => {
mocks.createUserUseCase.mockResolvedValue({
success: false,
errors: {
username: ["Username already exists"],
email: ["Email already exists"],
},
})
const result = await createUserAction({
username: "ada",
name: "Ada",
email: "ada@example.test",
password: "password1",
role: "STAFF",
isActive: true,
})
expect(result).toEqual({
success: false,
errors: {
username: [es.admin.users.actions.duplicateUsername],
email: [es.admin.users.actions.duplicateEmail],
},
message: es.admin.users.actions.createFailure,
})
})
it("returns a localized create success message and revalidates", async () => {
mocks.createUserUseCase.mockResolvedValue({ success: true })
const result = await createUserAction({
username: "ada",
name: "Ada",
email: "ada@example.test",
password: "password1",
role: "STAFF",
isActive: true,
})
expect(result).toEqual({
success: true,
message: es.admin.users.actions.createSuccess,
})
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/users")
})
})
describe("updateUserAction", () => {
it("returns localized schema validation errors for invalid update input", async () => {
const result = await updateUserAction({
id: "",
username: "",
name: "",
email: "bad",
role: "ADMIN",
isActive: true,
})
expect(mocks.getI18n).toHaveBeenCalledOnce()
expect(mocks.updateUserUseCase).not.toHaveBeenCalled()
expect(result).toEqual({
success: false,
errors: {
id: [es.admin.users.schema.userIdRequired],
username: [es.admin.users.schema.usernameRequired],
name: [es.admin.users.schema.nameRequired],
email: [es.admin.users.schema.emailInvalid],
},
})
})
it("localizes mapped use-case errors for update failures", async () => {
mocks.updateUserUseCase.mockResolvedValue({
success: false,
errors: {
id: [
"Cannot remove access from the last active administrator",
"You cannot remove your own administrator access",
],
},
})
const result = await updateUserAction({
id: "user-1",
username: "admin",
name: "Admin",
email: "admin@example.test",
role: "MANAGER",
isActive: true,
})
expect(result).toEqual({
success: false,
errors: {
id: [
es.admin.users.actions.lastActiveAdmin,
es.admin.users.actions.selfAdminAccess,
],
},
message: es.admin.users.actions.updateFailure,
})
})
it("returns a localized update success message and revalidates", async () => {
mocks.updateUserUseCase.mockResolvedValue({ success: true })
const result = await updateUserAction({
id: "user-1",
username: "admin",
name: "Admin",
email: "admin@example.test",
role: "ADMIN",
isActive: true,
})
expect(result).toEqual({
success: true,
message: es.admin.users.actions.updateSuccess,
})
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/users")
})
})
describe("setUserActiveAction", () => {
it("returns localized schema validation errors for invalid input", async () => {
const result = await setUserActiveAction({
id: "",
isActive: false,
})
expect(mocks.getI18n).toHaveBeenCalledOnce()
expect(mocks.setUserActiveUseCase).not.toHaveBeenCalled()
expect(result).toEqual({
success: false,
errors: {
id: [es.admin.users.schema.userIdRequired],
},
})
})
it("localizes mapped use-case errors for toggle failures", async () => {
mocks.setUserActiveUseCase.mockResolvedValue({
success: false,
errors: {
id: ["You cannot deactivate your own user"],
},
})
const result = await setUserActiveAction({
id: "user-1",
isActive: false,
})
expect(result).toEqual({
success: false,
errors: {
id: [es.admin.users.actions.selfDeactivate],
},
message: es.admin.users.actions.toggleStatusFailure,
})
})
it("returns a localized toggle status success message and revalidates", async () => {
mocks.setUserActiveUseCase.mockResolvedValue({ success: true })
const result = await setUserActiveAction({
id: "user-1",
isActive: false,
})
expect(result).toEqual({
success: true,
message: es.admin.users.actions.toggleStatusSuccess,
})
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/users")
})
})
describe("resetUserPasswordAction", () => {
it("returns localized schema validation errors for invalid input", async () => {
const result = await resetUserPasswordAction({
id: "",
password: "short",
})
expect(mocks.getI18n).toHaveBeenCalledOnce()
expect(mocks.resetUserPasswordUseCase).not.toHaveBeenCalled()
expect(result).toEqual({
success: false,
errors: {
id: [es.admin.users.schema.userIdRequired],
password: [es.admin.users.schema.passwordMinLength],
},
})
})
it("localizes mapped use-case errors for reset failures", async () => {
mocks.resetUserPasswordUseCase.mockResolvedValue({
success: false,
errors: { id: ["User not found"] },
})
const result = await resetUserPasswordAction({
id: "nonexistent",
password: "newpassword1",
})
expect(result).toEqual({
success: false,
errors: {
id: [es.admin.users.actions.notFound],
},
message: es.admin.users.actions.resetPasswordFailure,
})
})
it("returns a localized reset success message and revalidates", async () => {
mocks.resetUserPasswordUseCase.mockResolvedValue({ success: true })
const result = await resetUserPasswordAction({
id: "user-1",
password: "newpassword1",
})
expect(result).toEqual({
success: true,
message: es.admin.users.actions.resetPasswordSuccess,
})
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/users")
})
})
})
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest"
import { localizeUserFieldErrors } from "@/actions/user.messages"
import { es } from "@/i18n/dictionaries/es"
const actionCopy = es.admin.users.actions
describe("user action message localization", () => {
it("localizes all 6 known use-case error strings to dictionary keys", () => {
expect(
localizeUserFieldErrors(
{
username: ["Username already exists"],
email: ["Email already exists"],
id: [
"User not found",
"Cannot remove access from the last active administrator",
"You cannot remove your own administrator access",
"You cannot deactivate your own user",
],
},
actionCopy,
),
).toEqual({
username: [actionCopy.duplicateUsername],
email: [actionCopy.duplicateEmail],
id: [
actionCopy.notFound,
actionCopy.lastActiveAdmin,
actionCopy.selfAdminAccess,
actionCopy.selfDeactivate,
],
})
})
it("keeps unknown messages unchanged", () => {
expect(
localizeUserFieldErrors({ id: ["Unexpected user issue"] }, actionCopy),
).toEqual({ id: ["Unexpected user issue"] })
})
it("returns undefined for undefined input", () => {
expect(localizeUserFieldErrors(undefined, actionCopy)).toBeUndefined()
})
})
@@ -0,0 +1,200 @@
import { describe, expect, it } from "vitest"
import { dictionaries, getDictionary } from "@/i18n/dictionaries"
describe("admin users dictionary", () => {
it("provides localized admin users copy for English", () => {
const users = getDictionary("en").admin.users
expect(users.list).toEqual({
title: "Users",
empty: "No users found.",
columns: {
name: "Name",
username: "Username",
email: "Email",
role: "Role",
status: "Status",
actions: "Actions",
},
actions: {
edit: "Edit user",
},
})
expect(users.new).toEqual({ title: "New User" })
expect(users.edit).toEqual({ title: "Edit User" })
expect(users.form).toEqual({
nameLabel: "Name",
namePlaceholder: "Full name",
usernameLabel: "Username",
usernamePlaceholder: "Username",
emailLabel: "Email",
emailPlaceholder: "user@example.com",
passwordLabel: "Password",
passwordPlaceholder: "Minimum 8 characters",
roleLabel: "Role",
activeLabel: "Active user",
createSubmit: "Create User",
updateSubmit: "Update User",
})
expect(users.resetPassword).toEqual({
title: "Reset password",
passwordLabel: "New password",
passwordPlaceholder: "Minimum 8 characters",
submit: "Reset Password",
})
expect(users.roles).toEqual({
ADMIN: "Admin",
MANAGER: "Manager",
STAFF: "Staff",
VIEWER: "Viewer",
})
expect(users.status).toEqual({
active: "Active",
inactive: "Inactive",
})
expect(users.actions).toEqual({
createSuccess: "User created successfully",
createFailure: "Failed to create user",
updateSuccess: "User updated successfully",
updateFailure: "Failed to update user",
toggleStatusSuccess: "User status updated successfully",
toggleStatusFailure: "Failed to update user status",
resetPasswordSuccess: "Password reset successfully",
resetPasswordFailure: "Failed to reset password",
duplicateUsername: "Username already exists",
duplicateEmail: "Email already exists",
notFound: "User not found",
lastActiveAdmin:
"Cannot remove access from the last active administrator",
selfAdminAccess: "You cannot remove your own administrator access",
selfDeactivate: "You cannot deactivate your own user",
})
expect(users.schema).toEqual({
usernameRequired: "Username is required",
nameRequired: "Name is required",
emailInvalid: "Invalid email",
passwordMinLength: "Password must be at least 8 characters",
userIdRequired: "User id is required",
})
expect(users.fallback).toEqual({
unknownRole: "Unknown role",
})
})
it("provides localized admin users copy for Spanish", () => {
const users = getDictionary("es").admin.users
expect(users.list).toEqual({
title: "Usuarios",
empty: "No se encontraron usuarios.",
columns: {
name: "Nombre",
username: "Usuario",
email: "Correo electrónico",
role: "Rol",
status: "Estado",
actions: "Acciones",
},
actions: {
edit: "Editar usuario",
},
})
expect(users.new).toEqual({ title: "Nuevo usuario" })
expect(users.edit).toEqual({ title: "Editar usuario" })
expect(users.form).toEqual({
nameLabel: "Nombre",
namePlaceholder: "Nombre completo",
usernameLabel: "Usuario",
usernamePlaceholder: "Usuario",
emailLabel: "Correo electrónico",
emailPlaceholder: "usuario@ejemplo.com",
passwordLabel: "Contraseña",
passwordPlaceholder: "Mínimo 8 caracteres",
roleLabel: "Rol",
activeLabel: "Usuario activo",
createSubmit: "Crear usuario",
updateSubmit: "Actualizar usuario",
})
expect(users.resetPassword).toEqual({
title: "Restablecer contraseña",
passwordLabel: "Nueva contraseña",
passwordPlaceholder: "Mínimo 8 caracteres",
submit: "Restablecer contraseña",
})
expect(users.roles).toEqual({
ADMIN: "Administrador",
MANAGER: "Gerente",
STAFF: "Personal",
VIEWER: "Visor",
})
expect(users.status).toEqual({
active: "Activo",
inactive: "Inactivo",
})
expect(users.actions).toEqual({
createSuccess: "Usuario creado correctamente",
createFailure: "Error al crear el usuario",
updateSuccess: "Usuario actualizado correctamente",
updateFailure: "Error al actualizar el usuario",
toggleStatusSuccess: "Estado del usuario actualizado correctamente",
toggleStatusFailure: "Error al actualizar el estado del usuario",
resetPasswordSuccess: "Contraseña restablecida correctamente",
resetPasswordFailure: "Error al restablecer la contraseña",
duplicateUsername: "El nombre de usuario ya existe",
duplicateEmail: "El correo electrónico ya existe",
notFound: "Usuario no encontrado",
lastActiveAdmin:
"No se puede eliminar el acceso del último administrador activo",
selfAdminAccess: "No puedes eliminar tu propio acceso de administrador",
selfDeactivate: "No puedes desactivar tu propio usuario",
})
expect(users.schema).toEqual({
usernameRequired: "El usuario es obligatorio",
nameRequired: "El nombre es obligatorio",
emailInvalid: "Correo electrónico no válido",
passwordMinLength: "La contraseña debe tener al menos 8 caracteres",
userIdRequired: "El ID de usuario es obligatorio",
})
expect(users.fallback).toEqual({
unknownRole: "Rol desconocido",
})
})
it("maintains structural parity between English and Spanish admin.users", () => {
const enKeys = extractKeyPaths(dictionaries.en.admin.users)
const esKeys = extractKeyPaths(dictionaries.es.admin.users)
expect(esKeys).toEqual(enKeys)
})
})
function extractKeyPaths(value: unknown, prefix = ""): string[] {
if (!isPlainObject(value)) return [prefix]
return Object.keys(value)
.sort()
.flatMap((key) =>
extractKeyPaths(value[key], prefix ? `${prefix}.${key}` : key),
)
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest"
import {
buildCreateUserSchema,
buildResetUserPasswordSchema,
buildSetUserActiveSchema,
buildUpdateUserSchema,
createUserSchema,
defaultUserSchemaCopy,
type UserSchemaCopy,
updateUserSchema,
} from "@/schemas/user.schema"
const esCopy: UserSchemaCopy = {
usernameRequired: "El usuario es obligatorio",
nameRequired: "El nombre es obligatorio",
emailInvalid: "Correo electrónico no válido",
passwordMinLength: "La contraseña debe tener al menos 8 caracteres",
userIdRequired: "El ID de usuario es obligatorio",
}
const validCreateData = {
username: "admin",
name: "Admin User",
email: "admin@example.test",
password: "password1",
role: "ADMIN" as const,
isActive: true,
}
const validUpdateData = {
id: "user-id",
username: "admin",
name: "Admin User",
email: "admin@example.test",
role: "ADMIN" as const,
isActive: true,
}
describe("user schema localization", () => {
it("buildCreateUserSchema with default copy produces same validation as createUserSchema", () => {
const result = buildCreateUserSchema(defaultUserSchemaCopy).safeParse({
username: "",
name: "",
email: "bad",
password: "short",
role: "INVALID",
isActive: true,
})
expect(result.success).toBe(false)
if (!result.success) {
const errors = result.error.flatten().fieldErrors
expect(errors.username).toContain("Username is required")
expect(errors.name).toContain("Name is required")
expect(errors.email).toContain("Invalid email")
expect(errors.password).toContain(
"Password must be at least 8 characters",
)
// role error comes from z.enum, not our copy
expect(errors.role).toBeDefined()
}
})
it("buildCreateUserSchema with Spanish copy produces Spanish error messages", () => {
const result = buildCreateUserSchema(esCopy).safeParse({
username: "",
name: "",
email: "bad",
password: "short",
role: "INVALID",
isActive: true,
})
expect(result.success).toBe(false)
if (!result.success) {
const errors = result.error.flatten().fieldErrors
expect(errors.username).toContain(esCopy.usernameRequired)
expect(errors.name).toContain(esCopy.nameRequired)
expect(errors.email).toContain(esCopy.emailInvalid)
expect(errors.password).toContain(esCopy.passwordMinLength)
}
})
it("buildUpdateUserSchema with default copy produces same validation as updateUserSchema", () => {
const result = buildUpdateUserSchema(defaultUserSchemaCopy).safeParse({
id: "",
username: "",
name: "",
email: "bad",
role: "INVALID",
isActive: true,
})
expect(result.success).toBe(false)
if (!result.success) {
const errors = result.error.flatten().fieldErrors
expect(errors.id).toContain("User id is required")
expect(errors.username).toContain("Username is required")
expect(errors.name).toContain("Name is required")
expect(errors.email).toContain("Invalid email")
}
})
it("buildUpdateUserSchema with Spanish copy produces Spanish error messages", () => {
const result = buildUpdateUserSchema(esCopy).safeParse({
id: "",
username: "",
name: "",
email: "bad",
role: "INVALID",
isActive: true,
})
expect(result.success).toBe(false)
if (!result.success) {
const errors = result.error.flatten().fieldErrors
expect(errors.id).toContain(esCopy.userIdRequired)
expect(errors.username).toContain(esCopy.usernameRequired)
expect(errors.name).toContain(esCopy.nameRequired)
expect(errors.email).toContain(esCopy.emailInvalid)
}
})
it("buildSetUserActiveSchema with default copy validates id requirement", () => {
const result = buildSetUserActiveSchema(defaultUserSchemaCopy).safeParse({
id: "",
isActive: true,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.flatten().fieldErrors.id).toContain(
"User id is required",
)
}
})
it("buildResetUserPasswordSchema with default copy validates password and id", () => {
const result = buildResetUserPasswordSchema(
defaultUserSchemaCopy,
).safeParse({
id: "",
password: "short",
})
expect(result.success).toBe(false)
if (!result.success) {
const errors = result.error.flatten().fieldErrors
expect(errors.id).toContain("User id is required")
expect(errors.password).toContain(
"Password must be at least 8 characters",
)
}
})
it("default exports match builder with default copy for valid data", () => {
expect(createUserSchema.safeParse(validCreateData).success).toBe(true)
expect(
buildCreateUserSchema(defaultUserSchemaCopy).safeParse(validCreateData)
.success,
).toBe(true)
expect(updateUserSchema.safeParse(validUpdateData).success).toBe(true)
expect(
buildUpdateUserSchema(defaultUserSchemaCopy).safeParse(validUpdateData)
.success,
).toBe(true)
})
})