diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 15a4cdb..ba54a39 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -1,6 +1,6 @@ /** - * @file route.ts - * @description API route handler for user registration, managing name validation, email normalization, password matching, hashing, and auto sign-in. + * @file api/auth/register/route.ts + * @description API route handler for user registration utilizing Zod for validation, bcrypt hashing, and auto sign-in. */ import { NextResponse } from "next/server"; @@ -10,15 +10,15 @@ import { eq } from "drizzle-orm"; import bcrypt from "bcryptjs"; import { signIn } from "@/auth"; import { AVAILABLE_COLORS } from "@/types/user"; +import { registerSchema } from "@/lib/schemas/auth"; /** - * Handles POST requests for new user registration. - * Validates input fields, checks password confirmation, normalizes email, checks for existing users, - * assigns a random profile color, hashes the password, saves the user to the database, and performs an automatic sign-in. + * Handles POST requests to register a new user. + * Validates request payload via Zod, checks for email uniqueness, hashes the password, creates the user record, and attempts auto sign-in. * * @async - * @param {Request} request - The incoming HTTP request containing the registration payload in JSON format. - * @returns {Promise} A JSON response indicating registration success or an error message with the appropriate HTTP status code. + * @param {Request} request - The incoming HTTP request containing registration data. + * @returns {Promise} A JSON response indicating registration success or an error message. */ export async function POST(request: Request) { try { @@ -31,61 +31,23 @@ export async function POST(request: Request) { ); } + // 1. Validation with Zod (checks types, lengths, email format, and password matching) + const validationResult = registerSchema.safeParse(body); + + if (!validationResult.success) { + const errorMessage = validationResult.error.issues[0].message; + return NextResponse.json({ message: errorMessage }, { status: 400 }); + } + const { firstName, lastName, email: rawEmail, password, - confirmPassword, - } = body; - - // Validation of all required fields - if ( - !firstName || - typeof firstName !== "string" || - !lastName || - typeof lastName !== "string" || - !rawEmail || - typeof rawEmail !== "string" || - !password || - typeof password !== "string" || - !confirmPassword || - typeof confirmPassword !== "string" - ) { - return NextResponse.json( - { message: "All fields are required" }, - { status: 400 }, - ); - } - - // Check if passwords match - if (password !== confirmPassword) { - return NextResponse.json( - { message: "Passwords do not match" }, - { status: 400 }, - ); - } - - // Normalize email - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + } = validationResult.data; const email = rawEmail.toLowerCase().trim(); - if (!emailRegex.test(email)) { - return NextResponse.json( - { message: "Please provide a valid email address" }, - { status: 400 }, - ); - } - - // Password length check - if (password.length < 8) { - return NextResponse.json( - { message: "Password must be at least 8 characters long" }, - { status: 400 }, - ); - } - - // Check existing user + // 2. Check whether the email address already exists const existing = await db.query.usersTable.findFirst({ where: eq(usersTable.email, email), }); @@ -97,11 +59,11 @@ export async function POST(request: Request) { ); } - // Select a random default color from the palette + // 3. Select a random profile color const randomColor = AVAILABLE_COLORS[Math.floor(Math.random() * AVAILABLE_COLORS.length)]; - // Hash password & save user + // 4. Hash the password and store it in the database const hashedPassword = await bcrypt.hash(password, 10); await db.insert(usersTable).values({ firstName: firstName.trim(), @@ -111,7 +73,7 @@ export async function POST(request: Request) { color: randomColor, }); - // Auto sign-in + // 5. Automatic Login After Successful Registration const signInResult = await signIn("credentials", { email, password, diff --git a/lib/schemas/auth.ts b/lib/schemas/auth.ts new file mode 100644 index 0000000..a0a1f05 --- /dev/null +++ b/lib/schemas/auth.ts @@ -0,0 +1,48 @@ +/** + * @file lib/schemas/auth.ts + * @description Zod validation schemas for authentication forms, including login and user registration data constraints. + */ + +import { z } from "zod"; + +/** + * Zod validation schema for user login credentials. + */ +export const loginSchema = z.object({ + email: z + .string() + .max(255, "Email is too long") + .pipe(z.email("Please provide a valid email address")), + password: z + .string() + .min(1, "Password is required") + .max(72, "Password is too long"), +}); + +/** + * Zod validation schema for new user registration payloads, including password confirmation matching. + */ +export const registerSchema = z + .object({ + firstName: z + .string() + .min(1, "First name is required") + .max(50, "First name is too long"), + lastName: z + .string() + .min(1, "Last name is required") + .max(50, "Last name is too long"), + email: z + .string() + .max(255, "Email is too long") + .pipe(z.email("Please provide a valid email address")), + password: z + .string() + .min(8, "Password must be at least 8 characters long") + .max(72, "Password is too long (max 72 characters)"), + confirmPassword: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); diff --git a/package-lock.json b/package-lock.json index 570cec7..d2bf194 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "postgres": "^3.4.9", "react": "19.2.4", "react-dom": "19.2.4", - "swr": "^2.5.0" + "swr": "^2.5.0", + "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -8595,7 +8596,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 0313af8..78874c6 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "postgres": "^3.4.9", "react": "19.2.4", "react-dom": "19.2.4", - "swr": "^2.5.0" + "swr": "^2.5.0", + "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/services/auth.service.ts b/services/auth.service.ts index 40fea10..8435333 100644 --- a/services/auth.service.ts +++ b/services/auth.service.ts @@ -3,6 +3,7 @@ * @description Authentication service providing helper functions for guest credentials, sign-in, and registration. */ +import { loginSchema, registerSchema } from "@/lib/schemas/auth"; import { signIn } from "next-auth/react"; /** @@ -23,18 +24,24 @@ export async function getGuestCredentials() { } /** - * Authenticates a user using credentials via NextAuth. + * Authenticates a user using email and password credentials, performing client-side validation first. * * @async * @param {string} email - The user's email address. * @param {string} password - The user's account password. - * @returns {Promise<{ success?: boolean; error?: string }>} An object indicating success or describing the authentication error. + * @returns {Promise<{ success?: boolean; error?: string }>} An object indicating sign-in success or an error message. */ export async function loginUser(email: string, password: string) { + const validationResult = loginSchema.safeParse({ email, password }); + + if (!validationResult.success) { + return { error: validationResult.error.issues[0].message }; + } + try { const res = await signIn("credentials", { - email, - password, + email: validationResult.data.email, + password: validationResult.data.password, redirect: false, }); @@ -59,22 +66,24 @@ export async function loginUser(email: string, password: string) { } /** - * Registers a new user account via the registration API endpoint. + * Registers a new user account by validating the form data and submitting it to the backend registration route. * * @async - * @param {Record} payload - The user registration form payload containing user details and passwords. - * @returns {Promise<{ success?: boolean; error?: string }>} An object indicating success or describing the registration error. + * @param {Record} payload - The user registration form payload containing credentials and user details. + * @returns {Promise<{ success?: boolean; error?: string }>} An object indicating successful registration or an error message. */ export async function registerUser(payload: Record) { - if (payload.password !== payload.confirmPassword) { - return { error: "Passwords do not match" }; + const validationResult = registerSchema.safeParse(payload); + + if (!validationResult.success) { + return { error: validationResult.error.issues[0].message }; } try { const response = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), + body: JSON.stringify(validationResult.data), }); const contentType = response.headers.get("content-type");