feat(auth): integrate zod validation for login and registration
All checks were successful
Deploy Flowstate to VPS / deploy (push) Successful in 1m14s
All checks were successful
Deploy Flowstate to VPS / deploy (push) Successful in 1m14s
This commit is contained in:
parent
5a45643331
commit
3812afab5a
5 changed files with 91 additions and 71 deletions
|
|
@ -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
48
lib/schemas/auth.ts
Normal 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
4
package-lock.json
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue