diff --git a/next.config.ts b/next.config.ts index e9ffa30..d4447e9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,15 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + async redirects() { + return [ + { + source: "/", + destination: "/home", + permanent: true, + }, + ]; + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 6ef3317..52ebb4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "axios": "^1.10.0", "bcryptjs": "^3.0.2", + "email-validator": "^2.0.4", "jsonwebtoken": "^9.0.2", "next": "15.3.2", "nodemailer": "^7.0.3", @@ -5643,6 +5644,14 @@ "dev": true, "license": "ISC" }, + "node_modules/email-validator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/email-validator/-/email-validator-2.0.4.tgz", + "integrity": "sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ==", + "engines": { + "node": ">4.0" + } + }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", diff --git a/package.json b/package.json index 6619740..67c7c9e 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "axios": "^1.10.0", "bcryptjs": "^3.0.2", + "email-validator": "^2.0.4", "jsonwebtoken": "^9.0.2", "next": "15.3.2", "nodemailer": "^7.0.3", diff --git a/src/app/(main)/forgotpassword/page.tsx b/src/app/(main)/forgotpassword/page.tsx new file mode 100644 index 0000000..9683560 --- /dev/null +++ b/src/app/(main)/forgotpassword/page.tsx @@ -0,0 +1,64 @@ +"use client"; + +import React from "react"; +import axios from "axios"; +import toast, { Toaster } from "react-hot-toast"; +import { useState } from "react"; +import * as EmailValidator from "email-validator"; + +export default function ForgotPassword() { + const [email, setEmail] = useState(""); + + const handleSubmit = async () => { + try { + if (EmailValidator.validate(email)) { + const res = await axios.post("/api/users/forgotpassword", { email }); + toast.success(res.data.message); + } else { + toast.error("Invalid email address"); + } + } catch (error: any) { + if (error.response) { + toast.error(error.response.data.error || "An error occurred"); + } else { + toast.error("An error occurred"); + } + } + }; + + return ( +
+ +
+
+

+ Forgot your Password? 😭 +

+
+ + setEmail(e.target.value)} + value={email} + placeholder="youremail@example.com" + className="rounded bg-purple-500 w-full px-3 py-1 font-mono text-black" + > +
+ +
+ +
+
+
+
+ ); +} diff --git a/src/app/(main)/logout/page.tsx b/src/app/(main)/logout/page.tsx new file mode 100644 index 0000000..c3b3212 --- /dev/null +++ b/src/app/(main)/logout/page.tsx @@ -0,0 +1,67 @@ +"use client"; + +import React from "react"; +import axios from "axios"; +import { toast, Toaster } from "react-hot-toast"; +import { useRouter } from "next/navigation"; + +export default function Logout() { + const [processing, setProcessing] = React.useState(false); + const [buttonDisabled, setButtonDisabled] = React.useState(false); + const [loggedout, setLoggedout] = React.useState(false); + const [error, setError] = React.useState(false); + const router = useRouter(); + + //call logout function onClick + const onLogout = async () => { + setProcessing(true); + setButtonDisabled(true); + try { + const userdata = await axios.get("/api/users/logout"); + toast.success(userdata.data.message); + setLoggedout(true); + setTimeout(() => { + router.push("/home"); + }, 5000); + } catch (error: any) { + console.log(error); + setError(true); + toast.error("An error occurred during logout."); + } finally { + setProcessing(false); + } + }; + return ( +
+ +
+
+ {processing ? ( +

+ Logging out of your account... +

+ ) : loggedout ? ( +

Logged out successfully 😔

+ ) : error ? ( +

+ Logging out failed. Please try again or contact support. +

+ ) : ( +

+ Please stay pretty please with a cherry on top 🥺. +

+ )} + +
+
+
+ ); +} diff --git a/src/app/(main)/resetpassword/page.tsx b/src/app/(main)/resetpassword/page.tsx new file mode 100644 index 0000000..3b93442 --- /dev/null +++ b/src/app/(main)/resetpassword/page.tsx @@ -0,0 +1,128 @@ +"use client"; + +import axios from "axios"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import toast, { Toaster } from "react-hot-toast"; + +export default function ResetPasswordPage() { + const router = useRouter(); + const [password, setPassword] = useState(""); + const [confirmpassword, setConfirmPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [token, setToken] = useState(""); + + //search URL for the token that can only be received from nodemailer + useEffect(() => { + const urlToken = new URLSearchParams(window.location.search).get("token"); + console.log(urlToken); + setToken(urlToken || ""); + if (!urlToken) { + toast.error("Invalid reset request"); + } + }, []); + + const handleResetPassword = async () => { + if (password !== confirmpassword) { + toast.error("Passwords don't match"); + return; + } + + if (password.length < 8) { + toast.error("Password must be at least 8 characters"); + return; + } + + if (!/\d/.test(password)) { + toast.error("Password must contain at least one number"); + return; + } + + try { + setLoading(true); + const response = await axios.post("/api/users/resetpassword", { + token, + password, + }); + + if (response.data.success) { + toast.success("Password reset successfully!"); + setTimeout(() => router.push("/login"), 2000); + } else { + throw new Error(response.data.error || "Password reset failed"); + } + } catch (error: any) { + const errorMessage = + error.response?.data?.error || + error.message || + "Failed to reset password"; + toast.error(errorMessage); + + // If token is invalid, redirect after showing error + if (error.response?.status === 400) { + setTimeout(() => router.push("/forgotpassword"), 3000); + } + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+
+

+ Reset your Password 🤡 +

+
+ + setPassword(e.target.value)} + placeholder="••••••••••••••••" + className="rounded bg-purple-500 w-full px-3 py-1 font-mono text-black" + > +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="••••••••••••••••" + className="rounded bg-purple-500 w-full px-3 py-1 font-mono text-black" + > +
+ +
+ +
+
+
+
+ ); +} diff --git a/src/app/verifyemail/page.tsx b/src/app/(main)/verifyemail/page.tsx similarity index 86% rename from src/app/verifyemail/page.tsx rename to src/app/(main)/verifyemail/page.tsx index 17f8856..7a2ae4c 100644 --- a/src/app/verifyemail/page.tsx +++ b/src/app/(main)/verifyemail/page.tsx @@ -11,6 +11,7 @@ export default function VerifyEmail() { const VerifyUserEmail = async () => { try { + // user token from email sent from nodemailer const res = await axios.post("api/users/verifyemail", { token }); console.log(res.data); toast.success(res.data.message); @@ -23,6 +24,7 @@ export default function VerifyEmail() { } }; + //search the URL for the token useEffect(() => { const params = window.location.search.split("=")[1]; setToken(params || ""); @@ -36,7 +38,7 @@ export default function VerifyEmail() { }, [token]); return ( -
+

Email Verification

{loading ? ( diff --git a/src/app/(navgroup)/games/page.tsx b/src/app/(navgroup)/games/page.tsx new file mode 100644 index 0000000..69a6491 --- /dev/null +++ b/src/app/(navgroup)/games/page.tsx @@ -0,0 +1,12 @@ +"use client"; +import React from "react"; +import { Toaster } from "react-hot-toast"; + +export default function Games() { + return ( +
+ +
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/(navgroup)/home/page.tsx similarity index 65% rename from src/app/page.tsx rename to src/app/(navgroup)/home/page.tsx index f1bb9f0..a730e4b 100644 --- a/src/app/page.tsx +++ b/src/app/(navgroup)/home/page.tsx @@ -1,9 +1,10 @@ +"use client"; import React from "react"; import { Toaster } from "react-hot-toast"; export default function Home() { return ( -
+
diff --git a/src/app/(navgroup)/layout.tsx b/src/app/(navgroup)/layout.tsx new file mode 100644 index 0000000..8453a88 --- /dev/null +++ b/src/app/(navgroup)/layout.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; +import Navbar from "./navbar"; +import axios from "axios"; + +export default function NavbarLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const [isUserVerified, setIsUserVerified] = useState(false); + const pathname = usePathname(); + + useEffect(() => { + async function checkAuthStatus() { + try { + const response = await axios.get("/api/users/checkVerification"); + setIsUserVerified(response.data.isVerified); + } catch (error) { + console.error("Failed to fetch auth status:", error); + setIsUserVerified(false); + } + } + + checkAuthStatus(); + }, [pathname]); // Re-run when route changes + + return ( +
+ + {children} +
+ ); +} diff --git a/src/app/login/page.tsx b/src/app/(navgroup)/login/page.tsx similarity index 94% rename from src/app/login/page.tsx rename to src/app/(navgroup)/login/page.tsx index d34adf5..d6e453b 100644 --- a/src/app/login/page.tsx +++ b/src/app/(navgroup)/login/page.tsx @@ -4,6 +4,7 @@ import React, { useEffect } from "react"; import axios from "axios"; import { useRouter } from "next/navigation"; import toast, { Toaster } from "react-hot-toast"; +import * as EmailValidator from "email-validator"; export default function LoginPage() { const router = useRouter(); @@ -17,7 +18,8 @@ export default function LoginPage() { useEffect(() => { if ( - user.email.length > 0 && + // check if user email is valid, check if user password fits requirements + EmailValidator.validate(user.email) && user.password.length > 8 && /\d/.test(user.password) ) { @@ -39,6 +41,7 @@ export default function LoginPage() { setProcessing(true); const userdata = await axios.post("/api/users/login", user); toast.success(userdata.data.message); + //push to profile after login router.push("/profile"); } catch (error: any) { toast.error( diff --git a/src/app/navbar.tsx b/src/app/(navgroup)/navbar.tsx similarity index 70% rename from src/app/navbar.tsx rename to src/app/(navgroup)/navbar.tsx index 5102af4..e54c758 100644 --- a/src/app/navbar.tsx +++ b/src/app/(navgroup)/navbar.tsx @@ -24,15 +24,18 @@ export default function Navbar({ isVerified }: { isVerified: boolean }) {
    - {navbarItems.map((item) => ( - - {item} - - ))} + {navbarItems.map((item) => { + const hreftext = `/${item.toLowerCase()}`; + return ( + + {item} + + ); + })}
); diff --git a/src/app/(navgroup)/profile/page.tsx b/src/app/(navgroup)/profile/page.tsx new file mode 100644 index 0000000..9b31703 --- /dev/null +++ b/src/app/(navgroup)/profile/page.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { Toaster } from "react-hot-toast"; + +export default function Profile() { + return ( +
+ +
+
+ ); +} diff --git a/src/app/register/page.tsx b/src/app/(navgroup)/register/page.tsx similarity index 95% rename from src/app/register/page.tsx rename to src/app/(navgroup)/register/page.tsx index 9aa7e02..8426950 100644 --- a/src/app/register/page.tsx +++ b/src/app/(navgroup)/register/page.tsx @@ -4,6 +4,7 @@ import React, { useEffect } from "react"; import axios from "axios"; import { useRouter } from "next/navigation"; import toast, { Toaster } from "react-hot-toast"; +import * as EmailValidator from "email-validator"; export default function RegisterPage() { const router = useRouter(); @@ -16,9 +17,11 @@ export default function RegisterPage() { const [buttonDisabled, setButtonDisabled] = React.useState(true); const [processing, setProcessing] = React.useState(false); + //validate email before checking if the button can be activated to submit to server useEffect(() => { if ( user.email.length > 0 && + EmailValidator.validate(user.email) && user.password.length > 8 && /\d/.test(user.password) && user.username.length > 0 diff --git a/src/app/(navgroup)/users/page.tsx b/src/app/(navgroup)/users/page.tsx new file mode 100644 index 0000000..69a6491 --- /dev/null +++ b/src/app/(navgroup)/users/page.tsx @@ -0,0 +1,12 @@ +"use client"; +import React from "react"; +import { Toaster } from "react-hot-toast"; + +export default function Games() { + return ( +
+ +
+
+ ); +} diff --git a/src/app/api/users/checkVerification/route.ts b/src/app/api/users/checkVerification/route.ts new file mode 100644 index 0000000..a5893d0 --- /dev/null +++ b/src/app/api/users/checkVerification/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { connectToDatabase } from "@/dbConfig/dbConfig"; +import { cookies } from "next/headers"; +import jwt from "jsonwebtoken"; + +connectToDatabase(); + +//API route to check if the user is verified according to database +export async function GET() { + //get token from client + const token = (await cookies()).get("token")?.value || ""; + let isVerified = false; + try { + if (token) { + //verify token using public key + const decoded = jwt.verify(token, process.env.TOKEN_SECRET!) as { + isVerified: boolean; + }; + isVerified = decoded?.isVerified || false; + } + } catch (error: any) { + console.log("Invalid token:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } + return NextResponse.json({ isVerified }); +} diff --git a/src/app/api/users/forgotpassword/route.ts b/src/app/api/users/forgotpassword/route.ts index a566d01..d30838f 100644 --- a/src/app/api/users/forgotpassword/route.ts +++ b/src/app/api/users/forgotpassword/route.ts @@ -4,9 +4,10 @@ import { NextRequest, NextResponse } from "next/server"; import { sendMail } from "@/helpers/mailer"; connectToDatabase(); - +//API route to search for email in database and send mail if user has forgotten password export async function POST(request: NextRequest) { try { + //request recieved would be JSON body of email from the forgotpassword page.tsx const reqBody = await request.json(); const { email } = reqBody; console.log(email); @@ -15,13 +16,14 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "User not found" }, { status: 404 }); } - sendMail({ email: user.email, emailType: "REST", userId: user._id }); + await sendMail({ email: user.email, emailType: "RESET", userId: user._id }); return NextResponse.json({ message: "Reset Link Send successfully", success: true, }); } catch (error: any) { + console.log(error); return NextResponse.json({ error: error.message }, { status: 500 }); } } diff --git a/src/app/api/users/login/route.ts b/src/app/api/users/login/route.ts index 064f43e..6663527 100644 --- a/src/app/api/users/login/route.ts +++ b/src/app/api/users/login/route.ts @@ -20,7 +20,7 @@ export async function POST(request: NextRequest) { } if (await bcrypt.compare(password, user.password)) { - //create token + //compare the password hash and the hash of the user password from login request const tokenData = { id: user._id, diff --git a/src/app/api/users/logout/route.ts b/src/app/api/users/logout/route.ts index ad37d38..827bfdb 100644 --- a/src/app/api/users/logout/route.ts +++ b/src/app/api/users/logout/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; +//API request ot logout, wipe client cookie export async function GET() { try { const response = NextResponse.json({ diff --git a/src/app/api/users/me/route.ts b/src/app/api/users/me/route.ts index 64f374d..75f5b16 100644 --- a/src/app/api/users/me/route.ts +++ b/src/app/api/users/me/route.ts @@ -5,6 +5,7 @@ import { getTokenData } from "@/helpers/gettokendata"; connectToDatabase(); +//API request to get user information, by searching for unique user id export async function GET(request: NextRequest) { try { const userid = await getTokenData(request); diff --git a/src/app/api/users/register/route.ts b/src/app/api/users/register/route.ts index 834f907..87bc1cc 100644 --- a/src/app/api/users/register/route.ts +++ b/src/app/api/users/register/route.ts @@ -6,8 +6,10 @@ import { sendMail } from "@/helpers/mailer"; connectToDatabase(); +//API route to add a new user to the database from the register frontend page export async function POST(request: NextRequest) { try { + //json body of email and password from register page const reqBody = await request.json(); const { username, email, password } = reqBody; console.log(reqBody); @@ -29,8 +31,10 @@ export async function POST(request: NextRequest) { const newUser = new User({ username, email, password: hashedpassword }); + //add new user to the database const savedUser = await newUser.save(); + //send an email to user to verify their email afterwards await sendMail({ email: email, emailType: "VERIFY", diff --git a/src/app/api/users/resetpassword/route.ts b/src/app/api/users/resetpassword/route.ts index c689fd5..6e61d7e 100644 --- a/src/app/api/users/resetpassword/route.ts +++ b/src/app/api/users/resetpassword/route.ts @@ -5,13 +5,16 @@ import bcrypt from "bcryptjs"; connectToDatabase(); +//API route to reset password, only reset password if token si valid from user database export async function POST(req: NextRequest) { try { + //json body containing password and token from url const reqBody = await req.json(); const { token, password } = reqBody; - const currentTime = new Date(); // Current time + const currentTime = Date.now(); // Current time // Find the user with the token and ensure the token is still valid (not expired) + const user = await User.findOne({ forgotpasswordToken: token, forgotpasswordTokenExpiry: { $gt: currentTime }, // Check if verifyTokenExpiry is greater than currentTime diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ed026f7..b11cb30 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; -import Navbar from "@/app/navbar"; -import { cookies } from "next/headers"; -import jwt from "jsonwebtoken"; +import "@/app/globals.css"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -20,37 +17,15 @@ export const metadata: Metadata = { description: "Generated by create next app", }; -export default async function RootLayout({ +export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - const token = (await cookies()).get("token")?.value || ""; - let isUserVerified = false; - - try { - // The middleware already protects routes, this check is for UI rendering. - if (token) { - const decoded = jwt.verify(token, process.env.TOKEN_SECRET!) as { - isVerified: boolean; - }; - if (decoded) { - isUserVerified = decoded.isVerified; - } - } - } catch (error) { - // Token is invalid or expired - console.log("Invalid token:", error); - isUserVerified = false; - } - return ( - +
- {children}
diff --git a/src/helpers/gettokendata.ts b/src/helpers/gettokendata.ts index b1a61d1..51deb35 100644 --- a/src/helpers/gettokendata.ts +++ b/src/helpers/gettokendata.ts @@ -1,6 +1,7 @@ import { NextRequest } from "next/server"; import jwt from "jsonwebtoken"; +// get data from jwt after verifying export const getTokenData = (request: NextRequest) => { try { const token = request.cookies.get("token")?.value || ""; diff --git a/src/helpers/mailer.ts b/src/helpers/mailer.ts index 2d93768..e19c308 100644 --- a/src/helpers/mailer.ts +++ b/src/helpers/mailer.ts @@ -5,30 +5,30 @@ import nodemailer from "nodemailer"; connectToDatabase(); +//use nodemailer to send email to users that sign up with their email +//the type of email to send is either RESET or VERIFY export async function sendMail({ email, emailType, userId }: any) { const hashedToken = await bcrypt.hash(userId.toString(), 10); - const currentTime = new Date(); - const expiryTime = new Date(currentTime.getTime() + 3600000); // 1 hour from now - console.log("Current time:", currentTime); - console.log("Expiry time:", expiryTime); + const tokenExpiry = Date.now() + 3600000; try { - if (emailType === "REST") { + if (emailType === "RESET") { await User.findByIdAndUpdate(userId, { forgotpasswordToken: hashedToken, - forgotpasswordTokenExpiry: expiryTime, + forgotpasswordTokenExpiry: tokenExpiry, }); } else if (emailType === "VERIFY") { await User.findByIdAndUpdate(userId, { verifyToken: hashedToken, - verifyTokenExpiry: expiryTime, + verifyTokenExpiry: tokenExpiry, }); } } catch (error: any) { throw new Error(error.message); } + //nodemailer create a sender using the email provided const transport = nodemailer.createTransport({ service: "Gmail", host: process.env.SMTP_HOST, @@ -43,14 +43,14 @@ export async function sendMail({ email, emailType, userId }: any) { const mailOptions = { from: process.env.SENDER_EMAIL, to: email, - subject: emailType === "REST" ? "Reset Password" : "Verify Email", + subject: emailType === "RESET" ? "Reset Password" : "Verify Email", html: `

Click here to ${ - emailType === "REST" ? "reset your password" : "verify your email" + emailType === "RESET" ? "reset your password" : "verify your email" }

http://localhost:3000/${ - emailType === "REST" ? "resetpassword" : "verifyemail" + emailType === "RESET" ? "resetpassword" : "verifyemail" }?token=${hashedToken}

`, }; diff --git a/src/middleware.ts b/src/middleware.ts index c50b774..98b3015 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +//middleware between server and client to control where user can access depending on if they're logged or not export function middleware(request: NextRequest) { const path = request.nextUrl.pathname; // Get the path from the url request diff --git a/src/models/userModel.js b/src/models/userModel.js index 893cdc3..91ba8c0 100644 --- a/src/models/userModel.js +++ b/src/models/userModel.js @@ -34,8 +34,8 @@ const userSchema = new mongoose.Schema({ type: Boolean, default: false, }, - forgotPasswordToken: String, - forgotPasswordTokenExpiry: Date, + forgotpasswordToken: String, + forgotpasswordTokenExpiry: Date, verifyToken: String, verifyTokenExpiry: Date, }); diff --git a/src/tests/mailer.test.ts b/src/tests/mailer.test.ts index f3d37ab..0bfcd06 100644 --- a/src/tests/mailer.test.ts +++ b/src/tests/mailer.test.ts @@ -50,7 +50,7 @@ describe("sendMail", () => { //Check that the user model was updated with the right token and expiry expect(mockedUser.findByIdAndUpdate).toHaveBeenCalledWith(options.userId, { verifyToken: "hashed-token-from-mock", - verifyTokenExpiry: expect.any(Date), + verifyTokenExpiry: expect.any(Number), }); // Check that the nodemailer transport was created with the correct config @@ -84,7 +84,7 @@ describe("sendMail", () => { it("should send a password reset email correctly", async () => { const options = { email: "reset@example.com", - emailType: "REST" as const, + emailType: "RESET" as const, userId: "user-id-456", }; @@ -92,7 +92,7 @@ describe("sendMail", () => { expect(mockedUser.findByIdAndUpdate).toHaveBeenCalledWith(options.userId, { forgotpasswordToken: "hashed-token-from-mock", - forgotpasswordTokenExpiry: expect.any(Date), + forgotpasswordTokenExpiry: expect.any(Number), }); const sendMailMock = (mockedNodemailer.createTransport as jest.Mock).mock