diff --git a/backend/user-service/.env b/backend/user-service/.env
index c995ca9a5c..f3abcdb539 100644
--- a/backend/user-service/.env
+++ b/backend/user-service/.env
@@ -5,5 +5,17 @@ PORT=3001
# Will use cloud MongoDB Atlas database
ENV=PROD
+# email details
+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
+
+# frontend host
+FRONTEND_HOST=http://localhost
+FRONTEND_PORT=3000
+
# 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..138c889fe7 100644
--- a/backend/user-service/README.md
+++ b/backend/user-service/README.md
@@ -61,9 +61,32 @@
|-----------------------------|-------------------------------------------------------|
| 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 |
+### 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
+
+- 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.
@@ -134,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,
}
```
@@ -253,6 +277,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 +294,129 @@
|-----------------------------|----------------------------------------------------|
| 200 (OK) | Token verified, authenticated user's data returned |
| 401 (Unauthorized) | Missing/invalid/expired JWT |
- | 500 (Internal Server Error) | Database or server error |
\ No newline at end of file
+ | 500 (Internal Server Error) | Database or server error |
+
+### Send Email
+
+- 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), `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",
+ "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). |
+ | 404 (Not Found) | User with specified email not found |
+ | 500 (Internal Server Error) | Database or server error |
+
+### Send Verification Link Email
+
+- 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), `id` (string), `type` (string)
+
+ ```json
+ {
+ "email": "sample@gmail.com",
+ "username": "us",
+ "id": "avid0ud9ay2189rgdbjvdak",
+ "type": "sign-up" // or "update"
+ }
+ ```
+
+- 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 |
+
+### 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 d49517bf70..136a7c9ed6 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";
@@ -9,12 +10,16 @@ export async function handleLogin(req, res) {
try {
const user = await _findUserByEmail(email);
if (!user) {
- return res.status(401).json({ message: "Wrong email and/or password" });
+ return res.status(401).json({ message: "Wrong email" });
+ }
+
+ 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" });
+ return res.status(401).json({ message: "Wrong password" });
}
const accessToken = jwt.sign({
@@ -39,3 +44,92 @@ 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) {
+ return res.status(400).json({ message: "Both 'email' and 'otp' are required." });
+ }
+
+ const user = await _findUserByEmail(email);
+
+ if (!user) {
+ return res.status(404).json({ message: `User with email '${email}' not found` });
+ }
+
+ if (!user.otp) {
+ return res.status(403).json({
+ message: 'No OTP request found for this user'
+ });
+ }
+
+ if (user.otp !== otp || new Date() > user.otpExpiresAt) {
+ 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.error("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;
+ 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) {
+ 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
new file mode 100644
index 0000000000..22ea8ab91d
--- /dev/null
+++ b/backend/user-service/controller/email-controller.js
@@ -0,0 +1,247 @@
+import crypto from 'crypto';
+import nodemailer from 'nodemailer';
+import {
+ findUserById as _findUserById,
+ findUserByEmail as _findUserByEmail,
+} from "../model/repository.js";
+
+function generateOtp() {
+ return crypto.randomInt(100000, 999999).toString(); // Generates a 6-digit OTP
+}
+
+const generateOTPforUser = async (email) => {
+ const user = await _findUserByEmail(email);
+
+ if (!user) {
+ throw new Error("User not found.");
+ }
+
+ user.otp = generateOtp();
+ user.otpExpiresAt = Date.now() + 10 * 60 * 1000; // OTP valid for 10 minutes
+ await user.save();
+
+ return user.otp;
+}
+
+export const sendEmail = async (req, res) => {
+ try {
+ const { email, title, html } = req.body;
+ // Check if all required fields are present
+ 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: {
+ user: process.env.EMAIL_USER,
+ pass: process.env.EMAIL_PASS,
+ },
+ });
+
+ // Email options
+ const mailOptions = {
+ from: process.env.EMAIL_USER,
+ to: email,
+ subject: title,
+ html: html,
+ };
+
+ // Send the email
+ await transporter.sendMail(mailOptions);
+
+ // Return success response
+ 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 email' });
+ }
+};
+
+export const sendOTPEmail = async (req, res) => {
+ const { username, email } = req.body;
+ 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: `
+
+
+
+
+
+
+
+
+
+
Hi ${username},
+
We received a request to reset your password. Enter the following verification code to complete the process:
+
${otp}
+
+
+
+
+ `
+ }
+ sendEmail(req, res);
+}
+
+export const sendVerificationEmail = async (req, res) => {
+ const { username, email, id, type } = req.body;
+
+ let verificationLink;
+ if (type === 'sign-up') {
+ // For sign-up, only include the user ID in the verification link
+ verificationLink = `${process.env.FRONTEND_HOST}:${process.env.FRONTEND_PORT}/auth/verify-email?type=sign-up&id=${encodeURIComponent(id)}`;
+ } else if (type === 'email-update') {
+ // For email update, include user ID, email, and username
+ verificationLink = `${process.env.FRONTEND_HOST}:${process.env.FRONTEND_PORT}/auth/verify-email?type=update&id=${encodeURIComponent(id)}&email=${encodeURIComponent(email)}`;
+ }
+
+ let emailTitle;
+ let emailMessage;
+ if (type === 'sign-up') {
+ emailTitle = 'Confirm Your Email Address for PeerPrep';
+ emailMessage = `
+ Thank you for signing up for PeerPrep ! To start using your account, please verify your email address by clicking the button below:
+ Verify Email
+ If the button above doesn't work, please copy and paste the following link into your browser:
+ ${verificationLink}
+ `;
+ } else if (type === 'email-update') {
+ emailTitle = 'Confirm Your New Email Address for PeerPrep';
+ emailMessage = `
+ You recently requested to update your email for PeerPrep . To complete the process, please verify your new email address by clicking the button below:
+ Verify Email
+ If the button above doesn't work, please copy and paste the following link into your browser:
+ ${verificationLink}
+ `;
+ }
+
+ req.body = {
+ email: email,
+ title: emailTitle,
+ html: `
+
+
+
+
+
+
+
+
+
+
Welcome, ${username}!
+ ${emailMessage}
+
+
+
+
+ `,
+ }
+ 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 985a83384f..ebfb1f914f 100644
--- a/backend/user-service/controller/user-controller.js
+++ b/backend/user-service/controller/user-controller.js
@@ -16,11 +16,26 @@ export async function createUser(req, res) {
try {
const { username, email, password } = req.body;
if (username && email && password) {
- const existingUser = await _findUserByUsernameOrEmail(username, email);
- if (existingUser) {
- return res.status(409).json({ message: "username or email already exists" });
+ const existingUserByUsername = await _findUserByUsername(username);
+ const existingUserByEmail = await _findUserByEmail(email);
+
+ if (existingUserByEmail) {
+ // Check if the user exists but is not verified
+ if (!existingUserByEmail.isVerified && username == existingUserByEmail.username) {
+ // 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.",
+ data: formatUserResponse(existingUserByEmail),
+ });
+ }
+ // Return conflict error if the user is already verified
+ return res.status(409).json({ message: "email already exists" });
+ }
+ if (existingUserByUsername){
+ return res.status(409).json({
+ message: "username already exists."
+ });
}
-
const salt = bcrypt.genSaltSync(10);
const hashedPassword = bcrypt.hashSync(password, salt);
const createdUser = await _createUser(username, email, hashedPassword);
@@ -37,9 +52,37 @@ 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;
+
if (!isValidObjectId(userId)) {
return res.status(404).json({ message: `User ${userId} not found` });
}
@@ -69,8 +112,8 @@ export async function getAllUsers(req, res) {
export async function updateUser(req, res) {
try {
- const { username, email, password } = 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` });
@@ -95,7 +138,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),
@@ -162,6 +205,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 24a5835874..9598991bce 100644
--- a/backend/user-service/index.js
+++ b/backend/user-service/index.js
@@ -1,8 +1,10 @@
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";
+import emailRoutes from "./routes/email-routes.js";
const app = express();
@@ -32,6 +34,9 @@ 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/middleware/basic-access-control.js b/backend/user-service/middleware/basic-access-control.js
index bb92665710..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 };
+ req.user = { id: dbUser.id, username: dbUser.username, email: dbUser.email, isAdmin: dbUser.isAdmin, isVerified: dbUser.isVerified, otp: dbUser.otp, otpExpiresAt: dbUser.otpExpiresAt };
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..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) {
+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,
{
@@ -48,6 +54,11 @@ export async function updateUserById(userId, username, email, password) {
username,
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 df37491d09..d46aeeaf95 100644
--- a/backend/user-service/model/user-model.js
+++ b/backend/user-service/model/user-model.js
@@ -26,6 +26,26 @@ const UserModelSchema = new Schema({
required: true,
default: false,
},
+ isVerified: {
+ type: Boolean,
+ default: false,
+ },
+ otp: {
+ type: String,
+ required: false,
+ },
+ otpExpiresAt: {
+ 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/package-lock.json b/backend/user-service/package-lock.json
index e3dfb3c57d..ab2cd1a6a2 100644
--- a/backend/user-service/package-lock.json
+++ b/backend/user-service/package-lock.json
@@ -13,8 +13,12 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
+ "googleapis": "^144.0.0",
"jsonwebtoken": "^9.0.2",
- "mongoose": "^8.5.4"
+ "mongoose": "^8.5.4",
+ "nodemailer": "^6.9.15",
+ "react-icon": "^1.0.0",
+ "react-icons": "^5.3.0"
},
"devDependencies": {
"nodemon": "^3.1.4"
@@ -167,12 +171,42 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
+ "node_modules/babel-runtime": {
+ "version": "5.8.38",
+ "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-5.8.38.tgz",
+ "integrity": "sha512-KpgoA8VE/pMmNCrnEeeXqFG24TIH11Z3ZaimIhJWsin8EbfZy3WzFKUTIan10ZIDgRVvi9EkLbruJElJC9dRlg==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "core-js": "^1.0.0"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"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",
@@ -187,6 +221,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",
@@ -381,6 +424,14 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
+ "node_modules/core-js": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz",
+ "integrity": "sha512-ZiPp9pZlgxpWRu0M+YWbm6+aQ84XEfH1JRXvfOc/fILWI0VKhLC2LX13X1NYq4fULzLMq7Hfh43CSo2/aIaUPA==",
+ "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
@@ -574,6 +625,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",
@@ -698,6 +755,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",
@@ -751,6 +904,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",
@@ -763,6 +997,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",
@@ -979,6 +1247,34 @@
"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/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT",
+ "peer": true
+ },
+ "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",
@@ -1079,6 +1375,19 @@
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
@@ -1418,6 +1727,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",
@@ -1657,6 +1975,38 @@
"node": ">= 0.8"
}
},
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-icon": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/react-icon/-/react-icon-1.0.0.tgz",
+ "integrity": "sha512-VzSlpBHnLanVw79mOxyq98hWDi6DlxK9qPiZ1bAK6bLurMBCaxO/jjyYUrRx9+JGLc/NbnwOmyE/W5Qglbb2QA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "babel-runtime": "^5.3.3",
+ "react": ">=0.12.0"
+ }
+ },
+ "node_modules/react-icons": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.3.0.tgz",
+ "integrity": "sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "*"
+ }
+ },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -2020,6 +2370,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",
@@ -2035,6 +2391,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 b3ac4db247..bd5669fa56 100644
--- a/backend/user-service/package.json
+++ b/backend/user-service/package.json
@@ -20,7 +20,9 @@
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
+ "googleapis": "^144.0.0",
"jsonwebtoken": "^9.0.2",
- "mongoose": "^8.5.4"
+ "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 0000000000..718d6fea48
Binary files /dev/null and b/backend/user-service/public/images/favicon.ico differ
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/backend/user-service/routes/email-routes.js b/backend/user-service/routes/email-routes.js
new file mode 100644
index 0000000000..1356df3a38
--- /dev/null
+++ b/backend/user-service/routes/email-routes.js
@@ -0,0 +1,11 @@
+import express from 'express';
+
+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);
+
+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/.env.local b/frontend/.env.local
index eb3dc037fe..cb1f5a60aa 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
@@ -9,7 +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
\ No newline at end of file
+NEXT_PUBLIC_MATCHING_API_URL=$PUBLIC_URL:$MATCHING_API_PORT/matching
+
+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/(authenticated)/layout.tsx b/frontend/app/(authenticated)/layout.tsx
index 7ab2f1e87c..23c431611d 100644
--- a/frontend/app/(authenticated)/layout.tsx
+++ b/frontend/app/(authenticated)/layout.tsx
@@ -68,7 +68,12 @@ export default function AuthenticatedLayout({
Username
Profile
- Log out
+ {
+ // e.preventDefault();
+ localStorage.removeItem("token");
+ }}>
+ Log out
+
diff --git a/frontend/app/(authenticated)/profile/page.tsx b/frontend/app/(authenticated)/profile/page.tsx
index 37acac9d3b..54cddbf14f 100644
--- a/frontend/app/(authenticated)/profile/page.tsx
+++ b/frontend/app/(authenticated)/profile/page.tsx
@@ -1,40 +1,244 @@
"use client";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
-import { CircleX, Pencil, Save } from "lucide-react";
-import React, { ChangeEvent, useState } from "react";
+import { AlertCircle, CircleX, Pencil, Save } from "lucide-react";
+import { useRouter } from "next/navigation";
+import React, { ChangeEvent, useEffect, useRef, useState } from "react";
export default function Home() {
+ const router = useRouter();
+ const [feedback, setFeedback] = useState({ message: '', type: '' });
const [isEditing, setIsEditing] = useState(false);
const [userData, setUserData] = useState({
username: "johndoe",
email: "john@example.com",
password: "abcdefgh",
});
+ const initialUserData = useRef({
+ username: "johndoe",
+ email: "john@example.com",
+ password: "abcdefgh",
+ })
+ const userId = useRef(null);
+
+ useEffect(() => {
+ const authenticateUser = async () => {
+ try {
+ const token = localStorage.getItem('token');
+
+ if (!token) {
+ router.push('/auth/login'); // 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('/auth/login'); // Redirect to login if not authenticated
+ return;
+ }
- const handleEdit = () => {
- if (isEditing) {
- console.log("Saving changes:", userData);
+ const data = (await response.json()).data;
+ // placeholder for password *Backend wont expose password via any API call
+ const password = "********";
+
+ setUserData({
+ username: data.username,
+ email: data.email,
+ password: password,
+ })
+ initialUserData.current = {
+ username: data.username,
+ email: data.email,
+ password: password,
+ };
+ userId.current = data.id;
+ } catch (error) {
+ console.error('Error during authentication:', error);
+ router.push('/auth/login'); // Redirect to login in case of any error
+ }
+ };
+ authenticateUser();
+ }, []);
+
+ // Validate the password before making the API call
+ const validatePassword = (password: string) => {
+ let errorMessage = "";
+ if (!/[A-Z]/.test(password)) {
+ errorMessage += "Must contain at least one uppercase letter.\n";
}
- setIsEditing(!isEditing);
+ if (!/[a-z]/.test(password)) {
+ errorMessage += "Must contain at least one lowercase letter.\n";
+ }
+ if (!/[0-9]/.test(password)) {
+ errorMessage += "Must contain at least one number.\n";
+ }
+ if (!/[^A-Za-z0-9]/.test(password)) {
+ errorMessage += "Must contain at least one special character.\n";
+ }
+ return errorMessage;
};
+ const handleEdit = async () => {
+ let isErrorSet = false;
+ // Create an object to store only changed fields
+ // const updatedFields: any = {};
+ try {
+ setFeedback({ message: '', type: '' });
+ if (userData.username !== initialUserData.current.username) {
+ // updatedFields.username = userData.username;
+ 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({ username: userData.username }),
+ });
+ if (signUpResponse.status == 409) {
+ const responseMessage = (await signUpResponse.json()).message;
+ if (responseMessage.includes("username")) {
+ setFeedback({ message: "Username already in use. Please choose a different one", type: "error"});
+ isErrorSet = true;
+ throw new Error("username already exists " + signUpResponse.statusText);
+ } else {
+ 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) {
+ setFeedback({ message: 'Failed to update profile.', type: 'error' });
+ isErrorSet = true;
+ throw new Error("User not found" + signUpResponse.statusText);
+ } else {
+ initialUserData.current.username = userData.username; // update username new value
+ }
+ }
+ if (userData.email !== initialUserData.current.email) {
+ console.log("In user profile page: call api to check email exist in db");
+ const userResponse = await fetch(`${process.env.NEXT_PUBLIC_USER_API_USERS_URL}/check?email=${userData.email}`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ }
+ });
+
+ if (userResponse.status == 200) {
+ console.log("duplicate email")
+ setFeedback({ message: "This email is already in use. Please choose another one.", type: 'error' });
+ isErrorSet = true;
+ throw new Error("Email already exists in the database when user update their profile.");
+ } else if (userResponse.status !== 404) {
+ throw new Error("Error happen when calling API to detect duplicate 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({
+ id: userId.current,
+ type: 'email-update',
+ email: userData.email,
+ username: userData.username
+ }),
+ })
+
+ if (emailResponse.ok) {
+ initialUserData.current.email = userData.email;
+ setFeedback({ message: `An email has been sent to your new address ${userData.email} for verification. Please check your inbox or spam folder.`, type: "email-verification" });
+ } else {
+ setFeedback({ message: "There was an error sending the verification email.", type: 'error' });
+ throw new Error("Error during email verification process.");
+ }
+ }
+
+ if (userData.password !== initialUserData.current.password) {
+ console.log("detect password change: original:", initialUserData.current.password, " new pw: ", userData.password)
+ // Check for password validity
+ const passwordError = validatePassword(userData.password);
+ if (passwordError) {
+ setFeedback({ message: `Password does not meet requirements:\n${passwordError}`, type: "error" });
+ isErrorSet = true;
+ throw new Error("Password update failed");
+ }
+ const passwordResponse = 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({ password: userData.password }),
+ });
+ if (!passwordResponse.ok) {
+ setFeedback({ message: "Failed to update password.", type: "error" });
+ isErrorSet = true;
+ throw new Error("Password update failed");
+ } else {
+ initialUserData.current.password = userData.password;
+ }
+ }
+
+ setIsEditing(!isEditing);
+ } catch(err) {
+ if (!isErrorSet) {
+ setFeedback({ message: "Something went wrong on our backend. Please retry shortly.", type: 'error' });
+ }
+ console.error(err);
+ }
+ };
+
+
const handleInputChange = (e: ChangeEvent) => {
const { id, value } = e.target;
setUserData(prev => ({ ...prev, [id]: value }));
};
+ const handleClose = async () => {
+ const response = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/verify-token`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${localStorage.getItem('token')}`,
+ },
+ });
+
+ if (!response.ok) {
+ localStorage.removeItem("token"); // remove invalid token from browser
+ router.push('/auth/login'); // Redirect to login if not authenticated
+ return;
+ }
+
+ const data = (await response.json()).data;
+
+ setUserData({
+ username: data.username,
+ email: data.email,
+ password: data.password,
+ })
+
+ setFeedback({ message: '', type: '' });
+ setIsEditing(!isEditing);
+ }
+
return (
-
+
Profile
{isEditing ? (
-
+
@@ -42,12 +246,30 @@ export default function Home() {
) : (
-
+ setIsEditing(!isEditing)} className="bg-primary text-primary-foreground border-none rounded-xl hover:bg-primary/85 hover:text-primary-foreground">
)}
+ {feedback.message && (
+
+
+
+ {feedback.type === 'error' ? 'Error' : 'Check your email'}
+
+
+ {feedback.message.split('\n').map((line, index, arr) => (
+ line && (
+
+ {line}
+ {index < arr.length - 1 && } {/* Only add if it's not the last line */}
+
+ )
+ ))}
+
+
+ )}
Username
diff --git a/frontend/app/(authenticated)/question-repo/page.tsx b/frontend/app/(authenticated)/question-repo/page.tsx
index 772cb251ef..d8a5d2b99d 100644
--- a/frontend/app/(authenticated)/question-repo/page.tsx
+++ b/frontend/app/(authenticated)/question-repo/page.tsx
@@ -3,7 +3,11 @@
import { useEffect, useState } from "react";
import { columns, Question } from "./columns"
import { DataTable } from "./data-table"
-import { BadgeProps } from "@/components/ui/badge";
+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;
@@ -35,9 +39,52 @@ const categoryList: Array<{
];
export default function QuestionRepo() {
+ const router = useRouter();
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 {
@@ -73,10 +120,53 @@ export default function QuestionRepo() {
fetchQuestions();
}, []);
+ const handleProfileRedirect = () => {
+ router.push('/profile'); // Update with your actual profile page path
+ };
+
return (
Question Repository
+ //
+ //
+
+ //
+ // Question Repository
+ //
+ //
+ //
);
}
diff --git a/frontend/app/(authenticated)/questions/page.tsx b/frontend/app/(authenticated)/questions/page.tsx
index 46431040fd..cc576c3694 100644
--- a/frontend/app/(authenticated)/questions/page.tsx
+++ b/frontend/app/(authenticated)/questions/page.tsx
@@ -6,6 +6,8 @@ import { Card } from "@/components/ui/card";
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 = {
@@ -47,6 +49,7 @@ const categoryList: Array<{
];
export default function Home() {
+ const router = useRouter();
const [selectedComplexities, setSelectedComplexities] = useState(
complexityList.map((diff) => diff.value)
);
@@ -60,6 +63,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() {
diff --git a/frontend/app/forgot-password/page.tsx b/frontend/app/auth/forgot-password/page.tsx
similarity index 59%
rename from frontend/app/forgot-password/page.tsx
rename to frontend/app/auth/forgot-password/page.tsx
index d23086fba2..231cf44afa 100644
--- a/frontend/app/forgot-password/page.tsx
+++ b/frontend/app/auth/forgot-password/page.tsx
@@ -14,6 +14,7 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form"
+import { useRouter } from "next/navigation"
import { useEffect, useState } from "react";
import { AlertCircle, ChevronLeft, LoaderCircle, TriangleAlert } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -22,11 +23,10 @@ const formSchema = z.object({
email: z.string().min(1, "Email is required").email({ message: "Invalid email address" }),
})
-export default function ForgotPassword() {
+export default function ForgottenPassword() {
+ const router = useRouter()
const [isLoading, setIsLoading] = useState(false);
- const [error, setError] = useState(null);
- const [success, setSuccess] = useState(false);
- const [countdown, setCountdown] = useState(60);
+ const [error, setError] = useState("");
const form = useForm>({
resolver: zodResolver(formSchema),
@@ -35,27 +35,8 @@ export default function ForgotPassword() {
},
});
- useEffect(() => {
- let timer: NodeJS.Timeout;
- if (success && countdown > 0) {
- timer = setInterval(() => {
- setCountdown((prevCount) => prevCount - 1);
- }, 1000);
- }
- return () => {
- if (timer) clearInterval(timer);
- };
- }, [success, countdown]);
-
- useEffect(() => {
- if (countdown === 0) {
- setSuccess(false);
- setCountdown(60);
- }
- }, [countdown]);
-
async function onSubmit(values: z.infer) {
- // Placeholder for auth to user service
+ let isErrorSet = false;
try {
await form.trigger();
if (!form.formState.isValid) {
@@ -63,30 +44,45 @@ export default function ForgotPassword() {
}
setIsLoading(true);
+ setError(""); // Clear any previous errors
- const response = await fetch(`${process.env.NEXT_PUBLIC_USER_API_AUTH_URL}/forgot`, {
- method: "POST",
+ // 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',
- },
- body: JSON.stringify(values),
+ }
});
+ 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.");
+ }
- if (response.status == 400) {
- setError("Missing email.");
- throw new Error("Missing email: " + response.statusText);
- } else if (response.status == 500) {
- setError("Database or server error. Please try again.");
- throw new Error("Database or server error: " + response.statusText);
- } else if (!response.ok) {
- setError("There was an error resetting your password. Please try again.");
- throw new Error("Error sending reset link: " + response.statusText);
+ 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`);
}
- const responseData = await response.json();
- setSuccess(true);
- setCountdown(60);
- } catch (error) {
+ router.push(`/auth/forgot-password/verify-code?email=${encodeURIComponent(email)}`);
+ } catch (err) {
+ if (!isErrorSet) {
+ setError("Something went wrong on our backend. Please retry shortly.");
+ }
console.error(error);
} finally {
setIsLoading(false);
@@ -97,14 +93,14 @@ export default function ForgotPassword() {
- Back to Login
+ Back to Login
Forgot your password?
- Enter your email address and we will send you a link to reset your password.
+ Enter your email address and we will send you a verification code to reset your password.
{error && (
@@ -116,15 +112,6 @@ export default function ForgotPassword() {
)}
- {success && (
-
-
- Check your email
-
- A link to reset your password has been sent to your email address.
-
-
- )}
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..40bc46bfec
--- /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/reset-password?email=${encodeURIComponent(param_email)}&token=${resetPasswordToken}`);
+ } catch (err) {
+ if (!isErrorSet) {
+ setError("Something went wrong on our backend. Please retry shortly.");
+ }
+ console.error(err);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+
We have sent a code to your email {param_email && ":" + param_email} Please check your email.
+
+
+ {error && (
+
+
+ Error
+
+ {error}
+
+
+ )}
+
+
+ {/*
*/}
+
+
+ );
+}
diff --git a/frontend/app/login/page.tsx b/frontend/app/auth/login/page.tsx
similarity index 77%
rename from frontend/app/login/page.tsx
rename to frontend/app/auth/login/page.tsx
index 110000cb96..9a668f9604 100644
--- a/frontend/app/login/page.tsx
+++ b/frontend/app/auth/login/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();
@@ -47,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: {
@@ -54,25 +56,41 @@ export default function Login() {
},
body: JSON.stringify(values),
});
-
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.");
+ const data = await response.json();
+ if (data.message.includes("email")) {
+ setError("User not registered yet.");
+ } else {
+ setError("Incorrect 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);
}
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) {
+ if (!isErrorSet) {
+ setError("Something went wrong on our backend. Please retry shortly.");
+ }
console.error(error);
} finally {
setIsLoading(false);
@@ -80,7 +98,7 @@ export default function Login() {
}
return (
-
+
@@ -106,7 +124,7 @@ export default function Login() {
Email
-
+
@@ -120,7 +138,7 @@ export default function Login() {
Password
-
+
Forgot password?
@@ -145,10 +163,19 @@ export default function Login() {
+ {/*
+ Forgot your password?{" "}
+
+ Reset it
+
+
*/}
Don't have an account?{" "}
Sign up
diff --git a/frontend/app/auth/reset-password/page.tsx b/frontend/app/auth/reset-password/page.tsx
new file mode 100644
index 0000000000..c6d29cfc64
--- /dev/null
+++ b/frontend/app/auth/reset-password/page.tsx
@@ -0,0 +1,219 @@
+"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,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { useRouter, useSearchParams } from "next/navigation"
+import { useEffect, useState } from "react";
+import { AlertCircle, LoaderCircle, TriangleAlert } 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
+ .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 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 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) {
+ 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 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
+ })
+ });
+
+ // 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);
+ }
+
+ router.push(`/auth/reset-password/success`);
+ } catch (err) {
+ if (!isErrorSet) {
+ setError("Something went wrong on our backend. Please retry shortly.");
+ }
+ console.error(error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ return (
+
+
+
+
+ Reset Password
+
+
+ {error && (
+
+
+ Error
+
+ {error}
+
+
+ )}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/app/auth/reset-password/success/page.tsx b/frontend/app/auth/reset-password/success/page.tsx
new file mode 100644
index 0000000000..85dc67ab6b
--- /dev/null
+++ b/frontend/app/auth/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.
+
+ Go to Login
+
+
+ );
+};
+
+export default PasswordChangeSuccess;
diff --git a/frontend/app/auth/sign-up/page.tsx b/frontend/app/auth/sign-up/page.tsx
new file mode 100644
index 0000000000..d04569b1f2
--- /dev/null
+++ b/frontend/app/auth/sign-up/page.tsx
@@ -0,0 +1,388 @@
+"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,
+ FormDescription,
+ 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
+ let isErrorSet = false;
+ 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.");
+ isErrorSet = true;
+ 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) {
+ const responseMessage = await signUpResponse.json();
+
+ if (responseMessage.message.includes("username")) {
+ setError("A user with this username already exists.");
+ } else if (responseMessage.message.includes("email")) {
+ setError("This email is already registered.");
+ } else {
+ 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.");
+ 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,
+ })
+
+ // 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,
+ id: id,
+ type: "sign-up"
+ }),
+ });
+
+ // 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.");
+ 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",
+ 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 (err) {
+ if (!isErrorSet) {
+ setError("Something went wrong on our backend. Please retry shortly.");
+ }
+ console.error(err);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ const handleCloseDialog = () => {
+ setSuccessMessage('');
+ };
+
+ const handleResendEmail = async () => {
+ const { id, username, email } = userInfo;
+ 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,
+ id: id,
+ type: "sign-up"
+ }),
+ });
+ };
+
+ return (
+
+
+
+ PeerPrep
+
+
+
+
+
+ Create an account
+
+
+ Enter a username, email and password to sign up
+
+
+
+ {error && (
+
+
+ Error
+
+ {error}
+
+
+ )}
+
+
+
+
+ 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..bec0e2c727
--- /dev/null
+++ b/frontend/app/auth/sign-up/success-dialog.tsx
@@ -0,0 +1,141 @@
+import React, { useEffect, 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 [isLoading, setIsLoading] = useState(false);
+ const [showFeedback, setShowFeedback] = useState(false);
+ // const [success, setSuccess] = useState(false);
+ // const [countdown, setCountdown] = useState(60);
+ // 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.");
+ // }
+ // };
+
+ // useEffect(() => {
+ // let timer: NodeJS.Timeout;
+ // if (success && countdown > 0) {
+ // timer = setInterval(() => {
+ // setCountdown((prevCount) => prevCount - 1);
+ // }, 1000);
+ // }
+ // return () => {
+ // if (timer) clearInterval(timer);
+ // };
+ // }, [success, countdown]);
+
+ // useEffect(() => {
+ // if (countdown === 0) {
+ // setSuccess(false);
+ // setCountdown(60);
+ // }
+ // }, [countdown]);
+
+ const handleResendClick = () => {
+ // setIsLoading(true);
+ onResend();
+ setShowFeedback(true);
+
+ // setSuccess(true);
+ // setCountdown(60);
+
+ // Hide the UI and close the dialog after 3 seconds (3000ms) to avoid user multiple attempt of resend email
+ setTimeout(() => {
+ setShowFeedback(false);
+ onClose();
+ }, 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? It might be in your spam folder, or you can{" "}
+
+ Resend the link
+
+ {/* 0)}
+ >
+ {isLoading ? (
+
+ ) : success && countdown > 0 ? (
+ `Try again in ${countdown}s`
+ ) : (
+ "Send reset link"
+ )}
+ */}
+
+ }
+ {showFeedback && (
+
+
+ Email has been resent! Please check your inbox.
+
+ )}
+
+
+ Close
+
+
+
+ );
+}
diff --git a/frontend/app/auth/verify-email/page.tsx b/frontend/app/auth/verify-email/page.tsx
new file mode 100644
index 0000000000..2c6c5e3082
--- /dev/null
+++ b/frontend/app/auth/verify-email/page.tsx
@@ -0,0 +1,163 @@
+"use client"
+
+import { useEffect, useState } from 'react';
+import { useSearchParams } from 'next/navigation';
+import { Button } from "@/components/ui/button";
+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');
+ const type = searchParams.get('type')
+ const email = searchParams.get('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) {
+ 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 api to check if user exist")
+ 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
+ if (type == 'sign-up') {
+ 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.');
+ } else {
+ const response = await fetch(`${process.env.NEXT_PUBLIC_USER_API_USERS_URL}/${id}`, {
+ method: "PATCH",
+ headers: {
+ 'Content-Type': 'application/json',
+ 'authorization': `Bearer ${localStorage.getItem('token')}`
+ },
+ body: JSON.stringify({ email: email }),
+ });
+ if (!response.ok) {
+ const errorMessage = (await response.json()).message;
+ setMessage('Unexpected error occured. Please check the URL or request a new one.');
+ setStatus('error');
+ throw Error("Failed to update update user email: " + errorMessage);
+ }
+ setMessage('Your email address has been successfully updated!');
+ }
+
+ setStatus('success');
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ verifyUserEmail();
+ }, [id]);
+
+ return (
+
+ {status === 'loading' && (
+
+
Verifying your email, please wait...
+
+ )}
+ {status === 'success' && (
+ <>
+
+
+ Email Verified!
+
+
{message}
+
+
{type === 'sign-up' ? 'Go to Login' : 'Go to Homepage'}
+
+
+ >
+ )}
+ {status === 'error' && (
+ <>
+
+
+ Verification Failed
+
+
{message}
+
+
Go Back to Profile
+
+
+ >
+ )}
+
+ );
+};
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index e5262b0635..697831f349 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -8,15 +8,14 @@ export default function Landing() {
Temporary landing page with all the links
- Login
+ Login
- Sign Up
+ Sign Up
- Forgot password (enter email for link)
- Reset password (enter password to reset)
+ Forgot password (enter email for OTP)
Profile page
diff --git a/frontend/app/question/page.tsx b/frontend/app/question/page.tsx
new file mode 100644
index 0000000000..94ee5a931c
--- /dev/null
+++ b/frontend/app/question/page.tsx
@@ -0,0 +1,407 @@
+"use client";
+
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Badge, BadgeProps } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+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 = {
+ id: number;
+ title: string;
+ complexity: string | undefined;
+ categories: (string | undefined)[];
+ description: string;
+ selected: boolean;
+};
+
+const complexityList: Array<{
+ value: string;
+ label: string;
+ badgeVariant: BadgeProps["variant"];
+}> = [
+ { value: "easy", label: "Easy", badgeVariant: "easy" },
+ { value: "medium", label: "Medium", badgeVariant: "medium" },
+ { value: "hard", label: "Hard", badgeVariant: "hard" },
+ ];
+
+const categoryList: Array<{
+ value: string;
+ label: string;
+ badgeVariant: BadgeProps["variant"];
+}> = [
+ { value: "algorithms", label: "Algorithms", badgeVariant: "category" },
+ { value: "arrays", label: "Arrays", badgeVariant: "category" },
+ {
+ value: "bitmanipulation",
+ label: "Bit Manipulation",
+ badgeVariant: "category",
+ },
+ { value: "brainteaser", label: "Brainteaser", badgeVariant: "category" },
+ { value: "databases", label: "Databases", badgeVariant: "category" },
+ { value: "datastructures", label: "Data Structures", badgeVariant: "category" },
+ { value: "recursion", label: "Recursion", badgeVariant: "category" },
+ { value: "strings", label: "Strings", badgeVariant: "category" },
+ ];
+
+export default function Home() {
+ const router = useRouter();
+ const [selectedComplexities, setSelectedComplexities] = useState
(
+ complexityList.map((diff) => diff.value)
+ );
+ const [selectedCategories, setSelectedCategories] = useState(
+ categoryList.map((category) => category.value)
+ );
+ const [filtersHeight, setFiltersHeight] = useState(0);
+ const [questionList, setQuestionList] = useState([]); // Complete list of questions
+ const [selectedViewQuestion, setSelectedViewQuestion] =
+ useState(null);
+ 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() {
+ try {
+ const response = await fetch(`${process.env.NEXT_PUBLIC_QUESTION_API_BASE_URL}/all`, {
+ cache: "no-store",
+ });
+ const data = await response.json();
+
+ // Map backend data to match the frontend Question type
+ const mappedQuestions: Question[] = data.map((q: {id: number, title: string, complexity: string, category: string[], description: string, link: string,selected: boolean}) => ({
+ id: q.id,
+ title: q.title,
+ complexity: complexityList.find(
+ (complexity) => complexity.value === q.complexity.toLowerCase()
+ )?.value,
+ categories: q.category.sort((a: string, b: string) => a.localeCompare(b)),
+ description: q.description,
+ link: q.link,
+ selected: false, // Set selected to false initially
+ }));
+
+ setQuestionList(mappedQuestions); // Set the fetched data to state
+ } catch (error) {
+ console.error("Error fetching questions:", error);
+ }
+ }
+
+ fetchQuestions();
+ }, []);
+
+ useEffect(() => {
+ const filtersElement = document.getElementById("filters");
+ if (filtersElement) {
+ setFiltersHeight(filtersElement.offsetHeight);
+ }
+ }, []);
+
+ // Handle filtered questions based on user-selected complexities and categories
+ const filteredQuestions = questionList.filter((question) => {
+ const selectedcategoryLabels = selectedCategories.map(
+ (categoryValue) =>
+ categoryList.find((category) => category.value === categoryValue)?.label
+ );
+
+ const matchesComplexity =
+ selectedComplexities.length === 0 ||
+ (question.complexity &&
+ selectedComplexities.includes(question.complexity));
+
+ const matchesCategories =
+ selectedCategories.length === 0 ||
+ selectedcategoryLabels.some((category) => question.categories.includes(category));
+
+ return matchesComplexity && matchesCategories;
+ });
+
+ // Function to reset filters
+ const resetFilters = () => {
+ setSelectedComplexities(complexityList.map((diff) => diff.value));
+ setSelectedCategories(categoryList.map((category) => category.value));
+ setReset(true);
+ };
+
+ // Function to handle "Select All" button click
+ const handleSelectAll = () => {
+ const newIsSelectAll = !isSelectAll;
+ setIsSelectAll(newIsSelectAll);
+
+ // Toggle selection of all questions
+ const updatedQuestions = questionList.map((question) =>
+ filteredQuestions.map((f_qns) => f_qns.id).includes(question.id)
+ ? {
+ ...question,
+ selected: newIsSelectAll, // Select or unselect all questions
+ }
+ : question
+ );
+ setQuestionList(updatedQuestions);
+ };
+
+ // Function to handle individual question selection
+ const handleSelectQuestion = (id: number) => {
+ const updatedQuestions = questionList.map((question) =>
+ question.id === id
+ ? { ...question, selected: !question.selected }
+ : question
+ );
+ setQuestionList(updatedQuestions);
+ };
+
+ useEffect(() => {
+ const allSelected =
+ questionList.length > 0 && questionList.every((q) => q.selected);
+ const noneSelected =
+ questionList.length > 0 && questionList.every((q) => !q.selected);
+
+ if (allSelected) {
+ setIsSelectAll(true);
+ } else if (noneSelected) {
+ setIsSelectAll(false);
+ }
+ }, [questionList]);
+
+ useEffect(() => {
+ if (filteredQuestions.length === 0) {
+ setSelectedViewQuestion(null);
+ }
+ }, [filteredQuestions]);
+
+
+ useEffect(() => {
+ 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 (
+ //
+
+
+
+
+
+
+
+
+
+
+
+
+ {filteredQuestions.length > 0 && (
+
+ {isSelectAll ? "Deselect All" : "Select All"}
+
+ )}
+
+
+
+
+
+ {filteredQuestions.length == 0 ? (
+
+
No questions found
+
Reset filters
+
+ ) : (
+ filteredQuestions.map((question) => (
+
+
setSelectedViewQuestion(question)}
+ >
+
+
+ {question.title}
+
+
+
+ {question.complexity}
+
+ {question.categories.map((category, index) => (
+
+ {category}
+
+ ))}
+
+
+ handleSelectQuestion(question.id)}
+ >
+ {question.selected ? "Selected" : "Select"}
+
+
+
+ ))
+ )}
+
+
+
+
+
+ {!selectedViewQuestion ? (
+
Select a question to view
+ ) : (
+
+
+ {selectedViewQuestion.title}
+
+
+
+
+
+ {selectedViewQuestion.complexity}
+
+
+
+
+ {selectedViewQuestion.categories.map((category) => (
+
+ {category}
+
+ ))}
+
+
+
+ {selectedViewQuestion.description}
+
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx
deleted file mode 100644
index 2334c98a4e..0000000000
--- a/frontend/app/signup/page.tsx
+++ /dev/null
@@ -1,250 +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,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { AlertCircle, LoaderCircle } from "lucide-react"
-import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"
-
-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 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]);
-
- async function onSubmit(values: z.infer) {
- // Placeholder for auth to user service
- try {
- await form.trigger();
- if (!form.formState.isValid) {
- return;
- }
-
- setIsLoading(true);
-
- 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.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",
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(loginValues),
- });
-
- 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);
- }
-
- const responseData = await loginResponse.json();
- console.log(responseData.data["accessToken"]);
- router.push("/question-repo");
- } catch (error) {
- console.error(error);
- } finally {
- setIsLoading(false);
- }
- }
-
- return (
-
-
- PeerPrep
-
-
-
-
- Create an account
-
-
- Enter a username, email and password to sign up
-
-
-
- {error && (
-
-
- Error
-
- {error}
-
-
- )}
-
-
-
-
- Already have an account?{" "}
-
- Sign in
-
-
-
-
- )
-}
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..2fa0244176 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,9 +9,10 @@
"version": "0.1.0",
"dependencies": {
"@hookform/resolvers": "^3.9.0",
+ "@radix-ui/react-alert-dialog": "^1.1.2",
"@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,11 +29,13 @@
"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",
"react-dom": "^18",
"react-hook-form": "^7.53.0",
+ "react-icons": "^5.3.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9",
@@ -537,6 +540,49 @@
"integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==",
"license": "MIT"
},
+ "node_modules/@radix-ui/react-alert-dialog": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.2.tgz",
+ "integrity": "sha512-eGSlLzPhKO+TErxkiGcCZGuvbVMnLA1MTnyBksGOeGRGkxHiiJUujsjmNTdWTm4iHVSRaUao9/4Ur671auMghQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.0",
+ "@radix-ui/react-compose-refs": "1.1.0",
+ "@radix-ui/react-context": "1.1.1",
+ "@radix-ui/react-dialog": "1.1.2",
+ "@radix-ui/react-primitive": "2.0.0",
+ "@radix-ui/react-slot": "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-alert-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-arrow": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz",
@@ -673,25 +719,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 +754,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 +4345,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",
@@ -5590,6 +5776,15 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
+ "node_modules/react-icons": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.3.0.tgz",
+ "integrity": "sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "*"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 3cfc77043d..947f79d4c3 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,11 +29,13 @@
"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",
"react-dom": "^18",
"react-hook-form": "^7.53.0",
+ "react-icons": "^5.3.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9",
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts
index 444e183cee..f2641b6541 100644
--- a/frontend/tailwind.config.ts
+++ b/frontend/tailwind.config.ts
@@ -100,7 +100,16 @@ const config: Config = {
dropShadow: {
'question-card': '0px 2px 4px rgba(0, 0, 0, 0.15)',
'question-details': '0px 8px 8px rgba(0, 0, 0, 0.15)'
- }
+ },
+ 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")],