Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions app/api/auth/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import crypto from "crypto";
import prisma from "@/lib/prisma";
import { checkRateLimit } from "@/lib/rateLimit";
import { sendVerificationEmail } from "@/lib/email";
import { signupSchema } from "@/lib/validations/auth";

const REGISTER_LIMIT = 5;
const REGISTER_WINDOW_MS = 60 * 60 * 1000;

const registerSchema = signupSchema.pick({ email: true, password: true });

export async function POST(req: Request) {
const ip =
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
Expand All @@ -29,10 +32,15 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
}

const normalizedEmail = email.toLowerCase().trim();
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&]).{8,}$/;

if (!passwordRegex.test(password)) {
const result = registerSchema.safeParse({ email, password });
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors;
if (fieldErrors.email) {
return NextResponse.json(
{ error: "Invalid email address" },
{ status: 400 },
);
}
return NextResponse.json(
{ error: "Password does not meet requirements" },
{ status: 400 },
Expand All @@ -46,7 +54,7 @@ export async function POST(req: Request) {
user = await prisma.user.create({
data: {
name,
email: normalizedEmail,
email: result.data.email,
password: hashedPassword,
// emailVerified intentionally left null — set only after verification
},
Expand All @@ -69,18 +77,18 @@ export async function POST(req: Request) {

await prisma.verificationToken.create({
data: {
identifier: normalizedEmail,
identifier: result.data.email,
token,
expires,
},
});

// Send verification email — non-blocking in dev if SMTP not configured
try {
await sendVerificationEmail(normalizedEmail, token);
await sendVerificationEmail(result.data.email, token);
} catch {
// Email sending failure should not block registration
console.error("Failed to send verification email to", normalizedEmail);
console.error("Failed to send verification email to", result.data.email);
}

return NextResponse.json(
Expand All @@ -95,4 +103,4 @@ export async function POST(req: Request) {
{ status: 500 },
);
}
}
}
11 changes: 1 addition & 10 deletions app/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Spinner } from "@/components/ui/spinner";
import { getCsrfToken } from "@/lib/csrfClient";
import { useCsrf } from "@/lib/useCsrf";
import { PLATFORMS } from "@/lib/constants";
import { getPasswordError } from "@/lib/validations/auth";

import { Navbar } from "../components/Navbar";

Expand All @@ -28,16 +29,6 @@ export default function RegisterPage() {
const [googleLoading, setGoogleLoading] = useState(false);
const [githubLoading, setGithubLoading] = useState(false);

const getPasswordError = (value: string) => {
if (value.length < 8) return "Must be at least 8 characters";
if (!/[A-Z]/.test(value)) return "Must include one uppercase letter";
if (!/[a-z]/.test(value)) return "Must include one lowercase letter";
if (!/\d/.test(value)) return "Must include one number";
if (!/[@$!%*?&]/.test(value)) return "Must include one special character";

return null;
};

const error = getPasswordError(password);

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
Expand Down
103 changes: 103 additions & 0 deletions lib/validations/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import test from "node:test";

import { signupSchema, getPasswordError } from "@/lib/validations/auth";

test("signupSchema accepts valid email and password", () => {
const result = signupSchema.safeParse({
name: "John Doe",
email: "john@example.com",
password: "Secure@123",
});

assert.equal(result.success, true);
});

test("signupSchema rejects invalid email", () => {
const result = signupSchema.safeParse({
name: "John Doe",
email: "not-an-email",
password: "Secure@123",
});

assert.equal(result.success, false);
if (!result.success) {
const emailErrors = result.error.flatten().fieldErrors.email;
assert.ok(emailErrors?.some((e) => e.includes("Invalid email")));
}
});

test("signupSchema rejects empty email", () => {
const result = signupSchema.safeParse({
name: "John Doe",
email: "",
password: "Secure@123",
});

assert.equal(result.success, false);
});

test("signupSchema accepts valid password", () => {
const result = signupSchema.safeParse({
name: "John Doe",
email: "john@example.com",
password: "Abcdef1@",
});

assert.equal(result.success, true);
});

test("signupSchema rejects password below minimum length", () => {
const result = signupSchema.safeParse({
name: "John Doe",
email: "john@example.com",
password: "Ab1@",
});

assert.equal(result.success, false);
if (!result.success) {
const passwordErrors = result.error.flatten().fieldErrors.password;
assert.ok(passwordErrors?.some((e) => e.includes("at least 8")));
}
});

test("signupSchema rejects missing required fields", () => {
const result = signupSchema.safeParse({});

assert.equal(result.success, false);
if (!result.success) {
const errors = result.error.flatten().fieldErrors;
assert.ok(errors.name);
assert.ok(errors.email);
assert.ok(errors.password);
}
});

test("getPasswordError returns null for valid password", () => {
assert.equal(getPasswordError("Secure@123"), null);
});

test("getPasswordError returns error for password below minimum length", () => {
const error = getPasswordError("Ab1@");
assert.ok(error?.includes("at least 8 characters"));
});

test("getPasswordError returns error for missing uppercase", () => {
const error = getPasswordError("secure@123");
assert.ok(error?.includes("uppercase"));
});

test("getPasswordError returns error for missing lowercase", () => {
const error = getPasswordError("SECURE@123");
assert.ok(error?.includes("lowercase"));
});

test("getPasswordError returns error for missing number", () => {
const error = getPasswordError("Secure@abc");
assert.ok(error?.includes("number"));
});

test("getPasswordError returns error for missing special character", () => {
const error = getPasswordError("Secure123");
assert.ok(error?.includes("special character"));
});
20 changes: 19 additions & 1 deletion lib/validations/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ const passwordSchema = z
* - password: Must satisfy the password policy
*/
export const signupSchema = z.object({
name: z.string().min(2, 'Name is required'),
email: z.string().email('Invalid email address'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least 1 uppercase letter')
.regex(/[a-z]/, 'Password must contain at least 1 lowercase letter')
.regex(/[0-9]/, 'Password must contain at least 1 number')
.regex(/[@$!%*?&]/, 'Password must contain at least 1 special character'),
});

export function getPasswordError(password: string): string | null {
if (password.length < 8) return 'Must be at least 8 characters';
if (!/[A-Z]/.test(password)) return 'Must include one uppercase letter';
if (!/[a-z]/.test(password)) return 'Must include one lowercase letter';
if (!/\d/.test(password)) return 'Must include one number';
if (!/[@$!%*?&]/.test(password)) return 'Must include one special character';
return null;
}
name: z
.string()
.min(2, 'Name is required'),
Expand All @@ -46,4 +64,4 @@ export const signupSchema = z.object({
* Use this type to ensure consistency between validation
* and application logic.
*/
export type SignupInput = z.infer<typeof signupSchema>;
export type SignupInput = z.infer<typeof signupSchema>;