-
Notifications
You must be signed in to change notification settings - Fork 2
WIT-218: forgot password feature #99
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
456f3ae
1d78747
caee011
b90bd5f
0338051
94090b6
22cd0f5
d67a2d6
6b94b72
e891a2b
db366d3
794685b
bfa2c6f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same for here for tokens as well |
||
|
|
||
| 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, | ||
| }; | ||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,6 +42,25 @@ export async function doLogin( | |
| } | ||
| } | ||
|
|
||
| export async function doForgotPassword( | ||
| req: any, | ||
| setMessage: Dispatch<React.SetStateAction<string | null>>, | ||
| setError: Dispatch<React.SetStateAction<string | null>> | ||
| ) { | ||
| 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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ideally shouldn't update the frontend states directly inside of methods that handles request to backend |
||
| } else { | ||
| setError(data.message); | ||
| } | ||
| } | ||
|
|
||
| export async function getUserProfile() { | ||
| const res = await fetch(`${apiUrl}/user/profile`, { | ||
| method: 'GET', | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string | null>(null); | ||
| const [message, setMessage] = useState<string | null>(null); | ||
|
|
||
| const handleForgotPassword = (e: FormEvent<HTMLFormElement>) => { | ||
| setLoading(true); | ||
| e.preventDefault(); | ||
|
|
||
| const reqData = { | ||
| email: e.currentTarget.userEmail.value, | ||
| } | ||
|
|
||
| doForgotPassword(reqData, setMessage, setError).finally(() => setLoading(false)); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of passing in the setters from Example However, better way to further handle this is probably to use |
||
| }; | ||
|
|
||
| const initForgotPassword = async () => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| setLoading(false); | ||
| }; | ||
|
|
||
| useEffect(() => { | ||
| setLoading(true); | ||
| initForgotPassword(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This |
||
| }, []); | ||
|
|
||
| return ( | ||
| <div className={styles.user}> | ||
| <main className={montserrat.className}> | ||
| <div className={styles.panel}> | ||
| <LoadingOverlay isLoading={isLoading} /> | ||
| <div className={styles.left}> | ||
| <div className={styles.content}> | ||
| <h1>Forgot password</h1> | ||
| <div className={styles.auth}> | ||
| {/* guide: https://mattermost.com/blog/add-google-and-github-login-to-next-js-app-with-nextauth/ */} | ||
| </div> | ||
| {/* <div className={styles.dividerLabel}> | ||
| <hr /> | ||
| OR | ||
| <hr /> | ||
| </div> */} | ||
| <form method="POST" action="/user/forgot-password" onSubmit={handleForgotPassword}> | ||
| <div> | ||
| <label>Email</label> | ||
| <input | ||
| required | ||
| className={montserrat.className} | ||
| type="text" | ||
| id="userEmail" | ||
| name="userEmail" | ||
| placeholder="Enter your email" | ||
| pattern="[a-zA-Z0-9_\.]+@[a-zA-Z0-9]+(\.[a-zA-Z]+)+$" | ||
| title="name@example.com" | ||
| /> | ||
| </div> | ||
| <hr /> | ||
| {error && <p className={styles.error}>{error}</p>} | ||
| {message && <p>{message}</p>} | ||
| <button className={montserrat.className} type="submit"> | ||
| Submit | ||
| </button> | ||
| </form> | ||
| </div> | ||
| </div> | ||
| <div className={styles.right}> | ||
| <img src="/login/image.png" alt="woman engineers" /> | ||
| </div> | ||
| </div> | ||
| </main> | ||
| <div className={styles.bg}> | ||
| <img className={styles.decor1} src="/login/bottom-left.svg" /> | ||
| <img className={styles.decor2} src="/login/top-right.svg" /> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In generating the token, I believe we should stick maybe to the same unique id (uuidv4)?