diff --git a/backend/database/user.js b/backend/database/user.js index 8ca2660ee..99f702b51 100644 --- a/backend/database/user.js +++ b/backend/database/user.js @@ -1,10 +1,12 @@ // User (both Mentee and Admin) API Endpoints require("dotenv").config(); +const nodemailer = require("nodemailer"); const db = require("./db"); const { verifyToken } = require("./auth"); const { checkUserExists } = require("./helper"); +const bcrypt = require("bcrypt"); // Get user info const userInfo = async (req, res) => { @@ -21,8 +23,8 @@ const userInfo = async (req, res) => { try { const query = ` - SELECT email, zid, firstname, lastname, role, year, mentor - FROM users + SELECT email, zid, firstname, lastname, role, year, mentor + FROM users WHERE zid = $1 `; const values = [zid]; @@ -45,6 +47,103 @@ const userInfo = async (req, res) => { } }; +// NOTE: re-using admin's /invite implementation and database +const forgotPassword = async (req, res) => { + const email = req.body.email; + + // Check if email is valid + const data = await db.query(`SELECT * FROM users WHERE email = $1;`, [email]); + const arr = data.rows; + if (arr.length === 0) { + return res.status(400).json({ message: `Email doesn't exist. Please register first.` }); + } + + const zid = await db.query(`SELECT zid FROM users WHERE email = $1;`, [email]); + + // NOTE: re-using database because of tight deadlines + // token stores max size 32. Chose to use zid over email to fit length. + const token = btoa(Math.floor(Math.random() * 99999999999999 + 1) + zid.rows[0].zid); + + const date = new Date().toLocaleString(); + const query = ` + INSERT INTO invitation_tokens (token, used, created_at) + VALUES ($1, $2, $3) + `; + + const params = [token, false, date]; + await db.query(query, params); + sendForgotPasswordEmail(email, token); + res.status(200).send("Reset password email sent successfully"); +}; + +// Function to send forgot password email +const sendForgotPasswordEmail = (email, token) => { + const transporter = nodemailer.createTransport({ + service: "gmail", + auth: { + user: process.env.EMAIL, + pass: process.env.EMAIL_PASSWORD, + }, + }); + + // TODO: Change link when deployed + const link = `https://empowerment.unswwit.com/user/reset-password?token=${token}`; + + const mailOptions = { + from: process.env.EMAIL, + to: email, + subject: "UNSW WIT Empowerment Program Reset Password", + text: `Hi there!,\n\nWe received a request to reset the password for ${email}.\n\nClick this link to enter a new password: ${link}.\n\nIf you did NOT expect to receive this message, please notify the admins immediately!.`, + }; + + transporter.sendMail(mailOptions, (error, info) => { + if (error) { + console.error("Error sending email:", error); + } else { + console.log("Email sent:", info.response); + } + }); +}; + +const resetPassword = async (req, res) => { + const { email, password } = req.body; + const token = req.query.token; + + if (token === "undefined") { + return res.status(400).send({ message: "Invalid/expired token" }); + } + + // Check valid reset token (using invitation_tokens database) + const tokenResult = await db.query(`SELECT * FROM invitation_tokens WHERE token = $1 AND used = $2`, [token, false]); + const check = await db.query(`SELECT * FROM invitation_tokens`); + + if (tokenResult.rows.length === 0) { + return res.status(400).send({ message: "Invalid/expired token" }); + } + + const decoded = atob(token); + + const zid = await db.query(`SELECT zid FROM users WHERE email = $1;`, [email]); + + if (!decoded.endsWith(zid.rows[0].zid)) { + return res.status(400).send({ message: "Invalid token/email" }); + } + + // Update password + const hashedPassword = await bcrypt.hash(password, 10); + const params = [email, hashedPassword]; + const q = "UPDATE users SET password = $2 WHERE email = $1"; + await db.query(q, params); + + // Mark the token as used + await db.query("UPDATE invitation_tokens SET used = $1 WHERE token = $2", [true, token]); + + return res.status(200).json({ message: "Password reset successfully! Please proceed to login." }); +}; + + module.exports = { userInfo, + forgotPassword, + resetPassword, }; diff --git a/backend/index.js b/backend/index.js index 7e06a6470..174431ae8 100644 --- a/backend/index.js +++ b/backend/index.js @@ -19,7 +19,10 @@ app.use(cors()); // -------- User --------// app.post("/user/register", auth.registerUser); app.post("/user/login", auth.loginUser); +app.post("/user/forgot-password", user.forgotPassword); +app.post("/user/reset-password", user.resetPassword); app.get("/user/profile", user.userInfo); +app.post("/user/forgot-password", user.forgotPassword); // -------- Mentee --------// app.post("/mentee/request-hours", mentee.requestHours); diff --git a/frontend/data/env.local b/frontend/data/env.local deleted file mode 100644 index b29df49f5..000000000 --- a/frontend/data/env.local +++ /dev/null @@ -1,13 +0,0 @@ -NEXT_PUBLIC_CONTENTFUL_API_TOKEN=lz8EsAj_HoSKXjY-V10nhwncIgiupgOWKucMJwr5zeY -NEXT_PUBLIC_CONTENTFUL_API_SPACE=g8syemd5uoqq -NEXT_PUBLIC_CONTENTFUL_HOST=https://cdn.contentful.com -NEXT_PUBLIC_CONTENTFUL_ENVRIONMENT=master -NEXT_PUBLIC_CONTENTFUL_MANAGEMENT_TOKEN=CFPAT-WcNYUT4vc-_JCfpk-7gwLhpDbkcsx71VdEqJt71GO1k - -NEXT_PUBLIC_EMAILJS_ID=user_mot8s1x3g15iocWpLIIWP -NEXT_PUBLIC_TEMPLATE=template_bq55xor -NEXT_PUBLIC_SERVICE_ID=service_pp1j6dh - -NEXT_PUBLIC_RECAPTCHA_SITE_KEY=6Leg-sYfAAAAAEaDI8bB-eu7RKFNrFxmdyZ_hKjL -NEXT_PUBLIC_MEASUREMENT_ID=G-6QEYGEF8Q5 -NEXT_PUBLIC_MAILCHIMP_URL=https://unswwit.us18.list-manage.com/subscribe/post?u=d8fbbbb991e2a0f3152600bbf&id=cd8ceaef47 \ No newline at end of file diff --git a/frontend/pages/api/user.ts b/frontend/pages/api/user.ts index 3aaeeaaa8..4992ebef7 100644 --- a/frontend/pages/api/user.ts +++ b/frontend/pages/api/user.ts @@ -42,6 +42,25 @@ export async function doLogin( } } +export async function doForgotPassword( + req: any, + setMessage: Dispatch>, + setError: Dispatch> +) { + const res = await fetch(`${apiUrl}/user/forgot-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); + const data = await res.json(); + + if (res.ok) { + setMessage(data.message); + } else { + setError(data.message); + } +} + export async function getUserProfile() { const res = await fetch(`${apiUrl}/user/profile`, { method: 'GET', diff --git a/frontend/pages/user/forgot-password.tsx b/frontend/pages/user/forgot-password.tsx new file mode 100644 index 000000000..d1108450f --- /dev/null +++ b/frontend/pages/user/forgot-password.tsx @@ -0,0 +1,86 @@ +import React, { FormEvent, useEffect, useState } from 'react'; +import { Montserrat } from 'next/font/google'; + +import styles from '../../styles/User.module.css'; +import { doForgotPassword } from '../api/user'; +import LoadingOverlay from '../../components/LoadingOverlay'; + +const montserrat = Montserrat({ subsets: ['latin'] }); + +export default function Forgot_password() { + + const [isLoading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + + const handleForgotPassword = (e: FormEvent) => { + setLoading(true); + e.preventDefault(); + + const reqData = { + email: e.currentTarget.userEmail.value, + } + + doForgotPassword(reqData, setMessage, setError).finally(() => setLoading(false)); + }; + + const initForgotPassword = async () => { + setLoading(false); + }; + + useEffect(() => { + setLoading(true); + initForgotPassword(); + }, []); + + return ( +
+
+
+ +
+
+

Forgot password

+
+ {/* guide: https://mattermost.com/blog/add-google-and-github-login-to-next-js-app-with-nextauth/ */} +
+ {/*
+
+ OR +
+
*/} +
+
+ + +
+
+ {error &&

{error}

} + {message &&

{message}

} + +
+
+
+
+ woman engineers +
+
+
+
+ + +
+
+ ); +} diff --git a/frontend/pages/user/login.tsx b/frontend/pages/user/login.tsx index cc785b7e8..a1ae695aa 100644 --- a/frontend/pages/user/login.tsx +++ b/frontend/pages/user/login.tsx @@ -34,6 +34,10 @@ export default function Login() { setLoading(false); }; + const handleResetPassword = () => { + router.push('/user/forgot-password'); + }; + useEffect(() => { setLoading(true); initLogin(); @@ -89,6 +93,7 @@ export default function Login() {
{error &&

{error}

} +

Forgot your password?

diff --git a/frontend/styles/User.module.css b/frontend/styles/User.module.css index c1bb7cb23..0f8f2870a 100644 --- a/frontend/styles/User.module.css +++ b/frontend/styles/User.module.css @@ -259,6 +259,15 @@ color: red; } +/* --- forgot password style --- */ +.forgot_password_container { + text-align: right; +} +.forgot_password_container p{ + cursor: pointer; + font-size: small; +} + /* -- Admin Tools --- */ /* Form Style */ .form {