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:

+ + `; + } 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:

+ + `; + } + + 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 ? (
-
) : ( - )}
+ {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 */} +
+ ) + ))} +
+
+ )}
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
+ //
+ //
+ //
+ // + // PeerPrep + // + // {process.env.NODE_ENV == "development" && ( + // + // DEV + // + // )} + //
+ //
+ // + //
+ //
+ + //
+ //
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() {
- +
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. - - - )}
@@ -135,7 +122,7 @@ export default function ForgotPassword() { Email - + @@ -144,14 +131,12 @@ export default function ForgotPassword() {
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 ( +
+
+
+
+

Email Verification

+
+
+

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

+
+
+ {error && ( + + + Error + + {error} + + + )} +
+ + ( + + + ( + <> +
+ {slots.map((slot, idx) => ( + + ))} +
+ + )} + {...field}/> +
+ +
+ )} + /> + + + + {/* */} +
+
+ ); +} 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} + + + )} +
+
+ + ( + + +
+ Password + + + + + Password must have at least: +
    +
  • 8 characters
  • +
  • 1 uppercase character
  • +
  • 1 lowercase character
  • +
  • 1 number
  • +
  • 1 special character
  • +
+
+
+
+
+
+ + + + +
+ )} + /> + ( + + Confirm password + + + + + + )} + /> + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/app/auth/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.

+ +
+ ); +}; + +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} + + + )} +
+ + ( + + + {/*
+ Username + + + + +

Minimum 4 characters

+
+
+
+
*/} + Username +
+ + + + + Minimum 4 characters +
+ )} + /> + ( + + Email + + + + + + )} + /> + ( + + + {/*
+ Password + + + + + Password must have at least: +
    +
  • 8 characters
  • +
  • 1 uppercase character
  • +
  • 1 lowercase character
  • +
  • 1 number
  • +
  • 1 special character
  • +
+
+
+
+
*/} + Password +
+ + + + + + Must be at least 8 characters, include uppercase, lowercase, a number, and a special character. + +
+ )} + /> + ( + + Confirm password + + + + + + )} + /> + + + +
+
+ Already have an account?{" "} + + Sign in + +
+
+ +
+) +} diff --git a/frontend/app/auth/sign-up/success-dialog.tsx b/frontend/app/auth/sign-up/success-dialog.tsx new file mode 100644 index 0000000000..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 + + {/* */} +

+ } + {showFeedback && ( + + + Email has been resent! Please check your inbox. + + )} +
+ + + +
+
+ ); +} 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}

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

{message}

+ + + +
+ + )} +
+ ); +}; 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

- 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 ( + //
+
+
+
+ + PeerPrep + + {process.env.NODE_ENV == "development" && ( + + DEV + + )} +
+
+ +
+
+ +
+
+
+
+ +
+ + +
+ {filteredQuestions.length > 0 && ( + + )} +
+ + +
+
+ {filteredQuestions.length == 0 ? ( +
+

No questions found

+ +
+ ) : ( + filteredQuestions.map((question) => ( +
+ setSelectedViewQuestion(question)} + > +
+

+ {question.title} +

+
+ + {question.complexity} + + {question.categories.map((category, index) => ( + + {category} + + ))} +
+
+ +
+
+ )) + )} +
+
+
+
+
+ {!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} - - - )} -
- - ( - - Username - - - - - Minimum 4 characters - - )} - /> - ( - - Email - - - - - - )} - /> - ( - - Password - - - - - - Must have at least: -
    -
  • 8 characters
  • -
  • 1 uppercase character
  • -
  • 1 lowercase character
  • -
  • 1 number
  • -
  • 1 special character
  • -
-
-
- )} - /> - ( - - Confirm password - - - - - - )} - /> - - - -
-
- Already have an account?{" "} - - Sign in - -
-
-
- ) -} diff --git a/frontend/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")],