refactor: remove username from User model, login by email only

This commit is contained in:
2026-06-16 16:18:42 +02:00
parent caf19575c6
commit 68c2983d36
30 changed files with 42 additions and 198 deletions
-1
View File
@@ -18,7 +18,6 @@ STOCK_MANAGER_DEFAULT_LOCALE=en
# ADMIN BOOTSTRAP
ADMIN_BOOTSTRAP_ENABLED=true
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@localhost
ADMIN_NAME=Administrator
ADMIN_PASSWORD=change-me
+1 -1
View File
@@ -80,7 +80,7 @@ Variables principales:
|-------|-----------|
| Base de datos | `DATABASE_URL`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `POSTGRES_HOST`, `POSTGRES_PORT` |
| Auth | `AUTH_SECRET`, `AUTH_TRUST_HOST`, `DOMAIN`, `NODE_ENV`, `DEMO_MODE` |
| Bootstrap admin | `ADMIN_BOOTSTRAP_ENABLED`, `ADMIN_USERNAME`, `ADMIN_EMAIL`, `ADMIN_NAME`, `ADMIN_PASSWORD` |
| Bootstrap admin | `ADMIN_BOOTSTRAP_ENABLED`, `ADMIN_EMAIL`, `ADMIN_NAME`, `ADMIN_PASSWORD` |
### Bootstrap admin
-1
View File
@@ -38,7 +38,6 @@ services:
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST}
AUTH_SECRET: ${AUTH_SECRET}
ADMIN_BOOTSTRAP_ENABLED: ${ADMIN_BOOTSTRAP_ENABLED:-"true"}
ADMIN_USERNAME: ${ADMIN_USERNAME:-"admin"}
ADMIN_EMAIL: ${ADMIN_EMAIL:-"admin@localhost"}
ADMIN_NAME: ${ADMIN_NAME:-"Administrator"}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
-4
View File
@@ -3,7 +3,6 @@ import { getPasswordHash } from "@/lib/security"
import prisma from "../src/lib/prisma"
type BootstrapAdminInput = {
username: string
email: string
name: string
password: string
@@ -12,7 +11,6 @@ type BootstrapAdminInput = {
function getBootstrapAdminInput(): BootstrapAdminInput {
const isProduction = process.env.NODE_ENV === "production"
const username = process.env.ADMIN_USERNAME ?? "admin"
const email = process.env.ADMIN_EMAIL ?? "admin@localhost"
const name = process.env.ADMIN_NAME ?? "Administrator"
const password = process.env.ADMIN_PASSWORD
@@ -22,7 +20,6 @@ function getBootstrapAdminInput(): BootstrapAdminInput {
}
return {
username,
email,
name,
password: password ?? "admin",
@@ -55,7 +52,6 @@ export async function bootstrapAdmin(client: typeof prisma) {
},
create: {
name: admin.name,
username: admin.username,
email: admin.email,
role: "ADMIN",
password: await getPasswordHash(admin.password),
@@ -13,7 +13,6 @@ CREATE TYPE "MovementType" AS ENUM ('IN', 'OUT', 'ASSIGNMENT', 'RETURN', 'ADJUST
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
@@ -119,9 +118,6 @@ CREATE TABLE "Movement" (
CONSTRAINT "Movement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
+5 -6
View File
@@ -23,7 +23,6 @@ enum UserRole {
model User {
id String @id @default(uuid())
username String @unique
name String
email String @unique
password String
@@ -133,8 +132,8 @@ model Assignment {
item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull, onUpdate: Cascade)
assetId String? @unique
asset Asset? @relation(fields: [assetId], references: [id], onDelete: SetNull, onUpdate: Cascade)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: Cascade, onUpdate: Cascade)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: Cascade, onUpdate: Cascade)
assignmentDate DateTime @default(now())
returnDate DateTime?
createdBy String
@@ -170,8 +169,8 @@ model Movement {
asset Asset? @relation(fields: [assetId], references: [id], onDelete: SetNull, onUpdate: Cascade)
previousStock Int?
newStock Int?
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull, onUpdate: Cascade)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull, onUpdate: Cascade)
assignmentId String?
assignment Assignment? @relation(fields: [assignmentId], references: [id], onDelete: SetNull, onUpdate: Cascade)
userId String
@@ -183,4 +182,4 @@ model Movement {
@@index([personId])
@@index([type])
@@index([userId])
}
}
+2 -2
View File
@@ -6,11 +6,11 @@ import { signIn } from "@/lib/auth"
import type { SignInFormType } from "@/schemas/auth.schema"
export async function signInAction(values: SignInFormType) {
const { username, password } = values
const { email, password } = values
try {
await signIn("credentials", {
username,
email,
password,
redirect: false,
})
-1
View File
@@ -5,7 +5,6 @@ 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",
@@ -22,7 +22,7 @@ export default function SignInForm({ copy }: SignInFormProps) {
const { register, handleSubmit, formState } = useForm<SignInFormType>({
resolver: zodResolver(signInSchema),
defaultValues: {
username: "",
email: "",
password: "",
},
})
@@ -42,15 +42,15 @@ export default function SignInForm({ copy }: SignInFormProps) {
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<label className="flex flex-col gap-1">
{copy.usernameLabel}
{copy.emailLabel}
<input
{...register("username")}
name="username"
{...register("email")}
name="email"
type="text"
className="border-input w-full rounded-md border-2 p-2"
/>
{formState.errors.username && (
<p className="text-error">{formState.errors.username.message}</p>
{formState.errors.email && (
<p className="text-error">{formState.errors.email.message}</p>
)}
</label>
<label className="flex flex-col gap-1">
@@ -45,7 +45,6 @@ export default function EditUserForm({
defaultValues: {
id: user.id,
name: user.name,
username: user.username,
email: user.email,
role: user.role,
isActive: user.isActive,
@@ -84,13 +83,6 @@ export default function EditUserForm({
placeholder={formCopy.namePlaceholder}
register={register("name")}
/>
<UserTextInput
error={errors.username?.message}
id="username"
label={formCopy.usernameLabel}
placeholder={formCopy.usernamePlaceholder}
register={register("username")}
/>
<UserTextInput
error={errors.email?.message}
id="email"
@@ -76,13 +76,6 @@ export default function NewUserForm({
placeholder={formCopy.namePlaceholder}
register={register("name")}
/>
<UserTextInput
error={errors.username?.message}
id="username"
label={formCopy.usernameLabel}
placeholder={formCopy.usernamePlaceholder}
register={register("username")}
/>
<UserTextInput
error={errors.email?.message}
id="email"
-4
View File
@@ -55,9 +55,6 @@ export default async function UsersPage(props: {
<th scope="col" className="p-4">
{copy.list.columns.name}
</th>
<th scope="col" className="p-4">
{copy.list.columns.username}
</th>
<th scope="col" className="p-4">
{copy.list.columns.email}
</th>
@@ -76,7 +73,6 @@ export default async function UsersPage(props: {
{users.map((user) => (
<tr key={user.id} className="border-b">
<td className="p-4">{user.name}</td>
<td className="p-4">{user.username}</td>
<td className="p-4">{user.email}</td>
<td className="p-4">
{formatUserRole(
+1 -6
View File
@@ -435,7 +435,6 @@ export const en = {
empty: "No users found.",
columns: {
name: "Name",
username: "Username",
email: "Email",
role: "Role",
status: "Status",
@@ -454,8 +453,6 @@ export const en = {
form: {
nameLabel: "Name",
namePlaceholder: "Full name",
usernameLabel: "Username",
usernamePlaceholder: "Username",
emailLabel: "Email",
emailPlaceholder: "user@example.com",
passwordLabel: "Password",
@@ -490,7 +487,6 @@ export const en = {
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:
@@ -499,7 +495,6 @@ export const en = {
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",
@@ -512,7 +507,7 @@ export const en = {
},
login: {
title: "Sign In",
usernameLabel: "Username",
emailLabel: "Email",
passwordLabel: "Password",
submitLabel: "Sign In",
},
+1 -6
View File
@@ -440,7 +440,6 @@ export const es = {
empty: "No se encontraron usuarios.",
columns: {
name: "Nombre",
username: "Usuario",
email: "Correo electrónico",
role: "Rol",
status: "Estado",
@@ -459,8 +458,6 @@ export const es = {
form: {
nameLabel: "Nombre",
namePlaceholder: "Nombre completo",
usernameLabel: "Usuario",
usernamePlaceholder: "Usuario",
emailLabel: "Correo electrónico",
emailPlaceholder: "usuario@ejemplo.com",
passwordLabel: "Contraseña",
@@ -495,7 +492,6 @@ export const es = {
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:
@@ -504,7 +500,6 @@ export const es = {
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",
@@ -517,7 +512,7 @@ export const es = {
},
login: {
title: "Iniciar sesión",
usernameLabel: "Usuario",
emailLabel: "Correo electrónico",
passwordLabel: "Contraseña",
submitLabel: "Iniciar sesión",
},
+8 -8
View File
@@ -6,7 +6,7 @@ import type { UserRole } from "@/generated/prisma/client"
import { SIGN_IN_URL, TOKEN_EXPIRATION_SECONDS } from "@/lib/constants"
import { verifyPassword } from "@/lib/security"
import { signInSchema } from "@/schemas/auth.schema"
import { getUserByUsername } from "@/services/user.service"
import { getUserByEmail } from "@/services/user.service"
declare module "next-auth" {
interface Session {
@@ -29,27 +29,27 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
credentials: {
username: {},
email: {},
password: {},
},
authorize: async (credentials) => {
try {
const { data, success } = signInSchema.safeParse(credentials)
if (!success) throw new Error("Invalid username or password")
if (!success) throw new Error("Invalid email or password")
const user = await getUserByUsername(data.username)
const user = await getUserByEmail(data.email)
if (!user) {
throw new Error("Invalid username or password")
throw new Error("Invalid email or password")
}
if (!user.isActive) {
throw new Error("Invalid username or password")
throw new Error("Invalid email or password")
}
if (!(await verifyPassword(data.password, user.password)))
throw new Error("Invalid username or password")
throw new Error("Invalid email or password")
return {
...user,
@@ -59,7 +59,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
if (error instanceof ZodError) {
return null
}
throw new Error("Invalid username or password")
throw new Error("Invalid email or password")
}
},
}),
+1 -6
View File
@@ -1,12 +1,7 @@
import { z } from "zod"
export const signInSchema = z.object({
username: z
.string()
.min(1, {
error: "Invalid username",
})
.nonempty("Username is required"),
email: z.email().nonempty("Email is required"),
password: z
.string()
.min(3, {
-3
View File
@@ -5,7 +5,6 @@ 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",
@@ -20,7 +19,6 @@ function buildUserPasswordSchema(copy: UserSchemaCopy) {
export function buildCreateUserSchema(copy: UserSchemaCopy) {
return z.object({
username: z.string().trim().min(1, { error: copy.usernameRequired }),
name: z.string().trim().min(1, { error: copy.nameRequired }),
email: z.email({ error: copy.emailInvalid }),
password: buildUserPasswordSchema(copy),
@@ -34,7 +32,6 @@ export const createUserSchema = buildCreateUserSchema(defaultUserSchemaCopy)
export function buildUpdateUserSchema(copy: UserSchemaCopy) {
return z.object({
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,
+2 -12
View File
@@ -7,7 +7,6 @@ import type { User } from "@/types/user"
const userWithoutPasswordSelect = {
id: true,
username: true,
name: true,
email: true,
role: true,
@@ -20,12 +19,12 @@ export type UserWithoutPassword = Prisma.UserGetPayload<{
select: typeof userWithoutPasswordSelect
}>
type CreateUserData = Pick<User, "username" | "name" | "email" | "password"> & {
type CreateUserData = Pick<User, "name" | "email" | "password"> & {
role?: UserRole
isActive?: boolean
}
type UpdateUserData = Partial<Pick<User, "username" | "name" | "email">> & {
type UpdateUserData = Partial<Pick<User, "name" | "email">> & {
role?: UserRole
isActive?: boolean
}
@@ -44,7 +43,6 @@ export async function createUser(
) {
const newUser = await db.user.create({
data: {
username: data.username,
name: data.name,
email: data.email,
password: await getPasswordHash(data.password),
@@ -72,13 +70,6 @@ export async function getUserProfileById(
})
}
export async function getUserByUsername(
username: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
) {
return await db.user.findUnique({ where: { username } })
}
export async function getUserByEmail(
email: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
@@ -103,7 +94,6 @@ export async function getUsers({
...(search
? {
OR: [
{ username: { contains: search, mode: "insensitive" } },
{ name: { contains: search, mode: "insensitive" } },
{ email: { contains: search, mode: "insensitive" } },
],
+4 -18
View File
@@ -11,7 +11,6 @@ import {
createUser,
getUserByEmail,
getUserById,
getUserByUsername,
resetUserPassword,
setUserActive,
updateUser,
@@ -49,15 +48,11 @@ function uniqueErrorFor(error: unknown): FieldErrors | null {
const target = Array.isArray(error.meta?.target) ? error.meta.target : []
if (target.includes("username")) {
return { username: ["Username already exists"] }
}
if (target.includes("email")) {
return { email: ["Email already exists"] }
}
return { username: ["Username already exists"] }
return { email: ["Email already exists"] }
}
function isTransactionConflictError(error: unknown) {
@@ -113,14 +108,10 @@ async function getAdminAccessLossError(
export async function createUserUseCase(
input: CreateUserFormType,
): Promise<UserUseCaseResult> {
const { username, email } = input
const { email } = input
try {
return await prisma.$transaction(async (tx) => {
if (await getUserByUsername(username, tx)) {
return userError({ username: ["Username already exists"] })
}
if (await getUserByEmail(email, tx)) {
return userError({ email: ["Email already exists"] })
}
@@ -145,7 +136,7 @@ export async function createUserUseCase(
export async function updateUserUseCase(
input: UpdateUserFormType & ActorInput,
): Promise<UserUseCaseResult> {
const { actorId, id, username, email, role, isActive, ...data } = input
const { actorId, id, email, role, isActive, ...data } = input
try {
return await runSerializableUserTransaction(async (tx) => {
@@ -166,17 +157,12 @@ export async function updateUserUseCase(
if (error) return userError({ id: [error] })
}
const existingUsername = await getUserByUsername(username, tx)
if (existingUsername && existingUsername.id !== id) {
return userError({ username: ["Username already exists"] })
}
const existingEmail = await getUserByEmail(email, tx)
if (existingEmail && existingEmail.id !== id) {
return userError({ email: ["Email already exists"] })
}
await updateUser(id, { ...data, username, email, role, isActive }, tx)
await updateUser(id, { ...data, email, role, isActive }, tx)
return {
success: true,
-2
View File
@@ -40,7 +40,6 @@ async function main() {
AUTH_URL: baseUrl,
NEXTAUTH_URL: baseUrl,
ADMIN_BOOTSTRAP_ENABLED: "true",
ADMIN_USERNAME: "admin",
ADMIN_EMAIL: "admin@example.test",
ADMIN_NAME: "E2E Admin",
ADMIN_PASSWORD: "admin-password",
@@ -51,7 +50,6 @@ async function main() {
process.env.AUTH_URL = baseUrl
process.env.NEXTAUTH_URL = baseUrl
process.env.ADMIN_BOOTSTRAP_ENABLED = "true"
process.env.ADMIN_USERNAME = "admin"
process.env.ADMIN_EMAIL = "admin@example.test"
process.env.ADMIN_NAME = "E2E Admin"
process.env.ADMIN_PASSWORD = "admin-password"
-2
View File
@@ -14,7 +14,6 @@ function nextSuffix() {
export async function createTestUser(
prisma: PrismaClient,
overrides: Partial<{
username: string
email: string
name: string
role: UserRole
@@ -25,7 +24,6 @@ export async function createTestUser(
return prisma.user.create({
data: {
username: overrides.username ?? `test-user-${suffix}`,
email: overrides.email ?? `test-user-${suffix}@example.test`,
name: overrides.name ?? "Test User",
password: "hashed-password",
@@ -41,7 +41,6 @@ afterAll(async () => {
describe("user use-cases", () => {
it("creates a user with a hashed password", async () => {
const result = await createUserUseCase({
username: "new-user",
name: "New User",
email: "new-user@example.test",
password: "secure-password",
@@ -52,11 +51,10 @@ describe("user use-cases", () => {
expect(result).toEqual({ success: true })
const user = await prisma.user.findUniqueOrThrow({
where: { username: "new-user" },
where: { email: "new-user@example.test" },
})
expect(user).toMatchObject({
username: "new-user",
name: "New User",
email: "new-user@example.test",
role: "STAFF",
@@ -68,29 +66,13 @@ describe("user use-cases", () => {
).resolves.toBe(true)
})
it("rejects duplicate usernames and duplicate emails", async () => {
it("rejects duplicate emails", async () => {
await createTestUser(prisma, {
username: "existing-user",
email: "existing@example.test",
})
await expect(
createUserUseCase({
username: "existing-user",
name: "Duplicate Username",
email: "unique@example.test",
password: "secure-password",
role: "STAFF",
isActive: true,
}),
).resolves.toEqual({
success: false,
errors: { username: ["Username already exists"] },
})
await expect(
createUserUseCase({
username: "unique-user",
name: "Duplicate Email",
email: "existing@example.test",
password: "secure-password",
@@ -104,19 +86,17 @@ describe("user use-cases", () => {
await expect(prisma.user.count()).resolves.toBe(1)
await expect(
prisma.user.findUniqueOrThrow({ where: { username: "existing-user" } }),
prisma.user.findUniqueOrThrow({ where: { email: "existing@example.test" } }),
).resolves.toMatchObject({ email: "existing@example.test" })
})
it("updates a user while preserving uniqueness constraints", async () => {
const actor = await createTestUser(prisma, { role: "ADMIN" })
const user = await createTestUser(prisma, {
username: "editable-user",
email: "editable@example.test",
role: "STAFF",
})
const other = await createTestUser(prisma, {
username: "other-user",
email: "other@example.test",
role: "STAFF",
})
@@ -125,7 +105,6 @@ describe("user use-cases", () => {
updateUserUseCase({
actorId: actor.id,
id: user.id,
username: "edited-user",
name: "Edited User",
email: "edited@example.test",
role: "MANAGER",
@@ -136,7 +115,6 @@ describe("user use-cases", () => {
await expect(
prisma.user.findUniqueOrThrow({ where: { id: user.id } }),
).resolves.toMatchObject({
username: "edited-user",
name: "Edited User",
email: "edited@example.test",
role: "MANAGER",
@@ -147,21 +125,19 @@ describe("user use-cases", () => {
updateUserUseCase({
actorId: actor.id,
id: user.id,
username: other.username,
name: "Edited User",
email: "another-email@example.test",
email: other.email,
role: "MANAGER",
isActive: true,
}),
).resolves.toEqual({
success: false,
errors: { username: ["Username already exists"] },
errors: { email: ["Email already exists"] },
})
await expect(
prisma.user.findUniqueOrThrow({ where: { id: user.id } }),
).resolves.toMatchObject({
username: "edited-user",
email: "edited@example.test",
})
})
@@ -173,7 +149,6 @@ describe("user use-cases", () => {
updateUserUseCase({
actorId: admin.id,
id: admin.id,
username: admin.username,
name: admin.name,
email: admin.email,
role: "STAFF",
@@ -191,12 +166,10 @@ describe("user use-cases", () => {
it("protects the last active administrator but allows deactivation when another active admin exists", async () => {
const firstAdmin = await createTestUser(prisma, {
username: "first-admin",
email: "first-admin@example.test",
role: "ADMIN",
})
const staffActor = await createTestUser(prisma, {
username: "staff-actor",
email: "staff-actor@example.test",
role: "STAFF",
})
@@ -215,7 +188,6 @@ describe("user use-cases", () => {
})
const secondAdmin = await createTestUser(prisma, {
username: "second-admin",
email: "second-admin@example.test",
role: "ADMIN",
})
@@ -255,7 +227,6 @@ describe("user use-cases", () => {
it("resets a user password and rejects missing users", async () => {
const user = await createTestUser(prisma, {
username: "password-user",
email: "password-user@example.test",
role: "STAFF",
})
-10
View File
@@ -50,7 +50,6 @@ describe("user actions localization", () => {
describe("createUserAction", () => {
it("returns localized schema validation errors for invalid create input", async () => {
const result = await createUserAction({
username: "",
name: "",
email: "bad",
password: "short",
@@ -63,7 +62,6 @@ describe("user actions localization", () => {
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],
@@ -75,13 +73,11 @@ describe("user actions localization", () => {
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",
@@ -92,7 +88,6 @@ describe("user actions localization", () => {
expect(result).toEqual({
success: false,
errors: {
username: [es.admin.users.actions.duplicateUsername],
email: [es.admin.users.actions.duplicateEmail],
},
message: es.admin.users.actions.createFailure,
@@ -103,7 +98,6 @@ describe("user actions localization", () => {
mocks.createUserUseCase.mockResolvedValue({ success: true })
const result = await createUserAction({
username: "ada",
name: "Ada",
email: "ada@example.test",
password: "password1",
@@ -123,7 +117,6 @@ describe("user actions localization", () => {
it("returns localized schema validation errors for invalid update input", async () => {
const result = await updateUserAction({
id: "",
username: "",
name: "",
email: "bad",
role: "ADMIN",
@@ -136,7 +129,6 @@ describe("user actions localization", () => {
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],
},
@@ -156,7 +148,6 @@ describe("user actions localization", () => {
const result = await updateUserAction({
id: "user-1",
username: "admin",
name: "Admin",
email: "admin@example.test",
role: "MANAGER",
@@ -180,7 +171,6 @@ describe("user actions localization", () => {
const result = await updateUserAction({
id: "user-1",
username: "admin",
name: "Admin",
email: "admin@example.test",
role: "ADMIN",
+1 -3
View File
@@ -6,11 +6,10 @@ 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", () => {
it("localizes all 5 known use-case error strings to dictionary keys", () => {
expect(
localizeUserFieldErrors(
{
username: ["Username already exists"],
email: ["Email already exists"],
id: [
"User not found",
@@ -22,7 +21,6 @@ describe("user action message localization", () => {
actionCopy,
),
).toEqual({
username: [actionCopy.duplicateUsername],
email: [actionCopy.duplicateEmail],
id: [
actionCopy.notFound,
@@ -74,7 +74,6 @@ describe("new user form localization", () => {
// 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")
@@ -104,7 +103,6 @@ describe("new user form localization", () => {
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")
@@ -136,7 +134,6 @@ describe("edit user form localization", () => {
mocks.getUserProfileById.mockResolvedValue({
id: "user-1",
name: "Ada Lovelace",
username: "ada",
email: "ada@example.test",
role: "ADMIN",
isActive: true,
-8
View File
@@ -51,7 +51,6 @@ describe("user pages localization", () => {
{
id: "user-1",
name: "Ada Lovelace",
username: "ada",
email: "ada@example.test",
role: "ADMIN",
isActive: true,
@@ -59,7 +58,6 @@ describe("user pages localization", () => {
{
id: "user-2",
name: "Grace Hopper",
username: "grace",
email: "grace@example.test",
role: "STAFF",
isActive: false,
@@ -78,7 +76,6 @@ describe("user pages localization", () => {
// 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")
@@ -94,10 +91,8 @@ describe("user pages localization", () => {
// 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
@@ -133,7 +128,6 @@ describe("user pages localization", () => {
{
id: "user-1",
name: "Ada Lovelace",
username: "ada",
email: "ada@example.test",
role: "MANAGER",
isActive: true,
@@ -150,7 +144,6 @@ describe("user pages localization", () => {
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")
@@ -172,7 +165,6 @@ describe("user pages localization", () => {
{
id: "user-1",
name: "Test User",
username: "testuser",
email: "test@example.test",
role: "UNKNOWN_ROLE",
isActive: true,
@@ -12,7 +12,6 @@ describe("admin users dictionary", () => {
empty: "No users found.",
columns: {
name: "Name",
username: "Username",
email: "Email",
role: "Role",
status: "Status",
@@ -29,8 +28,6 @@ describe("admin users dictionary", () => {
expect(users.form).toEqual({
nameLabel: "Name",
namePlaceholder: "Full name",
usernameLabel: "Username",
usernamePlaceholder: "Username",
emailLabel: "Email",
emailPlaceholder: "user@example.com",
passwordLabel: "Password",
@@ -69,7 +66,6 @@ describe("admin users dictionary", () => {
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:
@@ -79,7 +75,6 @@ describe("admin users dictionary", () => {
})
expect(users.schema).toEqual({
usernameRequired: "Username is required",
nameRequired: "Name is required",
emailInvalid: "Invalid email",
passwordMinLength: "Password must be at least 8 characters",
@@ -100,7 +95,6 @@ describe("admin users dictionary", () => {
empty: "No se encontraron usuarios.",
columns: {
name: "Nombre",
username: "Usuario",
email: "Correo electrónico",
role: "Rol",
status: "Estado",
@@ -117,8 +111,6 @@ describe("admin users dictionary", () => {
expect(users.form).toEqual({
nameLabel: "Nombre",
namePlaceholder: "Nombre completo",
usernameLabel: "Usuario",
usernamePlaceholder: "Usuario",
emailLabel: "Correo electrónico",
emailPlaceholder: "usuario@ejemplo.com",
passwordLabel: "Contraseña",
@@ -157,7 +149,6 @@ describe("admin users dictionary", () => {
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:
@@ -167,7 +158,6 @@ describe("admin users dictionary", () => {
})
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",
+2 -2
View File
@@ -13,14 +13,14 @@ describe("i18n dictionaries", () => {
it("returns localized login copy for English and Spanish", () => {
expect(getDictionary("en").login).toEqual({
title: "Sign In",
usernameLabel: "Username",
emailLabel: "Email",
passwordLabel: "Password",
submitLabel: "Sign In",
})
expect(getDictionary("es").login).toEqual({
title: "Iniciar sesión",
usernameLabel: "Usuario",
emailLabel: "Correo electrónico",
passwordLabel: "Contraseña",
submitLabel: "Iniciar sesión",
})
+3 -9
View File
@@ -83,7 +83,6 @@ describe("core schemas", () => {
expect(
updateUserSchema.safeParse({
id: "user-id",
username: "user",
name: "User",
email: "user@example.test",
role: "ADMIN",
@@ -94,7 +93,6 @@ describe("core schemas", () => {
expect(
updateUserSchema.safeParse({
id: "",
username: "user",
name: "User",
email: "user@example.test",
role: "ADMIN",
@@ -110,13 +108,13 @@ describe("core schemas", () => {
)
expect(
signInSchema.safeParse({ username: "admin", password: "abc" }).success,
signInSchema.safeParse({ email: "admin@test.com", password: "abc" }).success,
).toBe(true)
expect(
signInSchema.safeParse({ username: "", password: "abc" }).success,
signInSchema.safeParse({ email: "", password: "abc" }).success,
).toBe(false)
expect(
signInSchema.safeParse({ username: "admin", password: "ab" }).success,
signInSchema.safeParse({ email: "admin@test.com", password: "ab" }).success,
).toBe(false)
})
@@ -152,7 +150,6 @@ describe("core schemas", () => {
it("validates user password, email, and role", () => {
expect(
createUserSchema.safeParse({
username: "user",
name: "User",
email: "user@example.test",
password: "password1",
@@ -163,7 +160,6 @@ describe("core schemas", () => {
expect(
createUserSchema.safeParse({
username: "user",
name: "User",
email: "bad-email",
password: "password1",
@@ -174,7 +170,6 @@ describe("core schemas", () => {
expect(
createUserSchema.safeParse({
username: "user",
name: "User",
email: "user@example.test",
password: "short",
@@ -185,7 +180,6 @@ describe("core schemas", () => {
expect(
createUserSchema.safeParse({
username: "user",
name: "User",
email: "user@example.test",
password: "password1",
-11
View File
@@ -12,7 +12,6 @@ import {
} 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",
@@ -20,7 +19,6 @@ const esCopy: UserSchemaCopy = {
}
const validCreateData = {
username: "admin",
name: "Admin User",
email: "admin@example.test",
password: "password1",
@@ -30,7 +28,6 @@ const validCreateData = {
const validUpdateData = {
id: "user-id",
username: "admin",
name: "Admin User",
email: "admin@example.test",
role: "ADMIN" as const,
@@ -40,7 +37,6 @@ const validUpdateData = {
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",
@@ -51,7 +47,6 @@ describe("user schema localization", () => {
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(
@@ -64,7 +59,6 @@ describe("user schema localization", () => {
it("buildCreateUserSchema with Spanish copy produces Spanish error messages", () => {
const result = buildCreateUserSchema(esCopy).safeParse({
username: "",
name: "",
email: "bad",
password: "short",
@@ -75,7 +69,6 @@ describe("user schema localization", () => {
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)
@@ -85,7 +78,6 @@ describe("user schema localization", () => {
it("buildUpdateUserSchema with default copy produces same validation as updateUserSchema", () => {
const result = buildUpdateUserSchema(defaultUserSchemaCopy).safeParse({
id: "",
username: "",
name: "",
email: "bad",
role: "INVALID",
@@ -96,7 +88,6 @@ describe("user schema localization", () => {
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")
}
@@ -105,7 +96,6 @@ describe("user schema localization", () => {
it("buildUpdateUserSchema with Spanish copy produces Spanish error messages", () => {
const result = buildUpdateUserSchema(esCopy).safeParse({
id: "",
username: "",
name: "",
email: "bad",
role: "INVALID",
@@ -116,7 +106,6 @@ describe("user schema localization", () => {
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)
}