feat(auth): integrate zod validation for login and registration
All checks were successful
Deploy Flowstate to VPS / deploy (push) Successful in 1m14s

This commit is contained in:
Chneemann 2026-08-23 10:37:14 +02:00
parent 5a45643331
commit 3812afab5a
No known key found for this signature in database
5 changed files with 91 additions and 71 deletions

View file

@ -1,6 +1,6 @@
/** /**
* @file route.ts * @file api/auth/register/route.ts
* @description API route handler for user registration, managing name validation, email normalization, password matching, hashing, and auto sign-in. * @description API route handler for user registration utilizing Zod for validation, bcrypt hashing, and auto sign-in.
*/ */
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
@ -10,15 +10,15 @@ import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { signIn } from "@/auth"; import { signIn } from "@/auth";
import { AVAILABLE_COLORS } from "@/types/user"; import { AVAILABLE_COLORS } from "@/types/user";
import { registerSchema } from "@/lib/schemas/auth";
/** /**
* Handles POST requests for new user registration. * Handles POST requests to register a new user.
* Validates input fields, checks password confirmation, normalizes email, checks for existing users, * Validates request payload via Zod, checks for email uniqueness, hashes the password, creates the user record, and attempts auto sign-in.
* assigns a random profile color, hashes the password, saves the user to the database, and performs an automatic sign-in.
* *
* @async * @async
* @param {Request} request - The incoming HTTP request containing the registration payload in JSON format. * @param {Request} request - The incoming HTTP request containing registration data.
* @returns {Promise<NextResponse>} A JSON response indicating registration success or an error message with the appropriate HTTP status code. * @returns {Promise<NextResponse>} A JSON response indicating registration success or an error message.
*/ */
export async function POST(request: Request) { export async function POST(request: Request) {
try { 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 { const {
firstName, firstName,
lastName, lastName,
email: rawEmail, email: rawEmail,
password, password,
confirmPassword, } = validationResult.data;
} = 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@]+$/;
const email = rawEmail.toLowerCase().trim(); const email = rawEmail.toLowerCase().trim();
if (!emailRegex.test(email)) { // 2. Check whether the email address already exists
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
const existing = await db.query.usersTable.findFirst({ const existing = await db.query.usersTable.findFirst({
where: eq(usersTable.email, email), 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 = const randomColor =
AVAILABLE_COLORS[Math.floor(Math.random() * AVAILABLE_COLORS.length)]; 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); const hashedPassword = await bcrypt.hash(password, 10);
await db.insert(usersTable).values({ await db.insert(usersTable).values({
firstName: firstName.trim(), firstName: firstName.trim(),
@ -111,7 +73,7 @@ export async function POST(request: Request) {
color: randomColor, color: randomColor,
}); });
// Auto sign-in // 5. Automatic Login After Successful Registration
const signInResult = await signIn("credentials", { const signInResult = await signIn("credentials", {
email, email,
password, password,

48
lib/schemas/auth.ts Normal file
View file

@ -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"],
});

4
package-lock.json generated
View file

@ -16,7 +16,8 @@
"postgres": "^3.4.9", "postgres": "^3.4.9",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"swr": "^2.5.0" "swr": "^2.5.0",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@ -8595,7 +8596,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"

View file

@ -17,7 +17,8 @@
"postgres": "^3.4.9", "postgres": "^3.4.9",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"swr": "^2.5.0" "swr": "^2.5.0",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

View file

@ -3,6 +3,7 @@
* @description Authentication service providing helper functions for guest credentials, sign-in, and registration. * @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"; 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 * @async
* @param {string} email - The user's email address. * @param {string} email - The user's email address.
* @param {string} password - The user's account password. * @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) { 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 { try {
const res = await signIn("credentials", { const res = await signIn("credentials", {
email, email: validationResult.data.email,
password, password: validationResult.data.password,
redirect: false, 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 * @async
* @param {Record<string, any>} payload - The user registration form payload containing user details and passwords. * @param {Record<string, any>} payload - The user registration form payload containing credentials and user details.
* @returns {Promise<{ success?: boolean; error?: string }>} An object indicating success or describing the registration error. * @returns {Promise<{ success?: boolean; error?: string }>} An object indicating successful registration or an error message.
*/ */
export async function registerUser(payload: Record<string, any>) { export async function registerUser(payload: Record<string, any>) {
if (payload.password !== payload.confirmPassword) { const validationResult = registerSchema.safeParse(payload);
return { error: "Passwords do not match" };
if (!validationResult.success) {
return { error: validationResult.error.issues[0].message };
} }
try { try {
const response = await fetch("/api/auth/register", { const response = await fetch("/api/auth/register", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(validationResult.data),
}); });
const contentType = response.headers.get("content-type"); const contentType = response.headers.get("content-type");