From f891835533cff91f2fbe665cfeafb035e9703ccd Mon Sep 17 00:00:00 2001 From: LimJiaYan Date: Mon, 14 Oct 2024 14:01:44 +0800 Subject: [PATCH 01/11] Implement forgot password in UI - Add API in user service to send verification email. - Display success message prompting user to check their inbox after successful email send. --- backend/user-service/.env | 4 + .../controller/email-controller.js | 36 ++++ backend/user-service/index.js | 1 + backend/user-service/routes/email-routes.js | 9 + frontend/.env.local | 9 +- frontend/app/forgot-password/page.tsx | 192 ++++++++++++++++++ frontend/app/page.tsx | 11 +- 7 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 backend/user-service/controller/email-controller.js create mode 100644 backend/user-service/routes/email-routes.js create mode 100644 frontend/app/forgot-password/page.tsx diff --git a/backend/user-service/.env b/backend/user-service/.env index c995ca9a5c..1eeefee062 100644 --- a/backend/user-service/.env +++ b/backend/user-service/.env @@ -5,5 +5,9 @@ PORT=3001 # Will use cloud MongoDB Atlas database ENV=PROD +# email details +EMAIL_USER=peerprep.no.reply@outlook.com +EMAIL_PASS=peerprepg44 + # Secret for creating JWT signature JWT_SECRET=you-can-replace-this-with-your-own-secret diff --git a/backend/user-service/controller/email-controller.js b/backend/user-service/controller/email-controller.js new file mode 100644 index 0000000000..e295467fd2 --- /dev/null +++ b/backend/user-service/controller/email-controller.js @@ -0,0 +1,36 @@ +import nodemailer from 'nodemailer'; + +export const sendVerificationEmail = async (req, res) => { + try { + const { email, link } = req.body; + + // Set up the transporter for nodemailer + const transporter = nodemailer.createTransport({ + host: 'smtp.office365.com', + service: 'outlook', + port: 465, + secure: true, + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, + }); + + // Email options + const mailOptions = { + from: process.env.EMAIL_USER, + to: email, + subject: 'Verify your email for PeerPrep', + html: `

Click here to verify your email.

`, + }; + + // Send the email + await transporter.sendMail(mailOptions); + + // Return success response + return res.status(200).json({ message: 'Verification email sent successfully!' }); + } catch (error) { + console.error('Error sending email:', error); + return res.status(500).json({ error: 'Failed to send verification email' }); + } +}; diff --git a/backend/user-service/index.js b/backend/user-service/index.js index 24a5835874..0b2a5c24c0 100644 --- a/backend/user-service/index.js +++ b/backend/user-service/index.js @@ -32,6 +32,7 @@ app.use((req, res, next) => { app.use("/users", userRoutes); app.use("/auth", authRoutes); +app.use('/email', emailRoutes); app.get("/", (req, res, next) => { console.log("Sending Greetings!"); diff --git a/backend/user-service/routes/email-routes.js b/backend/user-service/routes/email-routes.js new file mode 100644 index 0000000000..2b64cedcec --- /dev/null +++ b/backend/user-service/routes/email-routes.js @@ -0,0 +1,9 @@ +import express from 'express'; + +import { sendVerificationEmail } from '../controller/email-controller'; + +const router = express.Router(); + +router.post('/send-verification-email', sendVerificationEmail); + +export default router; \ No newline at end of file diff --git a/frontend/.env.local b/frontend/.env.local index eb3dc037fe..e1eb082b63 100644 --- a/frontend/.env.local +++ b/frontend/.env.local @@ -1,5 +1,9 @@ PUBLIC_URL=http://localhost +# Frontend servce +FRONTEND_PORT=3000 +NEXT_PUBLIC_FRONTEND_URL=$PUBLIC_URL:$FRONTEND_PORT + # Question service QUESTION_API_PORT=2000 NEXT_PUBLIC_QUESTION_API_BASE_URL=$PUBLIC_URL:$QUESTION_API_PORT/questions @@ -12,4 +16,7 @@ NEXT_PUBLIC_USER_API_USERS_URL=$USER_API_BASE_URL/users # Matching service MATCHING_API_PORT=3002 -NEXT_PUBLIC_MATCHING_API_URL=$PUBLIC_URL:$MATCHING_API_PORT/matching \ No newline at end of file +NEXT_PUBLIC_MATCHING_API_URL=$PUBLIC_URL:$MATCHING_API_PORT/matching + +NEXT_PUBLIC_EMAIL_USER=peerprep.no.reply@outlook.com +NEXT_PUBLIC_EMAIL_PASS=peerprepg44 \ No newline at end of file diff --git a/frontend/app/forgot-password/page.tsx b/frontend/app/forgot-password/page.tsx new file mode 100644 index 0000000000..6f23a95016 --- /dev/null +++ b/frontend/app/forgot-password/page.tsx @@ -0,0 +1,192 @@ +"use client" + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import Link from "next/link"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { useRouter } from "next/navigation" +import { useState } from "react"; +import { AlertCircle, LoaderCircle } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; + +const formSchema = z.object({ + email: z.string().min(1, "Email is required").email({ message: "Invalid email address" }), +}) + +export default function ForgottenPassword() { + const router = useRouter() + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(""); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + }, + }); + + let adminJWT: string | null = null; + let tokenTimestamp: number | null = null; + async function getAdminJWT() { + // Check if the token is cached and not expired + const tokenValidFor = 24 * 60 * 60 * 1000; + const currentTime = Date.now(); + + if (adminJWT && tokenTimestamp && (currentTime - tokenTimestamp < tokenValidFor)) { + return adminJWT; + } + + // If no token or token expired, login as admin to get a new token + const loginResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/login`, { + method: "POST", + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + "email": process.env.NEXT_PUBLIC_EMAIL_USER, + "password": process.env.NEXT_PUBLIC_EMAIL_PASS + }), + }); + + if (!loginResponse.ok) { + setError("Failed to reset password. Please try again."); + throw new Error(`Failed to fetch admin JWT token. Status: ${loginResponse.status}, Message: ${loginResponse.statusText}`); + } + + const loginData = await loginResponse.json(); + adminJWT = loginData.data.accessToken; + tokenTimestamp = currentTime; + return adminJWT; + } + + async function onSubmit(values: z.infer) { + // Placeholder for auth to user service + try { + await form.trigger(); + if (!form.formState.isValid) { + return; + } + + setIsLoading(true); + setError(""); // Clear any previous errors + setSuccessMessage(""); // Clear previous success message + + // Get admin JWT token (cached or freshly fetched) + const adminJWT = await getAdminJWT(); + + // Fetch user data with admin privileges + const userResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_USERS_URL}`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${adminJWT}` + } + }); + + if (!userResponse.ok) { + setError("Failed to reset password. Please try again."); + throw new Error(`Failed to fetch users from user service to reset password. Status: ${userResponse.status}, Message: ${userResponse.statusText}`); + } + + // check if the email exists in db + const userMatch = (await userResponse.json()).data.filter((user: { + id: string; + username: string; + email: string; + isAdmin: boolean; + createdAt: string; + }) => values.email == user.email) + + if (userMatch.length == 0) { + setError("No user found with the provided email address."); + throw new Error("User not found in the database with the provided email during password reset."); + } + + // Send verification email + const emailResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_URL}/email/send-verification-email`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: values.email, + link: `${process.env.NEXT_PUBLIC_FRONTEND_URL}/resetPassword/username=${encodeURIComponent(userMatch.username)}` + }), + }); + + if (!emailResponse.ok) { + setError("There was an error sending the verification email. Please try again."); + throw new Error(`Failed to send verification email`); + } + + const result = await emailResponse.json(); + setSuccessMessage("A link has been sent to your email. Please check your inbox to reset your password."); // Set the success message + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } + } + + return ( +
+
+
+ + Password Reset + +
+ {error && ( + + + Error + + {error} + + + )} +
+
+ + ( + + Enter you email: + + + + + + )} + /> + + + + {successMessage &&

{successMessage}

} +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 873a3299af..ae3ae6c8ec 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -106,7 +106,7 @@ export default function Login() { Email - + @@ -138,6 +138,15 @@ export default function Login() { +
+ Forgotten password? + + Reset it + +
Don't have an account?{" "} Date: Mon, 14 Oct 2024 19:06:35 +0800 Subject: [PATCH 02/11] Implement email verification for signup - Updated user schema to include isVerified field (default: false) - Updated user service API to handle new email verification logic and isVerified checks --- backend/user-service/.env | 4 +- backend/user-service/README.md | 42 +++++++ .../controller/auth-controller.js | 4 + .../controller/email-controller.js | 48 +++++-- .../controller/user-controller.js | 12 +- backend/user-service/index.js | 1 + .../middleware/basic-access-control.js | 4 +- backend/user-service/model/repository.js | 3 +- backend/user-service/model/user-model.js | 4 + backend/user-service/package-lock.json | 12 +- backend/user-service/package.json | 3 +- backend/user-service/routes/email-routes.js | 5 +- frontend/.env.local | 4 +- frontend/app/EmailVerification/page.tsx | 93 ++++++++++++++ frontend/app/forgot-password/page.tsx | 7 +- frontend/app/signup/page.tsx | 117 ++++++++++++++---- 16 files changed, 316 insertions(+), 47 deletions(-) create mode 100644 frontend/app/EmailVerification/page.tsx diff --git a/backend/user-service/.env b/backend/user-service/.env index 1eeefee062..1c23c624b8 100644 --- a/backend/user-service/.env +++ b/backend/user-service/.env @@ -6,8 +6,8 @@ PORT=3001 ENV=PROD # email details -EMAIL_USER=peerprep.no.reply@outlook.com -EMAIL_PASS=peerprepg44 +EMAIL_USER=peerprep.no.reply@gmail.com +EMAIL_PASS=vvkj xhtv twsf roeh # Secret for creating JWT signature JWT_SECRET=you-can-replace-this-with-your-own-secret diff --git a/backend/user-service/README.md b/backend/user-service/README.md index be27594dbc..e8f166d69a 100644 --- a/backend/user-service/README.md +++ b/backend/user-service/README.md @@ -61,6 +61,7 @@ |-----------------------------|-------------------------------------------------------| | 201 (Created) | User created successfully, created user data returned | | 400 (Bad Request) | Missing fields | + | 403 (Forbidden) | User hasn't verified their email | | 409 (Conflict) | Duplicate username or email encountered | | 500 (Internal Server Error) | Database or server error | @@ -253,6 +254,7 @@ | 200 (OK) | Login successful, JWT token and user data returned | | 400 (Bad Request) | Missing fields | | 401 (Unauthorized) | Incorrect email or password | + | 403 (Unauthorized) | User hasn't verified their email | | 500 (Internal Server Error) | Database or server error | ### Verify Token @@ -269,4 +271,44 @@ |-----------------------------|----------------------------------------------------| | 200 (OK) | Token verified, authenticated user's data returned | | 401 (Unauthorized) | Missing/invalid/expired JWT | + | 500 (Internal Server Error) | Database or server error | + +### Send Email Verification + +- This endpoint allows sending a verification email to the user after they sign up. The email contains a unique verification link. +- HTTP Method: `POST` +- Endpoint: http://localhost:3001/email//send-verification-email +- Body + - Required: `email` (string), `title` (string), `body` (string) + + ```json + { + "email": "sample@gmail.com", + "password": "Confirm Your Email for PeerPrep", + "body": "Click the link below to verify your email: " + } + ``` + +- Responses: + + | Response Code | Explanation | + |-----------------------------|----------------------------------------------------| + | 200 (OK) | Verification email sent successfully. | + | 400 (Bad Request) | Missing or invalid fields (email, title, body). | + | 500 (Internal Server Error) | Database or server error | + +### Verify Email + +- This endpoint verifies a user's email when they click the verification link provided in the email. +- HTTP Method: `GET` +- Endpoint: http://localhost:3001/email/verify-email +- Headers + - Required: `Authorization: Bearer ` + +- Responses: + + | Response Code | Explanation | + |-----------------------------|----------------------------------------------------| + | 200 (OK) | Email verified successfully. | + | 401 (Unauthorized) | Missing/invalid id | | 500 (Internal Server Error) | Database or server error | \ No newline at end of file diff --git a/backend/user-service/controller/auth-controller.js b/backend/user-service/controller/auth-controller.js index d49517bf70..11ab1c384e 100644 --- a/backend/user-service/controller/auth-controller.js +++ b/backend/user-service/controller/auth-controller.js @@ -12,6 +12,10 @@ export async function handleLogin(req, res) { return res.status(401).json({ message: "Wrong email and/or password" }); } + if (!user.isVerified) { + return res.status(403).json({ message: 'Please verify your email before logging in.' }); + } + const match = await bcrypt.compare(password, user.password); if (!match) { return res.status(401).json({ message: "Wrong email and/or password" }); diff --git a/backend/user-service/controller/email-controller.js b/backend/user-service/controller/email-controller.js index e295467fd2..3260e6af0d 100644 --- a/backend/user-service/controller/email-controller.js +++ b/backend/user-service/controller/email-controller.js @@ -1,15 +1,23 @@ +import bcrypt from "bcrypt"; import nodemailer from 'nodemailer'; +import { isValidObjectId } from "mongoose"; +import { + findUserById as _findUserById, + updateUserById as _updateUserById +} from "../model/repository.js"; export const sendVerificationEmail = async (req, res) => { try { - const { email, link } = req.body; + const { email, title, body } = req.body; + + // Check if all required fields are present + if (!email || !title || !body) { + return res.status(400).json({ message: 'Missing required fields: email, title, and body are all required.' }); + } // Set up the transporter for nodemailer const transporter = nodemailer.createTransport({ - host: 'smtp.office365.com', - service: 'outlook', - port: 465, - secure: true, + service: 'gmail', auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS, @@ -20,17 +28,41 @@ export const sendVerificationEmail = async (req, res) => { const mailOptions = { from: process.env.EMAIL_USER, to: email, - subject: 'Verify your email for PeerPrep', - html: `

Click here to verify your email.

`, + subject: title, + html: body, }; // Send the email await transporter.sendMail(mailOptions); // Return success response - return res.status(200).json({ message: 'Verification email sent successfully!' }); + return res.status(200).json({ message: 'Verification email sent successfully!'}); } catch (error) { console.error('Error sending email:', error); return res.status(500).json({ error: 'Failed to send verification email' }); } }; + +export const verifyEmail = async (req, res) => { + try { + const userId = req.query.id; + if (!isValidObjectId(userId)) { + return res.status(404).json({ message: `User ${userId} not found` }); + } + + const user = await _findUserById(userId); + const salt = bcrypt.genSaltSync(10); + console.log(user.password) + let hashedPassword = bcrypt.hashSync(user.password, salt); + + if (!user) { + return res.status(404).json({ message: `User ${userId} not found` }); + } else { + const updatedUser = await _updateUserById(userId, user.username, user.email, hashedPassword, true); + return res.status(200).json({ message: `Found user` }); + } + } catch (err) { + console.error('Error verifying user email:', err); + return res.status(500).json({ message: "Unknown error when getting user!" }); + } +}; \ No newline at end of file diff --git a/backend/user-service/controller/user-controller.js b/backend/user-service/controller/user-controller.js index 985a83384f..152750ae76 100644 --- a/backend/user-service/controller/user-controller.js +++ b/backend/user-service/controller/user-controller.js @@ -18,6 +18,14 @@ export async function createUser(req, res) { if (username && email && password) { const existingUser = await _findUserByUsernameOrEmail(username, email); if (existingUser) { + // Check if the user exists but is not verified + if (!existingUser.isVerified && username == existingUser.username && email == existingUser.email) { + // Return a specific message indicating the user is not verified + return res.status(403).json({ + message: "This user has already registered but has not yet verified their email. Please check your inbox for the verification link." + }); + } + // Return conflict error if the user is already verified return res.status(409).json({ message: "username or email already exists" }); } @@ -69,7 +77,7 @@ export async function getAllUsers(req, res) { export async function updateUser(req, res) { try { - const { username, email, password } = req.body; + const { username, email, password, isVerified = false } = req.body; if (username || email || password) { const userId = req.params.id; if (!isValidObjectId(userId)) { @@ -95,7 +103,7 @@ export async function updateUser(req, res) { const salt = bcrypt.genSaltSync(10); hashedPassword = bcrypt.hashSync(password, salt); } - const updatedUser = await _updateUserById(userId, username, email, hashedPassword); + const updatedUser = await _updateUserById(userId, username, email, hashedPassword, isVerified); return res.status(200).json({ message: `Updated data for user ${userId}`, data: formatUserResponse(updatedUser), diff --git a/backend/user-service/index.js b/backend/user-service/index.js index 0b2a5c24c0..4c38d9310d 100644 --- a/backend/user-service/index.js +++ b/backend/user-service/index.js @@ -3,6 +3,7 @@ import cors from "cors"; import userRoutes from "./routes/user-routes.js"; import authRoutes from "./routes/auth-routes.js"; +import emailRoutes from "./routes/email-routes.js"; const app = express(); diff --git a/backend/user-service/middleware/basic-access-control.js b/backend/user-service/middleware/basic-access-control.js index bb92665710..51c4dd62a1 100644 --- a/backend/user-service/middleware/basic-access-control.js +++ b/backend/user-service/middleware/basic-access-control.js @@ -20,7 +20,7 @@ export function verifyAccessToken(req, res, next) { return res.status(401).json({ message: "Authentication failed" }); } - req.user = { id: dbUser.id, username: dbUser.username, email: dbUser.email, isAdmin: dbUser.isAdmin }; + req.user = { id: dbUser.id, username: dbUser.username, email: dbUser.email, isAdmin: dbUser.isAdmin, isVerified: dbUser.isVerified }; next(); }); } @@ -45,4 +45,4 @@ export function verifyIsOwnerOrAdmin(req, res, next) { } return res.status(403).json({ message: "Not authorized to access this resource" }); -} +} \ No newline at end of file diff --git a/backend/user-service/model/repository.js b/backend/user-service/model/repository.js index 5d56b91e71..b729957593 100644 --- a/backend/user-service/model/repository.js +++ b/backend/user-service/model/repository.js @@ -40,7 +40,7 @@ export async function findAllUsers() { return UserModel.find(); } -export async function updateUserById(userId, username, email, password) { +export async function updateUserById(userId, username, email, password, isVerified) { return UserModel.findByIdAndUpdate( userId, { @@ -48,6 +48,7 @@ export async function updateUserById(userId, username, email, password) { username, email, password, + isVerified, }, }, { new: true }, // return the updated user diff --git a/backend/user-service/model/user-model.js b/backend/user-service/model/user-model.js index df37491d09..23d273feee 100644 --- a/backend/user-service/model/user-model.js +++ b/backend/user-service/model/user-model.js @@ -26,6 +26,10 @@ const UserModelSchema = new Schema({ required: true, default: false, }, + isVerified: { + type: Boolean, + default: false, + } }); export default mongoose.model("UserModel", UserModelSchema); diff --git a/backend/user-service/package-lock.json b/backend/user-service/package-lock.json index e3dfb3c57d..53e7aee62b 100644 --- a/backend/user-service/package-lock.json +++ b/backend/user-service/package-lock.json @@ -14,7 +14,8 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "jsonwebtoken": "^9.0.2", - "mongoose": "^8.5.4" + "mongoose": "^8.5.4", + "nodemailer": "^6.9.15" }, "devDependencies": { "nodemon": "^3.1.4" @@ -1418,6 +1419,15 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/nodemailer": { + "version": "6.9.15", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.15.tgz", + "integrity": "sha512-AHf04ySLC6CIfuRtRiEYtGEXgRfa6INgWGluDhnxTZhHSKvrBu7lc1VVchQ0d8nPc4cFaZoPq8vkyNoZr0TpGQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.7.tgz", diff --git a/backend/user-service/package.json b/backend/user-service/package.json index b3ac4db247..838fffe567 100644 --- a/backend/user-service/package.json +++ b/backend/user-service/package.json @@ -21,6 +21,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "jsonwebtoken": "^9.0.2", - "mongoose": "^8.5.4" + "mongoose": "^8.5.4", + "nodemailer": "^6.9.15" } } diff --git a/backend/user-service/routes/email-routes.js b/backend/user-service/routes/email-routes.js index 2b64cedcec..2b5b96cfc1 100644 --- a/backend/user-service/routes/email-routes.js +++ b/backend/user-service/routes/email-routes.js @@ -1,9 +1,12 @@ import express from 'express'; -import { sendVerificationEmail } from '../controller/email-controller'; +import { sendVerificationEmail, verifyEmail } from '../controller/email-controller.js'; +import { verifyAccessToken } from "../middleware/basic-access-control.js"; + const router = express.Router(); router.post('/send-verification-email', sendVerificationEmail); +router.get('/verify-email', verifyEmail); export default router; \ No newline at end of file diff --git a/frontend/.env.local b/frontend/.env.local index e1eb082b63..cb1f5a60aa 100644 --- a/frontend/.env.local +++ b/frontend/.env.local @@ -13,10 +13,12 @@ USER_API_PORT=3001 USER_API_BASE_URL=$PUBLIC_URL:$USER_API_PORT NEXT_PUBLIC_USER_API_AUTH_URL=$USER_API_BASE_URL/auth NEXT_PUBLIC_USER_API_USERS_URL=$USER_API_BASE_URL/users +NEXT_PUBLIC_USER_API_EMAIL_URL=$USER_API_BASE_URL/email + # Matching service MATCHING_API_PORT=3002 NEXT_PUBLIC_MATCHING_API_URL=$PUBLIC_URL:$MATCHING_API_PORT/matching -NEXT_PUBLIC_EMAIL_USER=peerprep.no.reply@outlook.com +NEXT_PUBLIC_EMAIL_USER=peerprep.no.reply@gmail.com NEXT_PUBLIC_EMAIL_PASS=peerprepg44 \ No newline at end of file diff --git a/frontend/app/EmailVerification/page.tsx b/frontend/app/EmailVerification/page.tsx new file mode 100644 index 0000000000..5821ef6533 --- /dev/null +++ b/frontend/app/EmailVerification/page.tsx @@ -0,0 +1,93 @@ +"use client" + +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { Button } from "@/components/ui/button"; +import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/components/ui/card"; +import Link from 'next/link'; + +export default function VerifyEmail() { + const [status, setStatus] = useState<'success' | 'error' | 'loading'>('loading'); + const [message, setMessage] = useState(''); + + // Get the search params from the URL + const searchParams = useSearchParams(); + const id = searchParams.get('id'); + + useEffect(() => { + const verifyUserEmail = async () => { + if (!id) { + setMessage('Invalid or missing id.'); + setStatus('error'); + return; + } + + try { + // Send token to backend for verification + const response = await fetch(`${process.env.NEXT_PUBLIC_USER_API_EMAIL_URL}/verify-email?id=${id}`, { + headers: { + 'Content-Type': 'application/json', + } + }); + + if (response.ok) { + setMessage('Email verified successfully! You can now log in.'); + setStatus('success'); + } else { + const errorData = await response.json(); + setMessage(errorData.message || 'Verification failed. Please try again.'); + setStatus('error'); + } + } catch (error) { + setMessage('An error occurred. Please try again.'); + setStatus('error'); + } + }; + + verifyUserEmail(); + }, [id]); + + return ( +
+ + {status === 'loading' && ( + +

Verifying your email, please wait...

+
+ )} + + {status === 'success' && ( + <> + + Email Verified! + + +

{message}

+
+ + + + + + + )} + + {status === 'error' && ( + <> + + Verification Failed + + +

{message}

+
+ + + + + + + )} +
+
+ ); +}; diff --git a/frontend/app/forgot-password/page.tsx b/frontend/app/forgot-password/page.tsx index 6f23a95016..aaabc796d4 100644 --- a/frontend/app/forgot-password/page.tsx +++ b/frontend/app/forgot-password/page.tsx @@ -68,7 +68,7 @@ export default function ForgottenPassword() { adminJWT = loginData.data.accessToken; tokenTimestamp = currentTime; return adminJWT; - } + } async function onSubmit(values: z.infer) { // Placeholder for auth to user service @@ -113,7 +113,7 @@ export default function ForgottenPassword() { } // Send verification email - const emailResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_URL}/email/send-verification-email`, { + const emailResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_EMAIL_URL}/send-verification-email`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -129,8 +129,7 @@ export default function ForgottenPassword() { throw new Error(`Failed to send verification email`); } - const result = await emailResponse.json(); - setSuccessMessage("A link has been sent to your email. Please check your inbox to reset your password."); // Set the success message + setSuccessMessage("A link has been sent to your email. Please check your inbox to reset your password."); } catch (error) { console.error(error); } finally { diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index e863dc4f64..853aeb2e46 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -16,7 +16,7 @@ import { FormLabel, FormMessage, } from "@/components/ui/form" -import { AlertCircle, Info, LoaderCircle } from "lucide-react" +import { AlertCircle, Info, LoaderCircle, CheckCircle } from "lucide-react" import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" @@ -40,6 +40,8 @@ export default function Signup() { const router = useRouter() const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const form = useForm>({ resolver: zodResolver(formSchema), @@ -65,6 +67,40 @@ export default function Signup() { } }, [watchConfirm, form]); + let adminJWT: string | null = null; + let tokenTimestamp: number | null = null; + async function getAdminJWT() { + // Check if the token is cached and not expired + const tokenValidFor = 24 * 60 * 60 * 1000; + const currentTime = Date.now(); + + if (adminJWT && tokenTimestamp && (currentTime - tokenTimestamp < tokenValidFor)) { + return adminJWT; + } + + // If no token or token expired, login as admin to get a new token + const loginResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/login`, { + method: "POST", + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + "email": process.env.NEXT_PUBLIC_EMAIL_USER, + "password": process.env.NEXT_PUBLIC_EMAIL_PASS + }), + }); + + if (!loginResponse.ok) { + setError("Failed to reset password. Please try again."); + throw new Error(`Failed to fetch admin JWT token. Status: ${loginResponse.status}, Message: ${loginResponse.statusText}`); + } + + const loginData = await loginResponse.json(); + adminJWT = loginData.data.accessToken; + tokenTimestamp = currentTime; + return adminJWT; + } + async function onSubmit(values: z.infer) { // Placeholder for auth to user service try { @@ -74,6 +110,8 @@ export default function Signup() { } setIsLoading(true); + setError(""); // Clear any previous errors + setSuccessMessage(""); // Clear previous success message const { confirm, ...signUpValues } = values; const signUpResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_USERS_URL}`, { @@ -93,38 +131,60 @@ export default function Signup() { } else if (signUpResponse.status == 500) { setError("Database or server error. Please try again."); throw new Error("Database or server error: " + signUpResponse.statusText); - } else if (!signUpResponse.ok) { + } else if (signUpResponse.status == 403) { + setSuccessMessage("You have already registered but haven't verified your email. Please check your inbox for the verification link."); + }else if (!signUpResponse.ok) { setError("There was an error signing up. Please try again."); throw new Error("Error signing up: " + signUpResponse.statusText); } - // Sign up doesn't return JWT token so we need to login after signing up - const { username, ...loginValues } = values; - const loginResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/login`, { - method: "POST", + + const responseData = await signUpResponse.json(); + const id = responseData.data.id; + + // Send verification email + const emailResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_EMAIL_URL}/send-verification-email`, { + method: 'POST', headers: { - 'Content-Type': 'application/json', + 'Content-Type': 'application/json', }, - body: JSON.stringify(loginValues), + body: JSON.stringify({ + email: values.email, + title: 'Confirm Your Email Address for PeerPrep', + body: ` + + + +

Hi ${values.username},

+

Thank you for signing up for PeerPrep! Before you can start using your account, we need to verify your email address.

+

Please confirm your email by clicking the button below:

+ Verify Email +

If the button doesn't work, copy and paste this URL into your browser:

+

${process.env.NEXT_PUBLIC_FRONTEND_URL}/EmailVerification?token=${encodeURIComponent(id)}

+

This link will expire in [time, e.g., 24 hours].

+

If you didn't sign up for this account, you can safely ignore this email.

+

Best regards,
The PeerPrep Team

+ + ` + }), }); - - if (loginResponse.status == 400) { - setError("Missing email or password."); - throw new Error("Missing email or password: " + loginResponse.statusText); - } else if (loginResponse.status == 401) { - setError("Incorrect email or password."); - throw new Error("Incorrect email or password: " + loginResponse.statusText); - } else if (loginResponse.status == 500) { - setError("Database or server error. Please try again."); - throw new Error("Database or server error: " + loginResponse.statusText); - } else if (!loginResponse.ok) { - setError("There was an error logging in. Please try again."); - throw new Error("Error logging in: " + loginResponse.statusText); + console.log("email response: ", emailResponse); + const adminJWT = await getAdminJWT(); + if (!emailResponse.ok) { + console.log(emailResponse) + setError("There was an error sending the verification email. Please try again."); + await fetch(`${process.env.NEXT_PUBLIC_USER_API_USERS_URL}/${id}`, { + method: "DELETE", + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${adminJWT}`, + 'userId' : id, + } + }); + throw new Error(`Failed to send verification email`); } - const responseData = await loginResponse.json(); - console.log(responseData.data["accessToken"]); - router.push("/question-repo"); + setSuccessMessage("Thank you for signing up! A verification link has been sent to your email. Please check your inbox to verify your account."); } catch (error) { console.error(error); } finally { @@ -147,6 +207,15 @@ export default function Signup() {

+ {successMessage && ( + + + Success + + {successMessage} + + + )} {error && ( From 262b107d6cc59fe89699c4d410a92ac637f5ba53 Mon Sep 17 00:00:00 2001 From: LimJiaYan Date: Tue, 15 Oct 2024 21:25:26 +0800 Subject: [PATCH 03/11] Implement email verification for sign up --- backend/user-service/.env | 10 +- backend/user-service/README.md | 86 +++- .../controller/email-controller.js | 318 +++++++++++++-- .../controller/user-controller.js | 35 +- backend/user-service/index.js | 3 + backend/user-service/model/user-model.js | 10 +- backend/user-service/package-lock.json | 287 +++++++++++++ backend/user-service/package.json | 1 + .../user-service/public/images/favicon.ico | Bin 0 -> 25931 bytes backend/user-service/routes/email-routes.js | 7 +- backend/user-service/routes/user-routes.js | 3 + frontend/app/EmailVerification/page.tsx | 93 ----- .../forgot-password/reset-password/page.tsx | 228 +++++++++++ frontend/app/auth/sign-up/layout.tsx | 13 + frontend/app/auth/sign-up/page.tsx | 378 ++++++++++++++++++ frontend/app/auth/sign-up/success-dialog.tsx | 101 +++++ .../app/auth/sign-up/verify-email/page.tsx | 144 +++++++ frontend/app/forgot-password/page.tsx | 191 --------- frontend/app/page.tsx | 9 +- frontend/app/signup/page.tsx | 339 ---------------- 20 files changed, 1577 insertions(+), 679 deletions(-) create mode 100644 backend/user-service/public/images/favicon.ico delete mode 100644 frontend/app/EmailVerification/page.tsx create mode 100644 frontend/app/auth/forgot-password/reset-password/page.tsx create mode 100644 frontend/app/auth/sign-up/layout.tsx create mode 100644 frontend/app/auth/sign-up/page.tsx create mode 100644 frontend/app/auth/sign-up/success-dialog.tsx create mode 100644 frontend/app/auth/sign-up/verify-email/page.tsx delete mode 100644 frontend/app/forgot-password/page.tsx delete mode 100644 frontend/app/signup/page.tsx diff --git a/backend/user-service/.env b/backend/user-service/.env index 1c23c624b8..eefe8de202 100644 --- a/backend/user-service/.env +++ b/backend/user-service/.env @@ -6,8 +6,14 @@ PORT=3001 ENV=PROD # email details -EMAIL_USER=peerprep.no.reply@gmail.com -EMAIL_PASS=vvkj xhtv twsf roeh +# EMAIL_USER=peerprep.no.reply@gmail.com +# EMAIL_PASS=vvkj xhtv twsf roeh + +# EMAIL_USER=jiayan.yanyany@gmail.com +# EMAIL_PASS=mpyy rjyg rnke tqxk + +EMAIL_USER=peerprep9@gmail.com +EMAIL_PASS=vhgj idnk fhme ooim # Secret for creating JWT signature JWT_SECRET=you-can-replace-this-with-your-own-secret diff --git a/backend/user-service/README.md b/backend/user-service/README.md index e8f166d69a..c7dcdfcdbf 100644 --- a/backend/user-service/README.md +++ b/backend/user-service/README.md @@ -65,6 +65,28 @@ | 409 (Conflict) | Duplicate username or email encountered | | 500 (Internal Server Error) | Database or server error | +### Check User Exist by Email or Id + +- This endpoint allows checking if a user exists in the database based on their email address. + +- HTTP Method: `GET` + +- Endpoint: http://localhost:3001/users/check?email={email} + +- Parameters + - Required: at least one of `email`, `id` path parameter + - Example: `http://localhost:3001/users?email=sample@example.com` + +- Responses: + + | Response Code | Explanation | + |-----------------------------|----------------------------------------------------------| + | 200 (OK) | User found | + | 400 (Bad Request) | Bad request, parameter is missing. | + | 404 (Not Found) | User with the specified email not found | + | 500 (Internal Server Error) | Database or server error | + + ### Get User - This endpoint allows retrieval of a single user's data from the database using the user's ID. @@ -135,13 +157,14 @@ - Required: `userId` path parameter - Body - - At least one of the following fields is required: `username` (string), `email` (string), `password` (string) + - At least one of the following fields is required: `username` (string), `email` (string), `password` (string), `isVerified` (boolean) ```json { "username": "SampleUserName", "email": "sample@gmail.com", - "password": "SecurePassword" + "password": "SecurePassword", + "isVerified": true, } ``` @@ -273,19 +296,43 @@ | 401 (Unauthorized) | Missing/invalid/expired JWT | | 500 (Internal Server Error) | Database or server error | -### Send Email Verification +### Send Email -- This endpoint allows sending a verification email to the user after they sign up. The email contains a unique verification link. +- This endpoint allows sending an email to the user. - HTTP Method: `POST` - Endpoint: http://localhost:3001/email//send-verification-email - Body - - Required: `email` (string), `title` (string), `body` (string) + - Required: `email` (string), `title` (string), `html` (string) + + ```json + { + "email": "sample@gmail.com", + "title": "Confirm Your Email for PeerPrep", + "html": "

Click the link below to verify your email:

" + } + ``` + +- Responses: + + | Response Code | Explanation | + |-----------------------------|----------------------------------------------------| + | 200 (OK) | Verification email sent successfully. | + | 400 (Bad Request) | Missing or invalid fields (email, title, html). | + | 500 (Internal Server Error) | Database or server error | + +### Send OTP Email + +- This endpoint allows sending an email containing OTP to the user after they sign up. +- HTTP Method: `POST` +- Endpoint: http://localhost:3001/email//send-otp-email +- Body + - Required: `email` (string), `username` (string) ```json { "email": "sample@gmail.com", "password": "Confirm Your Email for PeerPrep", - "body": "Click the link below to verify your email: " + "html": "

Click the link below to verify your email:

" } ``` @@ -294,21 +341,30 @@ | Response Code | Explanation | |-----------------------------|----------------------------------------------------| | 200 (OK) | Verification email sent successfully. | - | 400 (Bad Request) | Missing or invalid fields (email, title, body). | + | 400 (Bad Request) | Missing or invalid fields (email, title, html). | + | 404 (Not Found) | User with specified email not found | | 500 (Internal Server Error) | Database or server error | -### Verify Email +### Send Verification Link Email -- This endpoint verifies a user's email when they click the verification link provided in the email. -- HTTP Method: `GET` -- Endpoint: http://localhost:3001/email/verify-email -- Headers - - Required: `Authorization: Bearer ` +- This endpoint sends a verification email containing a verification link to the user after they sign up. +- HTTP Method: `POST` +- Endpoint: http://localhost:3001/email//send-verification-email +- Body + - Required: `email` (string), `username` (string), `verificationLink` (string) + + ```json + { + "email": "sample@gmail.com", + "username": "us", + "verificationLink": "http://localhost:3001/" + } + ``` - Responses: | Response Code | Explanation | |-----------------------------|----------------------------------------------------| - | 200 (OK) | Email verified successfully. | - | 401 (Unauthorized) | Missing/invalid id | + | 200 (OK) | Verification email sent successfully. | + | 400 (Bad Request) | Missing or invalid fields (email, title, html). | | 500 (Internal Server Error) | Database or server error | \ No newline at end of file diff --git a/backend/user-service/controller/email-controller.js b/backend/user-service/controller/email-controller.js index 3260e6af0d..108a787c68 100644 --- a/backend/user-service/controller/email-controller.js +++ b/backend/user-service/controller/email-controller.js @@ -1,23 +1,125 @@ import bcrypt from "bcrypt"; +import crypto from 'crypto'; import nodemailer from 'nodemailer'; import { isValidObjectId } from "mongoose"; import { findUserById as _findUserById, + findUserByEmail as _findUserByEmail, updateUserById as _updateUserById } from "../model/repository.js"; -export const sendVerificationEmail = async (req, res) => { - try { - const { email, title, body } = req.body; +function generateOtp() { + return crypto.randomInt(100000, 999999).toString(); // Generates a 6-digit OTP +} + +// Send OTP to email and save in DB +export async function sendOtp(req, res) { + const { email } = req.body; + + try { + const user = await _findUserByEmail(email); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + const otp = generateOtp(); + const otpExpiresAt = Date.now() + 10 * 60 * 1000; // OTP valid for 10 minutes + + await _updateUserById(user.userId, user.username, mail, hashedPassword, user.isVerified, otp, otpExpiresAt); + + // Send OTP to user's email using your email service (e.g., Nodemailer) + await sendOtpEmail(email, otp); + + return res.status(200).json({ message: "OTP sent to email" }); + } catch (error) { + return res.status(500).json({ message: "Error sending OTP" }); + } + } +const generateOTPforUser = async (req, res) => { + const { email } = req.body; + + const user = await _findUserByEmail(email); + if (!user) { + throw new Error("User not found."); + } + + // Check if an existing OTP is still valid + if (user.otpExpiresAt && Date.now() < user.otpExpiresAt) { + return { + message: 'OTP already generated and still valid', + OTP: user.OTP + }; + } + + const OTP = generateOtp(); + const otpExpiresAt = Date.now() + 10 * 60 * 1000; // OTP valid for 10 minutes + await _updateUserById(user.userId, user.username, mail, hashedPassword, user.isVerified, OTP, otpExpiresAt); + + return OTP; +} + +// const { google } = require('googleapis'); +// const OAuth2 = google.auth.OAuth2; +// const createTransporter = async () => { +// try { +// const oauth2Client = new OAuth2( +// process.env.CLIENT_ID, +// process.env.CLIENT_SECRET, +// "https://developers.google.com/oauthplayground", +// ); + +// oauth2Client.setCredentials({ +// refresh_token: process.env.REFRESH_TOKEN, +// }); + +// const accessToken = await new Promise((resolve, reject) => { +// oauth2Client.getAccessToken((err, token) => { +// if (err) { +// console.log("*ERR: ", err) +// reject(); +// } +// resolve(token); +// }); +// }); + +// const transporter = nodemailer.createTransport({ +// service: "gmail", +// auth: { +// type: "OAuth2", +// user: process.env.USER_EMAIL, +// accessToken, +// clientId: process.env.CLIENT_ID, +// clientSecret: process.env.CLIENT_SECRET, +// refreshToken: process.env.REFRESH_TOKEN, +// }, +// }); +// return transporter; +// } catch (err) { +// return err +// } +// }; + + +export const sendEmail = async (req, res) => { + try { + const { email, title, html } = req.body; // Check if all required fields are present - if (!email || !title || !body) { - return res.status(400).json({ message: 'Missing required fields: email, title, and body are all required.' }); + if (!email || !title || !html) { + return res.status(400).json({ message: 'Missing required fields: email, title, and html are all required.' }); } // Set up the transporter for nodemailer const transporter = nodemailer.createTransport({ service: 'gmail', + // auth: { + // type: 'OAuth2', + // user: process.env.EMAIL_USER, + // clientId: 'YOUR_CLIENT_ID', + // clientSecret: 'YOUR_CLIENT_SECRET', + // refreshToken: 'YOUR_REFRESH_TOKEN', + // accessToken: accessToken.token, + // }, auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS, @@ -29,40 +131,200 @@ export const sendVerificationEmail = async (req, res) => { from: process.env.EMAIL_USER, to: email, subject: title, - html: body, + html: html, }; // Send the email await transporter.sendMail(mailOptions); // Return success response - return res.status(200).json({ message: 'Verification email sent successfully!'}); + console.log("successfully send") + return res.status(200).json({ message: 'Email sent successfully!'}); } catch (error) { console.error('Error sending email:', error); - return res.status(500).json({ error: 'Failed to send verification email' }); + return res.status(500).json({ error: 'Failed to send email' }); } }; -export const verifyEmail = async (req, res) => { - try { - const userId = req.query.id; - if (!isValidObjectId(userId)) { - return res.status(404).json({ message: `User ${userId} not found` }); +export const sendOTPEmail = async (req, res) => { + const { username, email } = req.body; + const OTP = generateOTPforUser(email); + const resetLink = ""// TODO: get reset link + + req.body = { + email: email, + title: 'Reset Your Password for PeerPrep', + html: ` + + + + + + + + + + + ` } + return sendEmail(req, res); +} - const user = await _findUserById(userId); - const salt = bcrypt.genSaltSync(10); - console.log(user.password) - let hashedPassword = bcrypt.hashSync(user.password, salt); - - if (!user) { - return res.status(404).json({ message: `User ${userId} not found` }); - } else { - const updatedUser = await _updateUserById(userId, user.username, user.email, hashedPassword, true); - return res.status(200).json({ message: `Found user` }); +export const sendVerificationEmail = async (req, res) => { + const { username, email, verificationLink } = req.body; + req.body = { + email: email, + title: 'Confirm Your Email Address for PeerPrep', + html: ` + + + + + + + + + + `, } - } catch (err) { - console.error('Error verifying user email:', err); - return res.status(500).json({ message: "Unknown error when getting user!" }); - } -}; \ No newline at end of file + return sendEmail(req, res); +} \ No newline at end of file diff --git a/backend/user-service/controller/user-controller.js b/backend/user-service/controller/user-controller.js index 152750ae76..c435476165 100644 --- a/backend/user-service/controller/user-controller.js +++ b/backend/user-service/controller/user-controller.js @@ -22,7 +22,8 @@ export async function createUser(req, res) { if (!existingUser.isVerified && username == existingUser.username && email == existingUser.email) { // Return a specific message indicating the user is not verified return res.status(403).json({ - message: "This user has already registered but has not yet verified their email. Please check your inbox for the verification link." + message: "This user has already registered but has not yet verified their email. Please check your inbox for the verification link.", + data: formatUserResponse(existingUser), }); } // Return conflict error if the user is already verified @@ -45,6 +46,33 @@ export async function createUser(req, res) { } } +export async function checkUserExistByEmailorId(req, res) { + try { + const { id, email } = req.query; + if (!id && !email ) { + return res.status(400).json({ message: "Either 'id' or 'email' is required." }); + } + + const user = email ? await _findUserByEmail(email): await _findUserById(id); + + if (!user) { + const identifier = email ? email : id; + const identifierType = email ? 'email' : 'id'; + return res.status(404).json({ message: `User with ${identifierType} '${identifier}' not found` }); + } + + return res.status(200).json({ + message: `User found`, + data: { + username: user.username, + } + }); + } catch (err) { + console.error(err); + return res.status(500).json({ message: "Unknown error when checking user by email!" }); + } +} + export async function getUser(req, res) { try { const userId = req.params.id; @@ -77,8 +105,8 @@ export async function getAllUsers(req, res) { export async function updateUser(req, res) { try { - const { username, email, password, isVerified = false } = req.body; - if (username || email || password) { + const { username, email, password, isVerified } = req.body; + if (username || email || password || isVerified) { const userId = req.params.id; if (!isValidObjectId(userId)) { return res.status(404).json({ message: `User ${userId} not found` }); @@ -170,6 +198,7 @@ export function formatUserResponse(user) { username: user.username, email: user.email, isAdmin: user.isAdmin, + isVerified: user.isVerified, createdAt: user.createdAt, }; } diff --git a/backend/user-service/index.js b/backend/user-service/index.js index 4c38d9310d..9598991bce 100644 --- a/backend/user-service/index.js +++ b/backend/user-service/index.js @@ -1,5 +1,6 @@ import express from "express"; import cors from "cors"; +import path from 'path'; import userRoutes from "./routes/user-routes.js"; import authRoutes from "./routes/auth-routes.js"; @@ -34,6 +35,8 @@ app.use((req, res, next) => { app.use("/users", userRoutes); app.use("/auth", authRoutes); app.use('/email', emailRoutes); +app.use('/public', express.static(path.join(process.cwd(), 'public'))); + app.get("/", (req, res, next) => { console.log("Sending Greetings!"); diff --git a/backend/user-service/model/user-model.js b/backend/user-service/model/user-model.js index 23d273feee..4371b0dd45 100644 --- a/backend/user-service/model/user-model.js +++ b/backend/user-service/model/user-model.js @@ -29,7 +29,15 @@ const UserModelSchema = new Schema({ isVerified: { type: Boolean, default: false, - } + }, + otp: { + type: String, + required: false, + }, + otpExpiresAt: { + type: Date, + required: false, + }, }); export default mongoose.model("UserModel", UserModelSchema); diff --git a/backend/user-service/package-lock.json b/backend/user-service/package-lock.json index 53e7aee62b..881a4ba156 100644 --- a/backend/user-service/package-lock.json +++ b/backend/user-service/package-lock.json @@ -13,6 +13,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", + "googleapis": "^144.0.0", "jsonwebtoken": "^9.0.2", "mongoose": "^8.5.4", "nodemailer": "^6.9.15" @@ -174,6 +175,26 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bcrypt": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", @@ -188,6 +209,15 @@ "node": ">= 10.0.0" } }, + "node_modules/bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -575,6 +605,12 @@ "node": ">= 0.10.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -699,6 +735,102 @@ "node": ">=10" } }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/gcp-metadata": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", + "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "gaxios": "^5.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", + "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -752,6 +884,87 @@ "node": ">= 6" } }, + "node_modules/google-auth-library": { + "version": "9.14.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.14.2.tgz", + "integrity": "sha512-R+FRIfk1GBo3RdlRYWPdwk8nmtVUOn6+BkDomAC46KoU8kzXzE1HLmOasSCbWUByMMAGkknVF0G5kQ69Vj7dlA==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-auth-library/node_modules/gcp-metadata": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.0.tgz", + "integrity": "sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-auth-library/node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-auth-library/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/googleapis": { + "version": "144.0.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-144.0.0.tgz", + "integrity": "sha512-ELcWOXtJxjPX4vsKMh+7V+jZvgPwYMlEhQFiu2sa9Qmt5veX8nwXPksOWGGN6Zk4xCiLygUyaz7xGtcMO+Onxw==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^9.0.0", + "googleapis-common": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/googleapis-common": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz", + "integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^6.0.3", + "google-auth-library": "^9.7.0", + "qs": "^6.7.0", + "url-template": "^2.0.8", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -764,6 +977,40 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/gtoken/node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/gtoken/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -980,6 +1227,27 @@ "node": ">=0.12.0" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -2030,6 +2298,12 @@ "node": ">= 0.8" } }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2045,6 +2319,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/backend/user-service/package.json b/backend/user-service/package.json index 838fffe567..bd5669fa56 100644 --- a/backend/user-service/package.json +++ b/backend/user-service/package.json @@ -20,6 +20,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", + "googleapis": "^144.0.0", "jsonwebtoken": "^9.0.2", "mongoose": "^8.5.4", "nodemailer": "^6.9.15" diff --git a/backend/user-service/public/images/favicon.ico b/backend/user-service/public/images/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/backend/user-service/routes/email-routes.js b/backend/user-service/routes/email-routes.js index 2b5b96cfc1..1356df3a38 100644 --- a/backend/user-service/routes/email-routes.js +++ b/backend/user-service/routes/email-routes.js @@ -1,12 +1,11 @@ import express from 'express'; -import { sendVerificationEmail, verifyEmail } from '../controller/email-controller.js'; -import { verifyAccessToken } from "../middleware/basic-access-control.js"; - +import { sendEmail, sendOTPEmail, sendVerificationEmail } from '../controller/email-controller.js'; const router = express.Router(); +router.post('/send-email', sendEmail); +router.post('/send-otp-email', sendOTPEmail); router.post('/send-verification-email', sendVerificationEmail); -router.get('/verify-email', verifyEmail); export default router; \ No newline at end of file diff --git a/backend/user-service/routes/user-routes.js b/backend/user-service/routes/user-routes.js index 51c2fb64a8..427a2003d1 100644 --- a/backend/user-service/routes/user-routes.js +++ b/backend/user-service/routes/user-routes.js @@ -4,6 +4,7 @@ import { createUser, deleteUser, getAllUsers, + checkUserExistByEmailorId, getUser, updateUser, updateUserPrivilege, @@ -14,6 +15,8 @@ const router = express.Router(); router.get("/", verifyAccessToken, verifyIsAdmin, getAllUsers); +router.get("/check", checkUserExistByEmailorId); + router.patch("/:id/privilege", verifyAccessToken, verifyIsAdmin, updateUserPrivilege); router.post("/", createUser); diff --git a/frontend/app/EmailVerification/page.tsx b/frontend/app/EmailVerification/page.tsx deleted file mode 100644 index 5821ef6533..0000000000 --- a/frontend/app/EmailVerification/page.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client" - -import { useEffect, useState } from 'react'; -import { useSearchParams } from 'next/navigation'; -import { Button } from "@/components/ui/button"; -import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/components/ui/card"; -import Link from 'next/link'; - -export default function VerifyEmail() { - const [status, setStatus] = useState<'success' | 'error' | 'loading'>('loading'); - const [message, setMessage] = useState(''); - - // Get the search params from the URL - const searchParams = useSearchParams(); - const id = searchParams.get('id'); - - useEffect(() => { - const verifyUserEmail = async () => { - if (!id) { - setMessage('Invalid or missing id.'); - setStatus('error'); - return; - } - - try { - // Send token to backend for verification - const response = await fetch(`${process.env.NEXT_PUBLIC_USER_API_EMAIL_URL}/verify-email?id=${id}`, { - headers: { - 'Content-Type': 'application/json', - } - }); - - if (response.ok) { - setMessage('Email verified successfully! You can now log in.'); - setStatus('success'); - } else { - const errorData = await response.json(); - setMessage(errorData.message || 'Verification failed. Please try again.'); - setStatus('error'); - } - } catch (error) { - setMessage('An error occurred. Please try again.'); - setStatus('error'); - } - }; - - verifyUserEmail(); - }, [id]); - - return ( -
- - {status === 'loading' && ( - -

Verifying your email, please wait...

-
- )} - - {status === 'success' && ( - <> - - Email Verified! - - -

{message}

-
- - - - - - - )} - - {status === 'error' && ( - <> - - Verification Failed - - -

{message}

-
- - - - - - - )} -
-
- ); -}; diff --git a/frontend/app/auth/forgot-password/reset-password/page.tsx b/frontend/app/auth/forgot-password/reset-password/page.tsx new file mode 100644 index 0000000000..1f06c18b6f --- /dev/null +++ b/frontend/app/auth/forgot-password/reset-password/page.tsx @@ -0,0 +1,228 @@ +"use client" + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import Link from "next/link"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { useRouter } from "next/navigation" +import { useEffect, useState } from "react"; +import { AlertCircle, LoaderCircle } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" + + +const formSchema = z.object({ + password: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[0-9]/, "Password must contain at least one number") + .regex(/[^A-Za-z0-9]/, "Password must contain at least one special character"), + confirm: z.string().min(8, "Passwords do not match"), +}).refine((data) => data.password === data.confirm, { + message: "Passwords do not match", + path: ["confirm"], +}); + +export default function ResetPassword() { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(""); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + password: "", + confirm: "", + }, + }); + + const watchPassword = form.watch("password"); + useEffect(() => { + if (watchPassword) { + form.trigger("password"); + } + }, [watchPassword, form]); + + const watchConfirm = form.watch("confirm"); + useEffect(() => { + if (watchConfirm) { + form.trigger("confirm"); + } + }, [watchConfirm, form]); + + async function onSubmit(values: z.infer) { + // Placeholder for auth to user service + try { + await form.trigger(); + if (!form.formState.isValid) { + return; + } + + setIsLoading(true); + setError(""); // Clear any previous errors + setSuccessMessage(""); // Clear previous success message + + // Fetch user data with admin privileges + const userResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_USERS_URL}/check-user?email=${values.email}`, { + headers: { + 'Content-Type': 'application/json', + } + }); + + if (!userResponse.ok) { + setError("No user found with the provided email address."); + throw new Error("User not found in the database with the provided email during password reset."); + } + + const responseData = await userResponse.json(); + const username = responseData.data.username; + const id = responseData.data.id; + console.log("response: ", responseData); + const resetLink = `${process.env.NEXT_PUBLIC_FRONTEND_URL}/resetPassword?id=${encodeURIComponent(id)}` + + // Send verification email + const emailResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_EMAIL_URL}/send-verification-email`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: values.email, + title: 'Reset Your Password for PeerPrep', + body: ` + + + +

Hi ${username},

+

We received a request to reset your password for your PeerPrep account. To reset your password, click the button below:

+ Reset Password +

If the button above doesn't work, copy and paste the following link into your browser:

+ ${resetLink} +

Best regards,
The PeerPrep Team

+ + ` + }), + }); + + if (!emailResponse.ok) { + setError("There was an error sending the verification email. Please try again."); + throw new Error(`Failed to send verification email`); + } + + setSuccessMessage("Check your email for the reset link."); + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } + } + + return ( +
+
+
+ + Password Reset + +
+ {error && ( + + + Error + + {error} + + + )} + {successMessage && ( + + + Email Sent + + {successMessage} {' '}After verifying your email, proceed{' '} + + here + {' '} + to log in. + + + )} +
+
+ + ( + + +
+ Password + + + + + Password must have at least: +
    +
  • 8 characters
  • +
  • 1 uppercase character
  • +
  • 1 lowercase character
  • +
  • 1 number
  • +
  • 1 special character
  • +
+
+
+
+
+
+ + + + +
+ )} + /> + ( + + Confirm password + + + + + + )} + /> + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/app/auth/sign-up/layout.tsx b/frontend/app/auth/sign-up/layout.tsx new file mode 100644 index 0000000000..dce964224f --- /dev/null +++ b/frontend/app/auth/sign-up/layout.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from "react"; + +export default function SignUpLayout({ children }: { children: ReactNode }) { + return ( +
+
+ PeerPrep +
+ + {children} +
+ ); +} diff --git a/frontend/app/auth/sign-up/page.tsx b/frontend/app/auth/sign-up/page.tsx new file mode 100644 index 0000000000..2ab7602160 --- /dev/null +++ b/frontend/app/auth/sign-up/page.tsx @@ -0,0 +1,378 @@ +"use client" + +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { z } from "zod" +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { AlertCircle, Info, LoaderCircle, CheckCircle } from "lucide-react" +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import SuccessDialog from "./success-dialog" + +const formSchema = z.object({ + username: z.string().min(4, "Username requires at least 4 characters"), + email: z.string().min(1, "Email is required").email({ message: "Invalid email address" }), + password: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[0-9]/, "Password must contain at least one number") + .regex(/[^A-Za-z0-9]/, "Password must contain at least one special character"), + confirm: z.string().min(8, "Passwords do not match"), +}).refine((data) => data.password === data.confirm, { + message: "Passwords do not match", + path: ["confirm"], +}); + +export default function Signup() { + const router = useRouter() + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [userInfo, setUserInfo] = useState({ + email: "", + username: "", + id: "", + }); + // let userInfo = { email: "", username: "", id: "" }; + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + username: "", + email: "", + password: "", + confirm: "", + }, + }); + + const watchPassword = form.watch("password"); + useEffect(() => { + if (watchPassword) { + form.trigger("password"); + } + }, [watchPassword, form]); + + const watchConfirm = form.watch("confirm"); + useEffect(() => { + if (watchConfirm) { + form.trigger("confirm"); + } + }, [watchConfirm, form]); + + let adminJWT: string | null = null; + let tokenTimestamp: number | null = null; + async function getAdminJWT() { + // Check if the token is cached and not expired + const tokenValidFor = 24 * 60 * 60 * 1000; + const currentTime = Date.now(); + + if (adminJWT && tokenTimestamp && (currentTime - tokenTimestamp < tokenValidFor)) { + return adminJWT; + } + + // If no token or token expired, login as admin to get a new token + const loginResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/login`, { + method: "POST", + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + "email": process.env.NEXT_PUBLIC_EMAIL_USER, + "password": process.env.NEXT_PUBLIC_EMAIL_PASS + }), + }); + + if (!loginResponse.ok) { + setError("Failed to reset password. Please try again."); + throw new Error(`Failed to fetch admin JWT token. Status: ${loginResponse.status}, Message: ${loginResponse.statusText}`); + } + + const loginData = await loginResponse.json(); + adminJWT = loginData.data.accessToken; + tokenTimestamp = currentTime; + return adminJWT; + } + + async function onSubmit(values: z.infer) { + // Placeholder for auth to user service + try { + await form.trigger(); + if (!form.formState.isValid) { + return; + } + + setIsLoading(true); + setError(""); // Clear any previous errors + setSuccessMessage(""); // Clear previous success message + + // register user to backend + const { confirm, ...signUpValues } = values; + console.log("In sign up page: call register user api"); + const signUpResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_USERS_URL}`, { + method: "POST", + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(signUpValues), + }); + + if (signUpResponse.status == 400) { + setError("Missing username, email or password."); + throw new Error("Missing username, email or password: " + signUpResponse.statusText); + } else if (signUpResponse.status == 403) { + const responseData = await signUpResponse.json(); + setUserInfo({ + username: responseData.data.username, + email: responseData.data.email, + id: responseData.data.id, + }) + setSuccessMessage("You have already registered but haven't verified your email. Please check your inbox for the verification link."); + form.reset(); + return; + } else if (signUpResponse.status == 409) { + setError("A user with this username or email already exists."); + throw new Error("Duplicate username or email: " + signUpResponse.statusText); + } else if (signUpResponse.status == 500) { + setError("Database or server error. Please try again."); + throw new Error("Database or server error: " + signUpResponse.statusText); + } else if (!signUpResponse.ok) { + setError("There was an error signing up. Please try again."); + throw new Error("Error signing up: " + signUpResponse.statusText); + } + + const responseData = await signUpResponse.json(); + const { id, username, email } = responseData.data; + setUserInfo({ + username: username, + email: email, + id: id, + }) + const verificationLink = `${process.env.NEXT_PUBLIC_FRONTEND_URL}/auth/sign-up/verify-email?id=${encodeURIComponent(id)}`; + + // Send verification email + console.log("In sign up page: call send verification email api"); + const emailResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_EMAIL_URL}/send-verification-email`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username: username, + email: email, + verificationLink: verificationLink + }), + }); + + // Revert the creation of the previously registered user when the backend fails to send the verification email. + console.log("In sign up page: fetch admin jwt token api"); + const adminJWT = await getAdminJWT(); + if (!emailResponse.ok) { + console.log("In sign up page: error heppen when backend try to send email", emailResponse) + setError("There was an error sending the verification email. Please try again."); + console.log("In sign up page: call delete user api"); + await fetch(`${process.env.NEXT_PUBLIC_USER_API_USERS_URL}/${encodeURIComponent(id)}`, { + method: "DELETE", + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${adminJWT}`, + 'userId' : id, + } + }); + throw new Error(`Failed to send verification email`); + } + + setSuccessMessage("Thank you for signing up! A verification link has been sent to your email. Please check your inbox to verify your account."); + form.reset(); + } catch (error) { + if (!error) { + setError("An unexpected error occurred when connecting to the backend. Please try again."); + } + console.error(error); + } finally { + setIsLoading(false); + } + } + + const handleCloseDialog = () => { + setSuccessMessage(''); + }; + + const handleResendEmail = async () => { + const { id, username, email } = userInfo; + const adminJWT = await getAdminJWT(); + const response = await fetch(`${process.env.NEXT_PUBLIC_USER_API_USERS_URL}`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${adminJWT}`, + } + }); + const user = (await response.json()).data.filter((user: { + id: number, + username: string, + email: string, + isAdmin: boolean, + isVerified: boolean, + createdAt: number, + }) => user.username == username || user.email == email) + const verificationLink = `${process.env.NEXT_PUBLIC_FRONTEND_URL}/auth/sign-up/verify-email?id=${encodeURIComponent(id)}`; + const emailResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_EMAIL_URL}/send-verification-email`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username: username, + email: email, + verificationLink: verificationLink, + }), + }); + }; + + return ( + <> +
+
+ + Create an account + +

+ Enter a username, email and password to sign up +

+
+
+ {error && ( + + + Error + + {error} + + + )} +
+ + ( + + +
+ Username + + + + +

Minimum 4 characters

+
+
+
+
+
+ + + + +
+ )} + /> + ( + + Email + + + + + + )} + /> + ( + + +
+ Password + + + + + Password must have at least: +
    +
  • 8 characters
  • +
  • 1 uppercase character
  • +
  • 1 lowercase character
  • +
  • 1 number
  • +
  • 1 special character
  • +
+
+
+
+
+
+ + + + +
+ )} + /> + ( + + Confirm password + + + + + + )} + /> + + + +
+
+ Already have an account?{" "} + + Sign in + +
+
+ + +) +} diff --git a/frontend/app/auth/sign-up/success-dialog.tsx b/frontend/app/auth/sign-up/success-dialog.tsx new file mode 100644 index 0000000000..de10a48968 --- /dev/null +++ b/frontend/app/auth/sign-up/success-dialog.tsx @@ -0,0 +1,101 @@ +import React, { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { AlertCircle, Info, LoaderCircle, CheckCircle } from "lucide-react" +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert" +import { Button } from "@/components/ui/button"; + +interface SuccessDialogProps { + message: string | null; + onClose: () => void; + onResend: () => void; +} + +export default function SuccessDialog({ + message, + onClose, + onResend, +}: SuccessDialogProps) { + const [showTemporaryUI, setShowTemporaryUI] = useState(false); + // const [resendCount, setResendCount] = useState(0); // Track how many times resend is clicked + // const [resendMessage, setResendMessage] = useState(message); + // const maxResendAttempts = 3; // Maximum resend attempts + + // const handleResendClick = () => { + // if (resendCount < maxResendAttempts) { + // onResend(); + // setResendCount(resendCount + 1); // Increment the resend count + // setResendMessage("The email has been resent. Please check your inbox."); + // } else { + // setResendMessage("You have reached the maximum number of resend attempts."); + // } + // }; + const handleResendClick = () => { + onResend(); + setShowTemporaryUI(true); // Show temporary UI + + // Hide the UI and close the dialog after 3 seconds (3000ms) + setTimeout(() => { + setShowTemporaryUI(false); // Hide temporary UI + onClose(); // Close the dialog + }, 3000); // Adjust the time as necessary + }; + + + return ( + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + + + {/* { resendCount <= maxResendAttempts ? + (resendMessage?.includes("haven't verified") ? "Reminder" : "Success!") : "Resend Limit Reached" + } */} + { + message?.includes("haven't verified") ? "Reminder" : "Success!" + } + + {message} + +
+ {/* Show the resend link only if under the limit */} + {/* {resendCount <= maxResendAttempts && ( +

+ Didn’t receive the code?{" "} + + Resend + +

+ )} */} + { +

+ Didn’t receive the code?{" "} + + Resend + +

+ } + {showTemporaryUI && ( + + + Email has been resent! Please check your inbox. + + )} +
+ + + +
+
+ ); +} diff --git a/frontend/app/auth/sign-up/verify-email/page.tsx b/frontend/app/auth/sign-up/verify-email/page.tsx new file mode 100644 index 0000000000..9f111a792e --- /dev/null +++ b/frontend/app/auth/sign-up/verify-email/page.tsx @@ -0,0 +1,144 @@ +"use client" + +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { Button } from "@/components/ui/button"; +import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/components/ui/card"; +import Link from 'next/link'; + +export default function VerifyEmail() { + const [status, setStatus] = useState<'success' | 'error' | 'loading'>('loading'); + const [message, setMessage] = useState(''); + + // Get the search params from the URL + const searchParams = useSearchParams(); + const id = searchParams.get('id'); + + let adminJWT: string | null = null; + let tokenTimestamp: number | null = null; + async function getAdminJWT() { + // Check if the token is cached and not expired + const tokenValidFor = 24 * 60 * 60 * 1000; + const currentTime = Date.now(); + + if (adminJWT && tokenTimestamp && (currentTime - tokenTimestamp < tokenValidFor)) { + return adminJWT; + } + + // If no token or token expired, login as admin to get a new token + const loginResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/login`, { + method: "POST", + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + "email": process.env.NEXT_PUBLIC_EMAIL_USER, + "password": process.env.NEXT_PUBLIC_EMAIL_PASS + }), + }); + + if (!loginResponse.ok) { + setStatus('error'); + setMessage('Unexpected error occured. Please check the URL or request a new one.'); + throw new Error(`Failed to fetch admin JWT token. Status: ${loginResponse.status}, Message: ${loginResponse.statusText}`); + } + + const loginData = await loginResponse.json(); + adminJWT = loginData.data.accessToken; + tokenTimestamp = currentTime; + return adminJWT; + } + + + useEffect(() => { + const verifyUserEmail = async () => { + if (!id) { + setMessage('Invalid verification link. Please check the URL or request a new one.'); + setStatus('error'); + return; + } + + try { + // Check if param userId of url is valid + console.log("In verify user page: call check user exist api") + const checkResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_USERS_URL}/check?id=${id}`, { + headers: { + 'Content-Type': 'application/json', + } + }); + + if (!checkResponse.ok) { + const errorMessage = (await checkResponse.json()).message; + setMessage('Invalid verification link. Please check the URL or request a new one.'); + setStatus('error'); + throw Error("Failed to verified user: " + errorMessage); + } + + // Update user verified state + console.log("In verify user page: fetch admin jwt token api"); + const adminJWT = await getAdminJWT(); + console.log("In verify user page: call update user verify status api") + const updateResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_USERS_URL}/${encodeURIComponent(id)}`, { + method: "PATCH", + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${adminJWT}`, + }, + body: JSON.stringify({ + isVerified: true, + }) + }); + console.log("update response: ", updateResponse) + if (!updateResponse.ok) { + const errorMessage = (await updateResponse.json()).message; + setMessage('Unexpected error occured. Please check the URL or request a new one.'); + setStatus('error'); + throw Error("Failed to update user verified state: " + errorMessage); + } + + setMessage('Email verified successfully! You can now log in.'); + setStatus('success'); + } catch (error) { + console.error(error); + } + }; + + verifyUserEmail(); + }, [id]); + + return ( +
+ {status === 'loading' && ( +
+

Verifying your email, please wait...

+
+ )} + {status === 'success' && ( + <> +
+ + Email Verified! + +

{message}

+ + + +
+ + )} + {status === 'error' && ( + <> +
+ + Verification Failed + +

{message}

+ + + +
+ + )} +
+ ); +}; diff --git a/frontend/app/forgot-password/page.tsx b/frontend/app/forgot-password/page.tsx deleted file mode 100644 index aaabc796d4..0000000000 --- a/frontend/app/forgot-password/page.tsx +++ /dev/null @@ -1,191 +0,0 @@ -"use client" - -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import Link from "next/link"; -import { z } from "zod"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { useRouter } from "next/navigation" -import { useState } from "react"; -import { AlertCircle, LoaderCircle } from "lucide-react"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; - -const formSchema = z.object({ - email: z.string().min(1, "Email is required").email({ message: "Invalid email address" }), -}) - -export default function ForgottenPassword() { - const router = useRouter() - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [successMessage, setSuccessMessage] = useState(""); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - email: "", - }, - }); - - let adminJWT: string | null = null; - let tokenTimestamp: number | null = null; - async function getAdminJWT() { - // Check if the token is cached and not expired - const tokenValidFor = 24 * 60 * 60 * 1000; - const currentTime = Date.now(); - - if (adminJWT && tokenTimestamp && (currentTime - tokenTimestamp < tokenValidFor)) { - return adminJWT; - } - - // If no token or token expired, login as admin to get a new token - const loginResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/login`, { - method: "POST", - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - "email": process.env.NEXT_PUBLIC_EMAIL_USER, - "password": process.env.NEXT_PUBLIC_EMAIL_PASS - }), - }); - - if (!loginResponse.ok) { - setError("Failed to reset password. Please try again."); - throw new Error(`Failed to fetch admin JWT token. Status: ${loginResponse.status}, Message: ${loginResponse.statusText}`); - } - - const loginData = await loginResponse.json(); - adminJWT = loginData.data.accessToken; - tokenTimestamp = currentTime; - return adminJWT; - } - - async function onSubmit(values: z.infer) { - // Placeholder for auth to user service - try { - await form.trigger(); - if (!form.formState.isValid) { - return; - } - - setIsLoading(true); - setError(""); // Clear any previous errors - setSuccessMessage(""); // Clear previous success message - - // Get admin JWT token (cached or freshly fetched) - const adminJWT = await getAdminJWT(); - - // Fetch user data with admin privileges - const userResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_USERS_URL}`, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${adminJWT}` - } - }); - - if (!userResponse.ok) { - setError("Failed to reset password. Please try again."); - throw new Error(`Failed to fetch users from user service to reset password. Status: ${userResponse.status}, Message: ${userResponse.statusText}`); - } - - // check if the email exists in db - const userMatch = (await userResponse.json()).data.filter((user: { - id: string; - username: string; - email: string; - isAdmin: boolean; - createdAt: string; - }) => values.email == user.email) - - if (userMatch.length == 0) { - setError("No user found with the provided email address."); - throw new Error("User not found in the database with the provided email during password reset."); - } - - // Send verification email - const emailResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_EMAIL_URL}/send-verification-email`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email: values.email, - link: `${process.env.NEXT_PUBLIC_FRONTEND_URL}/resetPassword/username=${encodeURIComponent(userMatch.username)}` - }), - }); - - if (!emailResponse.ok) { - setError("There was an error sending the verification email. Please try again."); - throw new Error(`Failed to send verification email`); - } - - setSuccessMessage("A link has been sent to your email. Please check your inbox to reset your password."); - } catch (error) { - console.error(error); - } finally { - setIsLoading(false); - } - } - - return ( -
-
-
- - Password Reset - -
- {error && ( - - - Error - - {error} - - - )} -
-
- - ( - - Enter you email: - - - - - - )} - /> - - - - {successMessage &&

{successMessage}

} -
-
-
- ) -} \ No newline at end of file diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index ae3ae6c8ec..e2eac84935 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -61,6 +61,9 @@ export default function Login() { } else if (response.status == 401) { setError("Incorrect email or password."); throw new Error("Incorrect email or password: " + response.statusText); + } else if (response.status == 403) { + setError("Email not verified. Please verify your email before logging in."); + throw new Error("Email not verified: " + response.statusText); } else if (response.status == 500) { setError("Database or server error. Please try again."); throw new Error("Database or server error: " + response.statusText); @@ -139,9 +142,9 @@ export default function Login() {
- Forgotten password? + Forgot your password?{" "} Reset it @@ -150,7 +153,7 @@ export default function Login() {
Don't have an account?{" "} Sign up diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx deleted file mode 100644 index 853aeb2e46..0000000000 --- a/frontend/app/signup/page.tsx +++ /dev/null @@ -1,339 +0,0 @@ -"use client" - -import Link from "next/link" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { z } from "zod" -import { useEffect, useState } from "react" -import { useRouter } from "next/navigation" -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { AlertCircle, Info, LoaderCircle, CheckCircle } from "lucide-react" -import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" - -const formSchema = z.object({ - username: z.string().min(4, "Username requires at least 4 characters"), - email: z.string().min(1, "Email is required").email({ message: "Invalid email address" }), - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(/[A-Z]/, "Password must contain at least one uppercase letter") - .regex(/[a-z]/, "Password must contain at least one lowercase letter") - .regex(/[0-9]/, "Password must contain at least one number") - .regex(/[^A-Za-z0-9]/, "Password must contain at least one special character"), - confirm: z.string().min(8, "Passwords do not match"), -}).refine((data) => data.password === data.confirm, { - message: "Passwords do not match", - path: ["confirm"], -}); - -export default function Signup() { - const router = useRouter() - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [successMessage, setSuccessMessage] = useState(null); - - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - username: "", - email: "", - password: "", - confirm: "", - }, - }); - - const watchPassword = form.watch("password"); - useEffect(() => { - if (watchPassword) { - form.trigger("password"); - } - }, [watchPassword, form]); - - const watchConfirm = form.watch("confirm"); - useEffect(() => { - if (watchConfirm) { - form.trigger("confirm"); - } - }, [watchConfirm, form]); - - let adminJWT: string | null = null; - let tokenTimestamp: number | null = null; - async function getAdminJWT() { - // Check if the token is cached and not expired - const tokenValidFor = 24 * 60 * 60 * 1000; - const currentTime = Date.now(); - - if (adminJWT && tokenTimestamp && (currentTime - tokenTimestamp < tokenValidFor)) { - return adminJWT; - } - - // If no token or token expired, login as admin to get a new token - const loginResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/login`, { - method: "POST", - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - "email": process.env.NEXT_PUBLIC_EMAIL_USER, - "password": process.env.NEXT_PUBLIC_EMAIL_PASS - }), - }); - - if (!loginResponse.ok) { - setError("Failed to reset password. Please try again."); - throw new Error(`Failed to fetch admin JWT token. Status: ${loginResponse.status}, Message: ${loginResponse.statusText}`); - } - - const loginData = await loginResponse.json(); - adminJWT = loginData.data.accessToken; - tokenTimestamp = currentTime; - return adminJWT; - } - - async function onSubmit(values: z.infer) { - // Placeholder for auth to user service - try { - await form.trigger(); - if (!form.formState.isValid) { - return; - } - - setIsLoading(true); - setError(""); // Clear any previous errors - setSuccessMessage(""); // Clear previous success message - - const { confirm, ...signUpValues } = values; - const signUpResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_USERS_URL}`, { - method: "POST", - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(signUpValues), - }); - - if (signUpResponse.status == 400) { - setError("Missing username, email or password."); - throw new Error("Missing username, email or password: " + signUpResponse.statusText); - } else if (signUpResponse.status == 409) { - setError("A user with this username or email already exists."); - throw new Error("Duplicate username or email: " + signUpResponse.statusText); - } else if (signUpResponse.status == 500) { - setError("Database or server error. Please try again."); - throw new Error("Database or server error: " + signUpResponse.statusText); - } else if (signUpResponse.status == 403) { - setSuccessMessage("You have already registered but haven't verified your email. Please check your inbox for the verification link."); - }else if (!signUpResponse.ok) { - setError("There was an error signing up. Please try again."); - throw new Error("Error signing up: " + signUpResponse.statusText); - } - - - const responseData = await signUpResponse.json(); - const id = responseData.data.id; - - // Send verification email - const emailResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_EMAIL_URL}/send-verification-email`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email: values.email, - title: 'Confirm Your Email Address for PeerPrep', - body: ` - - - -

Hi ${values.username},

-

Thank you for signing up for PeerPrep! Before you can start using your account, we need to verify your email address.

-

Please confirm your email by clicking the button below:

- Verify Email -

If the button doesn't work, copy and paste this URL into your browser:

-

${process.env.NEXT_PUBLIC_FRONTEND_URL}/EmailVerification?token=${encodeURIComponent(id)}

-

This link will expire in [time, e.g., 24 hours].

-

If you didn't sign up for this account, you can safely ignore this email.

-

Best regards,
The PeerPrep Team

- - ` - }), - }); - console.log("email response: ", emailResponse); - const adminJWT = await getAdminJWT(); - if (!emailResponse.ok) { - console.log(emailResponse) - setError("There was an error sending the verification email. Please try again."); - await fetch(`${process.env.NEXT_PUBLIC_USER_API_USERS_URL}/${id}`, { - method: "DELETE", - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${adminJWT}`, - 'userId' : id, - } - }); - throw new Error(`Failed to send verification email`); - } - - setSuccessMessage("Thank you for signing up! A verification link has been sent to your email. Please check your inbox to verify your account."); - } catch (error) { - console.error(error); - } finally { - setIsLoading(false); - } - } - - return ( -
-
- PeerPrep -
-
-
- - Create an account - -

- Enter a username, email and password to sign up -

-
-
- {successMessage && ( - - - Success - - {successMessage} - - - )} - {error && ( - - - Error - - {error} - - - )} -
- - ( - - -
- Username - - - - -

Minimum 4 characters

-
-
-
-
-
- - - - -
- )} - /> - ( - - Email - - - - - - )} - /> - ( - - -
- Password - - - - - Password must have at least: -
    -
  • 8 characters
  • -
  • 1 uppercase character
  • -
  • 1 lowercase character
  • -
  • 1 number
  • -
  • 1 special character
  • -
-
-
-
-
-
- - - - -
- )} - /> - ( - - Confirm password - - - - - - )} - /> - - - -
-
- Already have an account?{" "} - - Sign in - -
-
-
- ) -} From 6bdcb3b6de273610325e12d1c5c66847469d8152 Mon Sep 17 00:00:00 2001 From: LimJiaYan Date: Wed, 16 Oct 2024 17:22:42 +0800 Subject: [PATCH 04/11] Implement OTP for reset password --- backend/user-service/.env | 10 +- backend/user-service/README.md | 55 +++- .../controller/auth-controller.js | 96 +++++++ .../controller/email-controller.js | 250 +++++------------ .../middleware/basic-access-control.js | 2 +- backend/user-service/model/repository.js | 12 +- backend/user-service/model/user-model.js | 8 + backend/user-service/routes/auth-routes.js | 7 +- frontend/app/auth/forgot-password/page.tsx | 142 ++++++++++ .../forgot-password/reset-password/page.tsx | 202 +++++++------- .../reset-password/success/page.tsx | 31 +++ .../auth/forgot-password/verify-code/page.tsx | 251 ++++++++++++++++++ frontend/app/auth/sign-up/page.tsx | 11 +- frontend/components/ui/input-otp.tsx | 71 +++++ frontend/package-lock.json | 161 ++++++++++- frontend/package.json | 3 +- frontend/tailwind.config.ts | 11 +- 17 files changed, 1009 insertions(+), 314 deletions(-) create mode 100644 frontend/app/auth/forgot-password/page.tsx create mode 100644 frontend/app/auth/forgot-password/reset-password/success/page.tsx create mode 100644 frontend/app/auth/forgot-password/verify-code/page.tsx create mode 100644 frontend/components/ui/input-otp.tsx diff --git a/backend/user-service/.env b/backend/user-service/.env index eefe8de202..999ab37030 100644 --- a/backend/user-service/.env +++ b/backend/user-service/.env @@ -6,14 +6,12 @@ PORT=3001 ENV=PROD # email details -# EMAIL_USER=peerprep.no.reply@gmail.com -# EMAIL_PASS=vvkj xhtv twsf roeh - -# EMAIL_USER=jiayan.yanyany@gmail.com -# EMAIL_PASS=mpyy rjyg rnke tqxk - EMAIL_USER=peerprep9@gmail.com EMAIL_PASS=vhgj idnk fhme ooim +# backup email +BACKUP_EMAIL_USER=peerprep.no.reply@gmail.com +BACKUP_EMAIL_PASS=vvkj xhtv twsf roeh + # Secret for creating JWT signature JWT_SECRET=you-can-replace-this-with-your-own-secret diff --git a/backend/user-service/README.md b/backend/user-service/README.md index c7dcdfcdbf..074a79c8af 100644 --- a/backend/user-service/README.md +++ b/backend/user-service/README.md @@ -349,7 +349,7 @@ - This endpoint sends a verification email containing a verification link to the user after they sign up. - HTTP Method: `POST` -- Endpoint: http://localhost:3001/email//send-verification-email +- Endpoint: http://localhost:3001/email/send-verification-email - Body - Required: `email` (string), `username` (string), `verificationLink` (string) @@ -367,4 +367,55 @@ |-----------------------------|----------------------------------------------------| | 200 (OK) | Verification email sent successfully. | | 400 (Bad Request) | Missing or invalid fields (email, title, html). | - | 500 (Internal Server Error) | Database or server error | \ No newline at end of file + | 500 (Internal Server Error) | Database or server error | + +### Verify OTP + +- This endpoint verifies the OTP (One-Time Password) sent to the user and returns a reset token upon success. +- HTTP Method: `POST` +- Endpoint: http://localhost:3001/auth/verif-otp +- Body + - Required: `email` (string), `otp` (string) + + ```json + { + "email": "user@example.com", + "otp": "123456" + } + ``` + +- Responses: + + | Response Code | Explanation | + |-----------------------------|-----------------------------------------------------------------| + | 200 (OK) | OTP verified successfully. Returns a reset token | + | 400 | Both email and otp are required fields. | + | 403 | No OTP request found for this user, or OTP expired or incorrect.| + | 404 | User with the provided email not found. | + | 500 | Database or server error | + +### Reset Password + +- This endpoint resets the user’s password if the provided reset token is valid. +- HTTP Method: `POST` +- Endpoint: http://localhost:3001/auth/verif-otp +- Body + - Required: `email` (string), `token` (string), `newPassword` (string) + + ```json + { + "email": "user@example.com", + "token": "reset_token_value", + "newPassword": "newpassword123" + } + ``` + +- Responses: + + | Response Code | Explanation | + |-----------------------------|---------------------------------------------------------------------------| + | 200 (OK) | OTP verified successfully. Returns a reset token | + | 400 | Missing required fields, token mismatch, expired token, or password reuse.| + | 403 | No OTP request found for this user, or OTP expired or incorrect. | + | 404 | User not found. | + | 500 | Database or server error | \ No newline at end of file diff --git a/backend/user-service/controller/auth-controller.js b/backend/user-service/controller/auth-controller.js index 11ab1c384e..a809151e41 100644 --- a/backend/user-service/controller/auth-controller.js +++ b/backend/user-service/controller/auth-controller.js @@ -1,4 +1,5 @@ import bcrypt from "bcrypt"; +import crypto from 'crypto'; import jwt from "jsonwebtoken"; import { findUserByEmail as _findUserByEmail } from "../model/repository.js"; import { formatUserResponse } from "./user-controller.js"; @@ -43,3 +44,98 @@ export async function handleVerifyToken(req, res) { return res.status(500).json({ message: err.message }); } } + +const generateResetToken = () => { + return crypto.randomBytes(64).toString('hex'); +}; + +export async function verifOTP (req, res) { + try { + const { email, otp } = req.body; + + if (!email || !otp) { + console.log("error: Both 'email' and 'otp' are required.") + return res.status(400).json({ message: "Both 'email' and 'otp' are required." }); + } + + const user = await _findUserByEmail(email); + + if (!user) { + console.log(`error: User with email '${email}' not found`) + return res.status(404).json({ message: `User with email '${email}' not found` }); + } + + if (!user.otp) { + console.log("error: No OTP request found for this user") + return res.status(403).json({ + message: 'No OTP request found for this user' + }); + } + + if (user.otp !== otp || new Date() > user.otpExpiresAt) { + console.log("error: ", (user.otpExpiresAt ? 'OTP has expired': 'Incorrect OTP provided')) + return res.status(403).json({ + message: new Date() > user.otpExpiresAt ? 'OTP has expired': 'Incorrect OTP provided' + }); + } + + user.resetToken = generateResetToken(); + user.resetTokenExpiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15-minute expiration + await user.save() + + return res.status(200).json({ + message: 'OTP verified successfully', + data: { + token: user.resetToken, + } + }); + } catch (error) { + console.log("error: ", error.message) + return res.status(500).json({ message: "Unknown error when verifying OTP!" }); + } +}; + +export async function resetPassword(req, res) { + const { email, token, newPassword } = req.body; + console.log("pw receoved:",newPassword) + try { + const user = await _findUserByEmail(email); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + if (!user.resetToken) { + return res.status(400).json({ message: 'No reset token for this user' }); + } + + if (user.resetToken !== token) { + return res.status(400).json({ message: 'Token not match' }); + } + + if (new Date() > user.resetTokenExpiresAt) { + return res.status(400).json({ message: 'Expired token' }); + } + + const isSamePassword = bcrypt.compareSync(newPassword, user.password); + if (isSamePassword) { + return res.status(400).json({ message: 'New password matches old password' }); + } + + // Hash the new password + const salt = bcrypt.genSaltSync(10); + const hashedPassword = bcrypt.hashSync(newPassword, salt); + + user.password = hashedPassword; + user.otp = undefined; // Clear otp + user.otpExpiresAt = undefined; + user.resetToken = undefined; // Clear reset token + user.resetTokenExpiresAt = undefined; // Clear token expiration + await user.save(); + + res.status(200).json({ message: 'Password reset successfully' }); + } catch (error) { + console.log("inside error: ", error.message) + res.status(500).json({ message: error.message }); + } +}; \ No newline at end of file diff --git a/backend/user-service/controller/email-controller.js b/backend/user-service/controller/email-controller.js index 108a787c68..debff8a068 100644 --- a/backend/user-service/controller/email-controller.js +++ b/backend/user-service/controller/email-controller.js @@ -1,106 +1,28 @@ -import bcrypt from "bcrypt"; import crypto from 'crypto'; import nodemailer from 'nodemailer'; -import { isValidObjectId } from "mongoose"; import { findUserById as _findUserById, findUserByEmail as _findUserByEmail, - updateUserById as _updateUserById } from "../model/repository.js"; function generateOtp() { return crypto.randomInt(100000, 999999).toString(); // Generates a 6-digit OTP } -// Send OTP to email and save in DB -export async function sendOtp(req, res) { - const { email } = req.body; - - try { - const user = await _findUserByEmail(email); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - - const otp = generateOtp(); - const otpExpiresAt = Date.now() + 10 * 60 * 1000; // OTP valid for 10 minutes - - await _updateUserById(user.userId, user.username, mail, hashedPassword, user.isVerified, otp, otpExpiresAt); - - // Send OTP to user's email using your email service (e.g., Nodemailer) - await sendOtpEmail(email, otp); - - return res.status(200).json({ message: "OTP sent to email" }); - } catch (error) { - return res.status(500).json({ message: "Error sending OTP" }); - } - } - -const generateOTPforUser = async (req, res) => { - const { email } = req.body; - +const generateOTPforUser = async (email) => { const user = await _findUserByEmail(email); + if (!user) { throw new Error("User not found."); } - // Check if an existing OTP is still valid - if (user.otpExpiresAt && Date.now() < user.otpExpiresAt) { - return { - message: 'OTP already generated and still valid', - OTP: user.OTP - }; - } - - const OTP = generateOtp(); - const otpExpiresAt = Date.now() + 10 * 60 * 1000; // OTP valid for 10 minutes - await _updateUserById(user.userId, user.username, mail, hashedPassword, user.isVerified, OTP, otpExpiresAt); + user.otp = generateOtp(); + user.otpExpiresAt = Date.now() + 10 * 60 * 1000; // OTP valid for 10 minutes + await user.save(); - return OTP; + return user.otp; } -// const { google } = require('googleapis'); -// const OAuth2 = google.auth.OAuth2; -// const createTransporter = async () => { -// try { -// const oauth2Client = new OAuth2( -// process.env.CLIENT_ID, -// process.env.CLIENT_SECRET, -// "https://developers.google.com/oauthplayground", -// ); - -// oauth2Client.setCredentials({ -// refresh_token: process.env.REFRESH_TOKEN, -// }); - -// const accessToken = await new Promise((resolve, reject) => { -// oauth2Client.getAccessToken((err, token) => { -// if (err) { -// console.log("*ERR: ", err) -// reject(); -// } -// resolve(token); -// }); -// }); - -// const transporter = nodemailer.createTransport({ -// service: "gmail", -// auth: { -// type: "OAuth2", -// user: process.env.USER_EMAIL, -// accessToken, -// clientId: process.env.CLIENT_ID, -// clientSecret: process.env.CLIENT_SECRET, -// refreshToken: process.env.REFRESH_TOKEN, -// }, -// }); -// return transporter; -// } catch (err) { -// return err -// } -// }; - - export const sendEmail = async (req, res) => { try { const { email, title, html } = req.body; @@ -112,14 +34,6 @@ export const sendEmail = async (req, res) => { // Set up the transporter for nodemailer const transporter = nodemailer.createTransport({ service: 'gmail', - // auth: { - // type: 'OAuth2', - // user: process.env.EMAIL_USER, - // clientId: 'YOUR_CLIENT_ID', - // clientSecret: 'YOUR_CLIENT_SECRET', - // refreshToken: 'YOUR_REFRESH_TOKEN', - // accessToken: accessToken.token, - // }, auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS, @@ -148,98 +62,74 @@ export const sendEmail = async (req, res) => { export const sendOTPEmail = async (req, res) => { const { username, email } = req.body; - const OTP = generateOTPforUser(email); - const resetLink = ""// TODO: get reset link + const otp = await generateOTPforUser(email); + const resetPasswordLink = `${process.env.NEXT_PUBLIC_FRONTEND_URL}/auth/forget-password/reset-password?email=${encodeURIComponent(email)}&otp=${otp}` req.body = { email: email, title: 'Reset Your Password for PeerPrep', html: ` - - - - - - - - - - - ` + + + + + + + + + + ` } - return sendEmail(req, res); + sendEmail(req, res); } export const sendVerificationEmail = async (req, res) => { @@ -288,7 +178,7 @@ export const sendVerificationEmail = async (req, res) => { } .button { display: inline-block; - background-color: black; + background-color: #87cefa; color: white; border-radius: 10px; padding: 10px 20px; @@ -299,7 +189,7 @@ export const sendVerificationEmail = async (req, res) => { transition: all 0.3s ease; } .button:hover { - background-color: #333333; + background-color: #add8e6; } .footer { text-align: center; diff --git a/backend/user-service/middleware/basic-access-control.js b/backend/user-service/middleware/basic-access-control.js index 51c4dd62a1..facd316fb1 100644 --- a/backend/user-service/middleware/basic-access-control.js +++ b/backend/user-service/middleware/basic-access-control.js @@ -20,7 +20,7 @@ export function verifyAccessToken(req, res, next) { return res.status(401).json({ message: "Authentication failed" }); } - req.user = { id: dbUser.id, username: dbUser.username, email: dbUser.email, isAdmin: dbUser.isAdmin, isVerified: dbUser.isVerified }; + req.user = { id: dbUser.id, username: dbUser.username, email: dbUser.email, isAdmin: dbUser.isAdmin, isVerified: dbUser.isVerified, otp: dbUser.otp, otpExpiresAt: dbUser.otpExpiresAt }; next(); }); } diff --git a/backend/user-service/model/repository.js b/backend/user-service/model/repository.js index b729957593..3ebb68a819 100644 --- a/backend/user-service/model/repository.js +++ b/backend/user-service/model/repository.js @@ -40,7 +40,13 @@ export async function findAllUsers() { return UserModel.find(); } -export async function updateUserById(userId, username, email, password, isVerified) { +export async function updateUserById(userId, username, email, password, isVerified, otp, otpExpiresAt, resetToken, resetTokenExpiresAt) { + console.log(userId) + console.log("db functionality, otp: ", otp) + console.log("db functionality, otpExpiresAt: ", otpExpiresAt) + console.log("db functionality, resetToken: ", resetToken) + console.log("db functionality, resetTokenExpiresAt: ", resetTokenExpiresAt) + return UserModel.findByIdAndUpdate( userId, { @@ -49,6 +55,10 @@ export async function updateUserById(userId, username, email, password, isVerifi email, password, isVerified, + otp, + otpExpiresAt, + resetToken, + resetTokenExpiresAt, }, }, { new: true }, // return the updated user diff --git a/backend/user-service/model/user-model.js b/backend/user-service/model/user-model.js index 4371b0dd45..d46aeeaf95 100644 --- a/backend/user-service/model/user-model.js +++ b/backend/user-service/model/user-model.js @@ -38,6 +38,14 @@ const UserModelSchema = new Schema({ type: Date, required: false, }, + resetToken: { // password reset token + type: String, + required: false, + }, + resetTokenExpiresAt: { + type: Date, + required: false, + } }); export default mongoose.model("UserModel", UserModelSchema); diff --git a/backend/user-service/routes/auth-routes.js b/backend/user-service/routes/auth-routes.js index 8690cbe1f6..9357ac3c69 100644 --- a/backend/user-service/routes/auth-routes.js +++ b/backend/user-service/routes/auth-routes.js @@ -1,12 +1,13 @@ import express from "express"; -import { handleLogin, handleVerifyToken } from "../controller/auth-controller.js"; +import { handleLogin, handleVerifyToken, resetPassword, verifOTP } from "../controller/auth-controller.js"; import { verifyAccessToken } from "../middleware/basic-access-control.js"; const router = express.Router(); router.post("/login", handleLogin); - +router.post("/reset-password", resetPassword); +router.post("/verify-otp", verifOTP); router.get("/verify-token", verifyAccessToken, handleVerifyToken); -export default router; +export default router; \ No newline at end of file diff --git a/frontend/app/auth/forgot-password/page.tsx b/frontend/app/auth/forgot-password/page.tsx new file mode 100644 index 0000000000..5db347e25b --- /dev/null +++ b/frontend/app/auth/forgot-password/page.tsx @@ -0,0 +1,142 @@ +"use client" + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import Link from "next/link"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { useRouter } from "next/navigation" +import { useState } from "react"; +import { AlertCircle, LoaderCircle } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; + +const formSchema = z.object({ + email: z.string().min(1, "Email is required").email({ message: "Invalid email address" }), +}) + +export default function ForgottenPassword() { + const router = useRouter() + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + }, + }); + + async function onSubmit(values: z.infer) { + let isErrorSet = false; + try { + await form.trigger(); + if (!form.formState.isValid) { + return; + } + + setIsLoading(true); + setError(""); // Clear any previous errors + + // Verify user exists in db + console.log("In forgot password page: call api to check user exist in db"); + const userResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_USERS_URL}/check?email=${values.email}`, { + headers: { + 'Content-Type': 'application/json', + } + }); + if (!userResponse.ok) { + setError("No user found with the provided email address."); + isErrorSet = true; + throw new Error("User not found in the database with the provided email during password reset."); + } + + const responseData = await userResponse.json(); + const username = responseData.data.username; + const email = values.email; + + // Call API to generate OTP and sent its to user via email + console.log("In forgot password page: call api to sent otp email"); + const emailResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_EMAIL_URL}/send-otp-email`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, email }) + }); + if (!emailResponse.ok) { + setError("There was an error sending the verification email. Please try again."); + isErrorSet = true; + throw new Error(`Failed to send verification email`); + } + + router.push(`/auth/forgot-password/verify-code?email=${encodeURIComponent(email)}`); + } catch (err) { + if (!isErrorSet) { + setError("An unexpected error occurred when connecting to the backend. Please try again."); + } + console.error(error); + } finally { + setIsLoading(false); + } + } + + return ( +
+
+
+ + Reset Password + +
+ {error && ( + + + Error + + {error} + + + )} +
+
+ + ( + + Enter you email: + + + + + + )} + /> + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/app/auth/forgot-password/reset-password/page.tsx b/frontend/app/auth/forgot-password/reset-password/page.tsx index 1f06c18b6f..c67a7c3748 100644 --- a/frontend/app/auth/forgot-password/reset-password/page.tsx +++ b/frontend/app/auth/forgot-password/reset-password/page.tsx @@ -14,12 +14,12 @@ import { FormLabel, FormMessage, } from "@/components/ui/form" -import { useRouter } from "next/navigation" +import { useRouter, useSearchParams } from "next/navigation" import { useEffect, useState } from "react"; import { AlertCircle, LoaderCircle } from "lucide-react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" - +import { Info } from "lucide-react" const formSchema = z.object({ password: z @@ -37,9 +37,12 @@ const formSchema = z.object({ export default function ResetPassword() { const router = useRouter(); + const searchParams = useSearchParams(); + const param_email = searchParams.get("email"); + const param_token = searchParams.get("token"); + const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [successMessage, setSuccessMessage] = useState(""); const form = useForm>({ resolver: zodResolver(formSchema), @@ -64,7 +67,7 @@ export default function ResetPassword() { }, [watchConfirm, form]); async function onSubmit(values: z.infer) { - // Placeholder for auth to user service + let isErrorSet = false; try { await form.trigger(); if (!form.formState.isValid) { @@ -73,57 +76,57 @@ export default function ResetPassword() { setIsLoading(true); setError(""); // Clear any previous errors - setSuccessMessage(""); // Clear previous success message - // Fetch user data with admin privileges - const userResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_USERS_URL}/check-user?email=${values.email}`, { + // Verify code with backend + console.log("In reset password page: call api to reset passwsord"); + const verifyTokenResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/reset-password`, { + method: "POST", headers: { 'Content-Type': 'application/json', - } + }, + body: JSON.stringify({ + email: param_email, + token: param_token, + newPassword: values.password + }) }); - if (!userResponse.ok) { - setError("No user found with the provided email address."); - throw new Error("User not found in the database with the provided email during password reset."); + // handle error response + if (verifyTokenResponse.status == 404) { + setError("We couldn't find an account with that email. It looks like the email is missing in the link. Please check and try again"); + isErrorSet = true; + throw new Error("Missing email: " + verifyTokenResponse.statusText); + } else if (verifyTokenResponse.status == 400) { + const responseMessage = (await verifyTokenResponse.json()).message; + if (responseMessage.includes("expired")) { + setError("The reset link has expired. Please request a new one"); + isErrorSet = true; + } else if (responseMessage.includes("not match")) { + setError("The reset link is invalid. Please request a new password reset link."); + isErrorSet = true; + } else if (responseMessage.includes("old password")) { + setError("You cannot reuse your previous password. Please choose a new one."); + isErrorSet = true; + } else { + setError("It seems you haven’t requested a password reset. Please request a reset if needed."); + isErrorSet = true; + } + throw new Error("Error during verification: " + verifyTokenResponse.statusText); + } else if (verifyTokenResponse.status == 500) { + setError("Something went wrong on our end. Please try again later."); + isErrorSet = true; + throw new Error("Database or server error: " + verifyTokenResponse.statusText); + } else if (!verifyTokenResponse.ok) { + setError("There was an error happen when reset your password."); + isErrorSet = true; + throw new Error("Error resetting password: " + verifyTokenResponse.statusText); } - const responseData = await userResponse.json(); - const username = responseData.data.username; - const id = responseData.data.id; - console.log("response: ", responseData); - const resetLink = `${process.env.NEXT_PUBLIC_FRONTEND_URL}/resetPassword?id=${encodeURIComponent(id)}` - - // Send verification email - const emailResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_EMAIL_URL}/send-verification-email`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email: values.email, - title: 'Reset Your Password for PeerPrep', - body: ` - - - -

Hi ${username},

-

We received a request to reset your password for your PeerPrep account. To reset your password, click the button below:

- Reset Password -

If the button above doesn't work, copy and paste the following link into your browser:

- ${resetLink} -

Best regards,
The PeerPrep Team

- - ` - }), - }); - - if (!emailResponse.ok) { - setError("There was an error sending the verification email. Please try again."); - throw new Error(`Failed to send verification email`); + router.push(`/auth/forgot-password/reset-password/success`); + } catch (err) { + if (!isErrorSet) { + setError("An unexpected error occurred when connecting to the backend. Please try again."); } - - setSuccessMessage("Check your email for the reset link."); - } catch (error) { console.error(error); } finally { setIsLoading(false); @@ -135,7 +138,7 @@ export default function ResetPassword() {
- Password Reset + Reset Password
{error && ( @@ -147,67 +150,54 @@ export default function ResetPassword() { )} - {successMessage && ( - - - Email Sent - - {successMessage} {' '}After verifying your email, proceed{' '} - - here - {' '} - to log in. - - - )}
( - - -
- Password - - - - - Password must have at least: -
    -
  • 8 characters
  • -
  • 1 uppercase character
  • -
  • 1 lowercase character
  • -
  • 1 number
  • -
  • 1 special character
  • -
-
-
-
-
-
- - - - -
- )} - /> - ( - - Confirm password - - - - - - )} - /> + control={form.control} + name="password" + render={({ field }) => ( + + +
+ Password + + + + + Password must have at least: +
    +
  • 8 characters
  • +
  • 1 uppercase character
  • +
  • 1 lowercase character
  • +
  • 1 number
  • +
  • 1 special character
  • +
+
+
+
+
+
+ + + + +
+ )} + /> + ( + + Confirm password + + + + + + )} + /> diff --git a/frontend/app/auth/forgot-password/reset-password/success/page.tsx b/frontend/app/auth/forgot-password/reset-password/success/page.tsx new file mode 100644 index 0000000000..3a657bd1e9 --- /dev/null +++ b/frontend/app/auth/forgot-password/reset-password/success/page.tsx @@ -0,0 +1,31 @@ +"use client" + +import React from 'react'; +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button"; + +const PasswordChangeSuccess = () => { + const router = useRouter(); + + const handleReturn = () => { + router.push('/'); + }; + + return ( +
+
+ + + + +
+

Password Changed!

+

Your password has been changed successfully.

+ +
+ ); +}; + +export default PasswordChangeSuccess; diff --git a/frontend/app/auth/forgot-password/verify-code/page.tsx b/frontend/app/auth/forgot-password/verify-code/page.tsx new file mode 100644 index 0000000000..cbabb291fb --- /dev/null +++ b/frontend/app/auth/forgot-password/verify-code/page.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation" +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; +import { OTPInput, SlotProps } from 'input-otp' +import { useSearchParams } from "next/navigation"; +import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +import type { ClassValue } from "clsx"; +import { AlertCircle, Info, LoaderCircle, CheckCircle } from "lucide-react" +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert" + +// adapted from: https://input-otp.rodz.dev/ +function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +function FakeCaret() { + return ( +
+
+
+ ) +} + +function Slot(props: SlotProps) { + return ( +
+ {props.char !== null &&
{props.char}
} + {props.hasFakeCaret && } +
+ ) +} + +const FormSchema = z.object({ + otp: z.string().min(6, { + message: "Your code must be exactly 6 digits.", + }), +}); + +export default function OTPForm() { +// const [value, setValue] = useState(""); +// const [timer, setTimer] = useState(60); +// const [disable, setDisable] = useState(true); + const router = useRouter(); + const searchParams = useSearchParams(); + const param_email = searchParams.get("email"); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + + // Timer effect for Resend OTP + // useEffect(() => { + // if (disable && timer > 0) { + // const timer = setInterval(() => { + // setTimer(prev => prev - 1); + // }, 1000); + // return () => clearInterval(timer); + // } else if (timer === 0) { + // setDisable(false); + // } + // }, [disable, timer]); + + // const handleChange = (e: { target: { value: any; }; }, index: string | number) => { + // const value = e.target.value; + // if (/^[0-9]$/.test(value)) { + // const newOtp = [...otp]; + // newOtp[index] = value; + // setOtp(newOtp); + // } + // }; + + // const resendOTP = () => { + // if (!disable) { + // // Logic to resend the OTP + // console.log('Resend OTP'); + // setDisable(true); + // setTimer(30); // Reset the timer + // } + // }; + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + otp: "", + }, + }); + + async function onSubmit(data: z.infer) { + let isErrorSet = false; + try { + await form.trigger(); + if (!form.formState.isValid) { + return; + } + + setIsLoading(true); + setError(""); // Clear any previous errors + + // Verify code with backend + console.log("In verify code page: call api to verify 6 digit code"); + const verifyCodeResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/verify-otp`, { + method: "POST", + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: param_email, + otp: data.otp, + }) + }); + if (verifyCodeResponse.status == 400) { + setError("We couldn't identify your account. It looks like the email is missing in the link. Please try again"); + isErrorSet = true; + throw new Error("Missing email: " + verifyCodeResponse.statusText); + } else if (verifyCodeResponse.status == 403) { + const responseMessage = (await verifyCodeResponse.json()).message; + if (responseMessage.includes("expired")) { + setError("The verification code has expired. Please request a new one."); + isErrorSet = true; + } else if (responseMessage.includes("Incorrect")) { + setError("The code you entered is incorrect. Please try again."); + isErrorSet = true; + } else { + setError("Your account doesn't need verification at this time."); + isErrorSet = true; + } + throw new Error("Error during verification: " + verifyCodeResponse.statusText); + } else if (verifyCodeResponse.status == 404) { + setError("The email associated with this link doesn't exist in our system. Have you registered yet?"); + isErrorSet = true; + throw new Error("User doesn't exist: " + verifyCodeResponse.statusText); + } else if (verifyCodeResponse.status == 500) { + setError("Database or server error. Please try again."); + isErrorSet = true; + throw new Error("Database or server error: " + verifyCodeResponse.statusText); + } else if (!verifyCodeResponse.ok) { + setError("There was an error verifying the code."); + isErrorSet = true; + throw new Error("Error verifying code: " + verifyCodeResponse.statusText); + } + const responseData = await verifyCodeResponse.json(); + const resetPasswordToken = responseData.data.token; + router.push(`/auth/forgot-password/reset-password?email=${encodeURIComponent(param_email)}&token=${resetPasswordToken}`); + } catch (err) { + if (!isErrorSet) { + setError("An unexpected error occurred when connecting to the backend. Please try again."); + } + console.error(err); + } finally { + setIsLoading(false); + } + } + + return ( +
+
+
+
+

Email Verification

+
+
+

We have sent a code to your email {param_email && ":" + param_email}

+
+
+ {error && ( + + + Error + + {error} + + + )} +
+ + ( + + + ( + <> +
+ {slots.map((slot, idx) => ( + + ))} +
+ + )} + {...field}/> +
+ +
+ )} + /> + + + + {/* */} +
+
+ ); +} diff --git a/frontend/app/auth/sign-up/page.tsx b/frontend/app/auth/sign-up/page.tsx index 2ab7602160..64a6bce27f 100644 --- a/frontend/app/auth/sign-up/page.tsx +++ b/frontend/app/auth/sign-up/page.tsx @@ -109,6 +109,7 @@ export default function Signup() { async function onSubmit(values: z.infer) { // Placeholder for auth to user service + let isErrorSet = false; try { await form.trigger(); if (!form.formState.isValid) { @@ -132,6 +133,7 @@ export default function Signup() { if (signUpResponse.status == 400) { setError("Missing username, email or password."); + isErrorSet = true; throw new Error("Missing username, email or password: " + signUpResponse.statusText); } else if (signUpResponse.status == 403) { const responseData = await signUpResponse.json(); @@ -145,9 +147,11 @@ export default function Signup() { return; } else if (signUpResponse.status == 409) { setError("A user with this username or email already exists."); + isErrorSet = true; throw new Error("Duplicate username or email: " + signUpResponse.statusText); } else if (signUpResponse.status == 500) { setError("Database or server error. Please try again."); + isErrorSet = true; throw new Error("Database or server error: " + signUpResponse.statusText); } else if (!signUpResponse.ok) { setError("There was an error signing up. Please try again."); @@ -183,6 +187,7 @@ export default function Signup() { if (!emailResponse.ok) { console.log("In sign up page: error heppen when backend try to send email", emailResponse) setError("There was an error sending the verification email. Please try again."); + isErrorSet = true; console.log("In sign up page: call delete user api"); await fetch(`${process.env.NEXT_PUBLIC_USER_API_USERS_URL}/${encodeURIComponent(id)}`, { method: "DELETE", @@ -197,11 +202,11 @@ export default function Signup() { setSuccessMessage("Thank you for signing up! A verification link has been sent to your email. Please check your inbox to verify your account."); form.reset(); - } catch (error) { - if (!error) { + } catch (err) { + if (!isErrorSet) { setError("An unexpected error occurred when connecting to the backend. Please try again."); } - console.error(error); + console.error(err); } finally { setIsLoading(false); } diff --git a/frontend/components/ui/input-otp.tsx b/frontend/components/ui/input-otp.tsx new file mode 100644 index 0000000000..f66fcfa0dd --- /dev/null +++ b/frontend/components/ui/input-otp.tsx @@ -0,0 +1,71 @@ +"use client" + +import * as React from "react" +import { OTPInput, OTPInputContext } from "input-otp" +import { Dot } from "lucide-react" + +import { cn } from "@/lib/utils" + +const InputOTP = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, containerClassName, ...props }, ref) => ( + +)) +InputOTP.displayName = "InputOTP" + +const InputOTPGroup = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ className, ...props }, ref) => ( +
+)) +InputOTPGroup.displayName = "InputOTPGroup" + +const InputOTPSlot = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> & { index: number } +>(({ index, className, ...props }, ref) => { + const inputOTPContext = React.useContext(OTPInputContext) + const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ) +}) +InputOTPSlot.displayName = "InputOTPSlot" + +const InputOTPSeparator = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ ...props }, ref) => ( +
+ +
+)) +InputOTPSeparator.displayName = "InputOTPSeparator" + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 82a6ad0ef0..7b7ea85a91 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,7 +11,7 @@ "@hookform/resolvers": "^3.9.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.1", - "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-hover-card": "^1.1.1", "@radix-ui/react-label": "^2.1.0", @@ -28,6 +28,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", + "input-otp": "^1.2.4", "lucide-react": "^0.441.0", "next": "14.2.13", "react": "^18", @@ -673,25 +674,25 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.1.tgz", - "integrity": "sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.2.tgz", + "integrity": "sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.0", "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.0", - "@radix-ui/react-focus-guards": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.0", "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-portal": "1.1.1", - "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", "@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-slot": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0", "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.5.7" + "react-remove-scroll": "2.6.0" }, "peerDependencies": { "@types/react": "*", @@ -708,6 +709,136 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", + "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", + "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/react-remove-scroll": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz", + "integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.6", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", @@ -4169,6 +4300,16 @@ "dev": true, "license": "ISC" }, + "node_modules/input-otp": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.2.4.tgz", + "integrity": "sha512-md6rhmD+zmMnUh5crQNSQxq3keBRYvE3odbr4Qb9g2NWzQv9azi+t1a3X4TBTbh98fsGHgEEJlzbe1q860uGCA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, "node_modules/internal-slot": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3cfc77043d..3def2e554f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,7 @@ "@hookform/resolvers": "^3.9.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.1", - "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-hover-card": "^1.1.1", "@radix-ui/react-label": "^2.1.0", @@ -29,6 +29,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", + "input-otp": "^1.2.4", "lucide-react": "^0.441.0", "next": "14.2.13", "react": "^18", diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 00e9aecd08..0f797dfe75 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -96,7 +96,16 @@ const config: Config = { 'tablet': '640px', 'laptop': '1024px', 'desktop': '1280px', - } + }, + keyframes: { + "caret-blink": { + "0%,70%,100%": { opacity: "1" }, + "20%,50%": { opacity: "0" }, + }, + }, + animation: { + "caret-blink": "caret-blink 1.25s ease-out infinite", + }, }, }, plugins: [require("tailwindcss-animate")], From ff6e8f1b1457e4aa978806f345b7f9fe771553d2 Mon Sep 17 00:00:00 2001 From: LimJiaYan Date: Wed, 16 Oct 2024 17:30:14 +0800 Subject: [PATCH 05/11] Refined backend error messages to be clearer for end users. --- frontend/app/auth/forgot-password/page.tsx | 2 +- frontend/app/auth/forgot-password/reset-password/page.tsx | 2 +- frontend/app/auth/forgot-password/verify-code/page.tsx | 2 +- frontend/app/auth/sign-up/page.tsx | 2 +- frontend/app/page.tsx | 7 +++++++ 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/frontend/app/auth/forgot-password/page.tsx b/frontend/app/auth/forgot-password/page.tsx index 5db347e25b..2685449367 100644 --- a/frontend/app/auth/forgot-password/page.tsx +++ b/frontend/app/auth/forgot-password/page.tsx @@ -81,7 +81,7 @@ export default function ForgottenPassword() { router.push(`/auth/forgot-password/verify-code?email=${encodeURIComponent(email)}`); } catch (err) { if (!isErrorSet) { - setError("An unexpected error occurred when connecting to the backend. Please try again."); + setError("Something went wrong on our backend. Please retry shortly."); } console.error(error); } finally { diff --git a/frontend/app/auth/forgot-password/reset-password/page.tsx b/frontend/app/auth/forgot-password/reset-password/page.tsx index c67a7c3748..dc6d2ce1e0 100644 --- a/frontend/app/auth/forgot-password/reset-password/page.tsx +++ b/frontend/app/auth/forgot-password/reset-password/page.tsx @@ -125,7 +125,7 @@ export default function ResetPassword() { router.push(`/auth/forgot-password/reset-password/success`); } catch (err) { if (!isErrorSet) { - setError("An unexpected error occurred when connecting to the backend. Please try again."); + setError("Something went wrong on our backend. Please retry shortly."); } console.error(error); } finally { diff --git a/frontend/app/auth/forgot-password/verify-code/page.tsx b/frontend/app/auth/forgot-password/verify-code/page.tsx index cbabb291fb..50b9e49c5c 100644 --- a/frontend/app/auth/forgot-password/verify-code/page.tsx +++ b/frontend/app/auth/forgot-password/verify-code/page.tsx @@ -165,7 +165,7 @@ export default function OTPForm() { router.push(`/auth/forgot-password/reset-password?email=${encodeURIComponent(param_email)}&token=${resetPasswordToken}`); } catch (err) { if (!isErrorSet) { - setError("An unexpected error occurred when connecting to the backend. Please try again."); + setError("Something went wrong on our backend. Please retry shortly."); } console.error(err); } finally { diff --git a/frontend/app/auth/sign-up/page.tsx b/frontend/app/auth/sign-up/page.tsx index 64a6bce27f..4ca37872bc 100644 --- a/frontend/app/auth/sign-up/page.tsx +++ b/frontend/app/auth/sign-up/page.tsx @@ -204,7 +204,7 @@ export default function Signup() { form.reset(); } catch (err) { if (!isErrorSet) { - setError("An unexpected error occurred when connecting to the backend. Please try again."); + setError("Something went wrong on our backend. Please retry shortly."); } console.error(err); } finally { diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index e2eac84935..6c27e425ae 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -38,6 +38,7 @@ export default function Login() { }); async function onSubmit(values: z.infer) { + let isErrorSet = false; // Placeholder for auth to user service try { await form.trigger(); @@ -57,18 +58,23 @@ export default function Login() { if (response.status == 400) { setError("Missing email or password."); + isErrorSet = true; throw new Error("Missing email or password: " + response.statusText); } else if (response.status == 401) { setError("Incorrect email or password."); + isErrorSet = true; throw new Error("Incorrect email or password: " + response.statusText); } else if (response.status == 403) { setError("Email not verified. Please verify your email before logging in."); + isErrorSet = true; throw new Error("Email not verified: " + response.statusText); } else if (response.status == 500) { setError("Database or server error. Please try again."); + isErrorSet = true; throw new Error("Database or server error: " + response.statusText); } else if (!response.ok) { setError("There was an error logging in. Please try again."); + isErrorSet = true; throw new Error("Error logging in: " + response.statusText); } @@ -76,6 +82,7 @@ export default function Login() { console.log(responseData.data["accessToken"]); router.push("/question-repo"); } catch (error) { + console.error(error); } finally { setIsLoading(false); From 81c7011bbf5a2f2d1d7a10518f00544ea48218f8 Mon Sep 17 00:00:00 2001 From: LimJiaYan Date: Wed, 16 Oct 2024 19:53:08 +0800 Subject: [PATCH 06/11] Add user profile page and add routing --- .../controller/user-controller.js | 1 + frontend/app/page.tsx | 4 +- frontend/app/profile/page.tsx | 261 ++++++++++++++++++ frontend/app/question-repo/page.tsx | 8 +- frontend/package-lock.json | 44 +++ frontend/package.json | 1 + 6 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 frontend/app/profile/page.tsx diff --git a/backend/user-service/controller/user-controller.js b/backend/user-service/controller/user-controller.js index c435476165..fda7b4384b 100644 --- a/backend/user-service/controller/user-controller.js +++ b/backend/user-service/controller/user-controller.js @@ -76,6 +76,7 @@ export async function checkUserExistByEmailorId(req, res) { export async function getUser(req, res) { try { const userId = req.params.id; + if (!isValidObjectId(userId)) { return res.status(404).json({ message: `User ${userId} not found` }); } diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 6c27e425ae..fa787030a6 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -79,10 +79,10 @@ export default function Login() { } const responseData = await response.json(); - console.log(responseData.data["accessToken"]); + const { accessToken, id, username, email, isAdmin, ...other } = responseData.data; + localStorage.setItem('token', accessToken); router.push("/question-repo"); } catch (error) { - console.error(error); } finally { setIsLoading(false); diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx new file mode 100644 index 0000000000..e4308a9c2a --- /dev/null +++ b/frontend/app/profile/page.tsx @@ -0,0 +1,261 @@ +"use client" + +import React, { useEffect, useRef, useState } from 'react'; +import { Input } from "@/components/ui/input"; // Shadcn Input component +import { Button } from "@/components/ui/button"; // Shadcn Button component +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; // Shadcn Avatar component +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogOverlay } from '@/components/ui/dialog'; +import Link from 'next/link'; +import { Badge } from '@/components/ui/badge'; +import { useRouter } from 'next/navigation'; +import { z } from 'zod'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { useForm } from 'react-hook-form'; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Info, LoaderCircle } from 'lucide-react'; + +const formSchema = z.object({ + username: z.string().min(4, "Username requires at least 4 characters"), + email: z.string().min(1, "Email is required").email({ message: "Invalid email address" }), +}); + +const UserProfile = () => { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [username, setUsername] = useState(null); + const [email, setEmail] = useState(null); + const userId = useRef(null); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + username: "", + email: "", + }, + }); + + useEffect(() => { + const authenticateUser = async () => { + try { + const token = localStorage.getItem('token'); + + if (!token) { + router.push('/'); // Redirect to login if no token + return; + } + + // Call the API to verify the token + const response = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/verify-token`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + localStorage.removeItem("token"); // remove invalid token from browser + router.push('/'); // Redirect to login if not authenticated + return; + } + + const data = (await response.json()).data; + + setUsername(data.username); + setEmail(data.email); + form.setValue("username", data.username); + form.setValue("email", data.email); + userId.current = data.id; + } catch (error) { + console.error('Error during authentication:', error); + router.push('/login'); // Redirect to login in case of any error + } + }; + + authenticateUser(); + }, []); + + async function onSubmit(values: z.infer) { + let isErrorSet = false; + try { + await form.trigger(); + if (!form.formState.isValid) { + return; + } + + setIsLoading(true); + setError(""); // Clear any previous errors + + // update user to backend + console.log("In user profile page: call update user api"); + const signUpResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_USERS_URL}/${userId.current}`, { + method: "PATCH", + headers: { + 'Content-Type': 'application/json', + 'authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify(values), + }); + + if (signUpResponse.status == 409) { + const responseMessage = (await signUpResponse.json()).message; + if (responseMessage.includes("username")) { + setError("Username already in use. Please choose a different one"); + isErrorSet = true; + throw new Error("username already exists " + signUpResponse.statusText); + } else { + setError("Email already in use. Please choose a different one"); + isErrorSet = true; + throw new Error("email already exist " + signUpResponse.statusText); + } + } else if (!signUpResponse.ok) { + setError("Failed to update profile."); + isErrorSet = true; + throw new Error("User not found" + signUpResponse.statusText); + } + } catch(err) { + if (!isErrorSet) { + setError("Something went wrong on our backend. Please retry shortly."); + } + console.error(err); + } finally { + setIsLoading(false); + } + } + + return ( +
+
+
+ + PeerPrep + + {process.env.NODE_ENV == "development" && ( + + DEV + + )} +
+
+ +
+
+ +
+ + + User Profile + + +
+ + + UserProfile + +

{username}

+

{email}

+
+
+ + ( + + +
+ Username + + + + +

Minimum 4 characters

+
+
+
+
+
+ + + + +
+ )} + /> + ( + + Email + + + + + + )} + /> + + + +
+
+
+ + {error && ( + setError('')}> + + + Error + + {error} + + + + + + + + )} +
+ ); +}; + +export default UserProfile; diff --git a/frontend/app/question-repo/page.tsx b/frontend/app/question-repo/page.tsx index 4dfbdce01e..40e8572df0 100644 --- a/frontend/app/question-repo/page.tsx +++ b/frontend/app/question-repo/page.tsx @@ -7,6 +7,7 @@ import { Badge, BadgeProps } from "@/components/ui/badge"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { useRouter } from "next/navigation"; const complexityList: Array<{ value: string; @@ -38,6 +39,7 @@ const categoryList: Array<{ ]; export default function QuestionRepo() { + const router = useRouter(); const [questionList, setQuestionList] = useState([]); // Complete list of questions const [loading, setLoading] = useState(true); @@ -76,6 +78,10 @@ export default function QuestionRepo() { fetchQuestions(); }, []); + const handleProfileRedirect = () => { + router.push('/profile'); // Update with your actual profile page path + }; + return (
@@ -101,7 +107,7 @@ export default function QuestionRepo() { Repository - + +
@@ -133,8 +152,8 @@ export default function VerifyEmail() { Verification Failed

{message}

- - + +
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index fa787030a6..c1a18c01d4 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -48,6 +48,7 @@ export default function Login() { setIsLoading(true); + console.log("In login page: call api to authenticate user") const response = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/login`, { method: "POST", headers: { @@ -83,6 +84,9 @@ export default function Login() { localStorage.setItem('token', accessToken); router.push("/question-repo"); } catch (error) { + if (!isErrorSet) { + setError("Something went wrong on our backend. Please retry shortly."); + } console.error(error); } finally { setIsLoading(false); diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index e4308a9c2a..187845a70c 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { Input } from "@/components/ui/input"; // Shadcn Input component import { Button } from "@/components/ui/button"; // Shadcn Button component import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; // Shadcn Avatar component -import { Label } from "@/components/ui/label"; +import { IoIosLogOut } from "react-icons/io"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogOverlay } from '@/components/ui/dialog'; import Link from 'next/link'; @@ -32,7 +32,7 @@ const formSchema = z.object({ const UserProfile = () => { const router = useRouter(); const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const [feedback, setFeedback] = useState({ message: '', type: '' }); const [username, setUsername] = useState(null); const [email, setEmail] = useState(null); const userId = useRef(null); @@ -85,7 +85,7 @@ const UserProfile = () => { authenticateUser(); }, []); - async function onSubmit(values: z.infer) { + async function onChangeUserProfile(values: z.infer) { let isErrorSet = false; try { await form.trigger(); @@ -94,9 +94,9 @@ const UserProfile = () => { } setIsLoading(true); - setError(""); // Clear any previous errors + setFeedback({ message: '', type: '' }); // three type: "success", "error", or "email-verification" - // update user to backend + // update username to backend console.log("In user profile page: call update user api"); const signUpResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_USERS_URL}/${userId.current}`, { method: "PATCH", @@ -104,28 +104,43 @@ const UserProfile = () => { 'Content-Type': 'application/json', 'authorization': `Bearer ${localStorage.getItem('token')}` }, - body: JSON.stringify(values), + body: JSON.stringify({ username: values.username }), }); if (signUpResponse.status == 409) { const responseMessage = (await signUpResponse.json()).message; if (responseMessage.includes("username")) { - setError("Username already in use. Please choose a different one"); + setFeedback({ message: "Username already in use. Please choose a different one", type: "error"}); isErrorSet = true; throw new Error("username already exists " + signUpResponse.statusText); } else { - setError("Email already in use. Please choose a different one"); + setFeedback({ message: 'Email already in use. Please choose a different one', type: 'error' }); isErrorSet = true; throw new Error("email already exist " + signUpResponse.statusText); } } else if (!signUpResponse.ok) { - setError("Failed to update profile."); + setFeedback({ message: 'Failed to update profile.', type: 'error' }); isErrorSet = true; throw new Error("User not found" + signUpResponse.statusText); } + + // Send email verifcation if user is changing email + if (email != values.email) { + const emailResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_EMAIL_URL}/`, { + method: "POST", + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ id: userId.current, type: 'email-update', ...values }), + }) + setFeedback({ message: "An has been sent to your email", type: "email-verification"}); + return; + } + + setFeedback({ message: 'Profile updated successfully.', type: 'success' }); } catch(err) { if (!isErrorSet) { - setError("Something went wrong on our backend. Please retry shortly."); + setFeedback({ message: "Something went wrong on our backend. Please retry shortly.", type: 'error' }); } console.error(err); } finally { @@ -168,7 +183,7 @@ const UserProfile = () => {
-
+
User Profile @@ -176,14 +191,14 @@ const UserProfile = () => {
- - UserProfile + + {username?.slice(0, 2)}

{username}

{email}

- + {
+
- - {error && ( - setError('')}> - - - Error - - {error} - - - - - - - + { feedback.message && ( + setFeedback({ message: '', type: '' })}> + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()}> + + + {feedback.type === 'error' && 'Error'} + {feedback.type === 'success' && 'Success'} + {feedback.type === 'email-verification' && 'Email Verification Required'} + + + {feedback.message} + + + + + + + )}
); diff --git a/frontend/app/question-repo/page.tsx b/frontend/app/question-repo/page.tsx index 40e8572df0..dec5dd6eaa 100644 --- a/frontend/app/question-repo/page.tsx +++ b/frontend/app/question-repo/page.tsx @@ -43,6 +43,48 @@ export default function QuestionRepo() { const [questionList, setQuestionList] = useState([]); // Complete list of questions const [loading, setLoading] = useState(true); + // authenticate user else redirect them to login page + useEffect(() => { + const authenticateUser = async () => { + try { + const token = localStorage.getItem('token'); + + if (!token) { + router.push('/'); // Redirect to login if no token + return; + } + + // Call the API to verify the token + const response = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/verify-token`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + localStorage.removeItem("token"); // remove invalid token from browser + router.push('/'); // Redirect to login if not authenticated + return; + } + + const data = (await response.json()).data; + + // if needed + // setUsername(data.username); + // setEmail(data.email); + // form.setValue("username", data.username); + // form.setValue("email", data.email); + // userId.current = data.id; + } catch (error) { + console.error('Error during authentication:', error); + router.push('/login'); // Redirect to login in case of any error + } + }; + + authenticateUser(); + }, []); + useEffect(() => { async function fetchQuestions() { try { diff --git a/frontend/app/questions/page.tsx b/frontend/app/questions/page.tsx index 3152b62757..2fa27af7e3 100644 --- a/frontend/app/questions/page.tsx +++ b/frontend/app/questions/page.tsx @@ -8,6 +8,7 @@ import { MultiSelect } from "@/components/ui/multi-select"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Flag, MessageSquareText } from "lucide-react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import React, { useEffect, useState } from "react"; type Question = { @@ -49,6 +50,7 @@ const categoryList: Array<{ ]; export default function Home() { + const router = useRouter(); const [selectedComplexities, setSelectedComplexities] = useState( complexityList.map((diff) => diff.value) ); @@ -62,6 +64,47 @@ export default function Home() { const [isSelectAll, setIsSelectAll] = useState(false); const [reset, setReset] = useState(false); + // authenticate user else redirect them to login page + useEffect(() => { + const authenticateUser = async () => { + try { + const token = localStorage.getItem('token'); + + if (!token) { + router.push('/'); // Redirect to login if no token + return; + } + + // Call the API to verify the token + const response = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/verify-token`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + localStorage.removeItem("token"); // remove invalid token from browser + router.push('/'); // Redirect to login if not authenticated + return; + } + + const data = (await response.json()).data; + + // if needed + // setUsername(data.username); + // setEmail(data.email); + // form.setValue("username", data.username); + // form.setValue("email", data.email); + // userId.current = data.id; + } catch (error) { + console.error('Error during authentication:', error); + router.push('/login'); // Redirect to login in case of any error + } + }; + authenticateUser(); + }, []); + // Fetch questions from backend API useEffect(() => { async function fetchQuestions() { @@ -177,6 +220,10 @@ export default function Home() { console.log("Selected complexities:", selectedComplexities); }, [selectedComplexities]); // This effect runs every time selectedcomplexities change + const handleProfileRedirect = () => { + router.push('/profile'); // Update with your actual profile page path + }; + return ( //
@@ -203,7 +250,7 @@ export default function Home() { Repository - - -
- - -
- - - User Profile - - -
- - - {username?.slice(0, 2)} - -

{username}

-

{email}

-
-
- - ( - - -
- Username - - - - -

Minimum 4 characters

-
-
-
-
-
- - - - -
- )} - /> - ( - - Email - - - - - - )} - /> - - - -
-
- -
- { feedback.message && ( - setFeedback({ message: '', type: '' })}> - e.preventDefault()} - onInteractOutside={(e) => e.preventDefault()} - onEscapeKeyDown={(e) => e.preventDefault()}> - - - {feedback.type === 'error' && 'Error'} - {feedback.type === 'success' && 'Success'} - {feedback.type === 'email-verification' && 'Email Verification Required'} - - - {feedback.message} - - - - - - - - )} -
- ); -}; - -export default UserProfile;