🔥 Reset password now working

This commit is contained in:
2024-07-29 01:36:44 +08:00
parent 3afe173da7
commit 96efff85da
9 changed files with 506 additions and 186 deletions

View File

@@ -20,42 +20,46 @@ import EditEventsPage from "./pages/EditEventsPage";
import DefaultLayout from "./layouts/default"; import DefaultLayout from "./layouts/default";
import AdministratorLayout from "./layouts/administrator"; import AdministratorLayout from "./layouts/administrator";
import UsersManagement from "./pages/UsersManagement"; import UsersManagement from "./pages/UsersManagement";
import ResetPasswordPage from "./pages/ResetPasswordPage";
function App() { function App() {
return ( return (
<Routes> <Routes>
{/* User Routes */} {/* User Routes */}
<Route path="/" element={<DefaultLayout />}> <Route path="/">
{/* General Routes */} <Route element={<DefaultLayout />}>
<Route index element={<HomePage />} /> {/* General Routes */}
<Route element={<SignUpPage />} path="signup" /> <Route index element={<HomePage />} />
<Route element={<SignInPage />} path="signin" /> <Route element={<SignUpPage />} path="signup" />
<Route element={<SpringboardPage />} path="springboard" /> <Route element={<SignInPage />} path="signin" />
<Route element={<ManageUserAccountPage />} path="manage-account" /> <Route element={<SpringboardPage />} path="springboard" />
<Route element={<ManageUserAccountPage />} path="manage-account" />
{/* Events Route */} {/* Events Route */}
<Route path="events"> <Route path="events">
<Route index element={<EventsPage />} /> <Route index element={<EventsPage />} />
</Route> </Route>
{/* Karang Guni Schedules Route */} {/* Karang Guni Schedules Route */}
<Route path="karang-guni-schedules"> <Route path="karang-guni-schedules">
<Route index element={<SchedulePage />} /> <Route index element={<SchedulePage />} />
</Route> </Route>
{/* Home Bill Contest Route */} {/* Home Bill Contest Route */}
<Route path="home-bill-contest"> <Route path="home-bill-contest">
<Route index element={<HBContestPage />} /> <Route index element={<HBContestPage />} />
<Route element={<HBFormPage />} path="new-submission" /> <Route element={<HBFormPage />} path="new-submission" />
</Route> </Route>
{/* Community Posts Route */} {/* Community Posts Route */}
<Route path="community-posts"> <Route path="community-posts">
<Route index element={<CommunityPage />} /> <Route index element={<CommunityPage />} />
<Route element={<CreatePostPage />} path="create" /> <Route element={<CreatePostPage />} path="create" />
<Route element={<PostPage />} path="post/:id" /> <Route element={<PostPage />} path="post/:id" />
<Route element={<EditPostPage />} path="edit/:id" /> <Route element={<EditPostPage />} path="edit/:id" />
</Route>
</Route> </Route>
<Route element={<ResetPasswordPage />} path="reset-password/:token" />
</Route> </Route>
{/* Admin Routes */} {/* Admin Routes */}

View File

@@ -36,9 +36,6 @@ export default function NavigationBar() {
); );
setUserInformation(value); setUserInformation(value);
}) })
.catch(() => {
navigate("/signin");
})
.finally(() => { .finally(() => {
setDoneLoading(true); setDoneLoading(true);
}); });

View File

@@ -45,7 +45,7 @@ export default function SignInModule() {
if (value.accountType == 2) { if (value.accountType == 2) {
navigate("/admin"); navigate("/admin");
} else { } else {
navigate("/springboard"); window.location.reload();
} }
}); });
}) })

View File

@@ -18,7 +18,7 @@ import { Form, Formik } from "formik";
import NextUIFormikInput from "./NextUIFormikInput"; import NextUIFormikInput from "./NextUIFormikInput";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import UserProfilePicture from "./UserProfilePicture"; import UserProfilePicture from "./UserProfilePicture";
import { popErrorToast } from "../utilities"; import { popErrorToast, popToast } from "../utilities";
import instance from "../security/http"; import instance from "../security/http";
export default function UpdateAccountModule() { export default function UpdateAccountModule() {
@@ -105,6 +105,25 @@ export default function UpdateAccountModule() {
}); });
}; };
const sendResetPasswordEmail = () => {
instance
.put(
`${
config.serverAddress
}/users/request-reset-password/${encodeURIComponent(
userInformation.email
)}`
)
.then(() => {
console.log("Email sent successfully");
popToast("Email sent to your mailbox!", 1);
})
.catch((error) => {
console.error("Failed to send email:", error);
popErrorToast("Failed to send email: " + error);
});
};
return ( return (
<div> <div>
{userInformation && ( {userInformation && (
@@ -221,16 +240,7 @@ export default function UpdateAccountModule() {
color="danger" color="danger"
variant="light" variant="light"
onPress={() => { onPress={() => {
instance sendResetPasswordEmail();
.put(
`${config.serverAddress}/connections/send-reset-password-email/${userInformation.id}`
)
.then(() => {
console.log("Email sent successfully");
})
.catch((error) => {
console.error("Failed to send email:", error);
});
}} }}
> >
Reset your password Reset your password

View File

@@ -0,0 +1,142 @@
import { useParams, useNavigate } from "react-router-dom";
import instance from "../security/http";
import config from "../config";
import { useEffect, useState } from "react";
import { Button, Card, CircularProgress } from "@nextui-org/react";
import EcoconnectFullLogo from "../components/EcoconnectFullLogo";
import NextUIFormikInput from "../components/NextUIFormikInput";
import { Formik, Form } from "formik";
import * as Yup from "yup";
import axios from "axios";
import { popErrorToast, popToast } from "../utilities";
const validationSchema = Yup.object({
password: Yup.string()
.trim()
.max(69, "Password must be at most 69 characters")
.matches(
/^(?=.*[a-zA-Z])(?=.*[0-9]).{1,}$/,
"Password must contain both letters and numbers"
)
.required("Password is required"),
confirmPassword: Yup.string()
.oneOf([Yup.ref("password"), undefined], "Passwords must match")
.required("Confirm Password is required"),
});
export default function ResetPasswordPage() {
const { token } = useParams();
const navigate = useNavigate();
const [validationResult, setValidationResult] = useState<boolean>(false);
const [pageLoading, setPageLoading] = useState<boolean>(true);
const validateToken = () => {
instance
.get(`${config.serverAddress}/users/reset-password/${token}`)
.then(() => {
setValidationResult(true);
})
.catch(() => {
setValidationResult(false);
})
.finally(() => {
setPageLoading(false);
});
};
useEffect(() => {
validateToken();
}, []);
const handleSubmit = (values: any): void => {
console.log("submitting");
instance
.post(`${config.serverAddress}/users/reset-password`, {
token,
password: values.password,
})
.then(() => {
popToast("Success!", 1);
navigate("/signin");
})
.catch((error) => {
popErrorToast(error);
});
};
return (
<div className="absolute inset-0 p-8 w-full h-full flex flex-col justify-center">
<div className="flex flex-row justify-center">
{pageLoading && (
<div>
<CircularProgress label="Loading..." />
</div>
)}
{!pageLoading && (
<div className="flex flex-col gap-8 *:mx-auto">
<Card className="max-w-[600px] w-full mx-auto">
{validationResult && (
<div className="flex flex-col gap-8 p-12 text-left">
<div className="flex flex-col gap-2">
<p className="text-3xl font-bold">Password Reset</p>
<p>Enter a new password below.</p>
</div>
<Formik
initialValues={{ password: "", confirmPassword: "" }}
validationSchema={validationSchema}
onSubmit={handleSubmit}
>
{({ isValid, dirty }) => (
<Form className="flex flex-col gap-8">
<NextUIFormikInput
label="New Password"
name="password"
type="password"
placeholder="Enter new password"
labelPlacement="outside"
/>
<NextUIFormikInput
label="Confirm Password"
name="confirmPassword"
type="password"
placeholder="Confirm new password"
labelPlacement="outside"
/>
<Button
type="submit"
color="primary"
size="lg"
isDisabled={!isValid || !dirty}
>
Confirm
</Button>
</Form>
)}
</Formik>
</div>
)}
{!validationResult && (
<div className="flex flex-col gap-8 p-12 *:mr-auto text-left">
<div className="flex flex-col gap-2">
<p className="text-3xl font-bold">
Reset portal has been closed.
</p>
<p>Please request for a password reset again.</p>
</div>
</div>
)}
</Card>
<div className="flex flex-row gap-4">
<EcoconnectFullLogo />
<p>·</p>
<p className="opacity-50">
© Copyright {new Date().getFullYear()}. All rights reserved.
</p>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -49,170 +49,200 @@ async function sendEmail(recipientEmail, title, content) {
} }
} }
async function sendPasswordResetEmail(email, firstName) { const ecoconnectEmailLogoUrl =
"https://onedrive.live.com/download?resid=FDC8D8692E9A43C0%21425747&authkey=%21ADT2uhKbMIG4iqw&width=1310&height=212";
async function sendPasswordResetEmail(email, firstName, resetToken) {
let dateTimeNow = new Date().toLocaleString();
let emailContent = ` let emailContent = `
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ecoconnect Reset Password</title> <title>ecoconnect Reset Password</title>
<style> <style>
:root{ :root{
font-family: "Arial" font-family: "Arial"
} }
a { a {
all: unset; all: unset;
} }
.m-8 { .m-8 {
margin: 2rem; margin: 2rem;
} }
.mx-auto { .mx-auto {
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
.my-auto { .my-auto {
margin-top: auto; margin-top: auto;
margin-bottom: auto; margin-bottom: auto;
} }
.mr-auto { .mr-auto {
margin-right: auto; margin-right: auto;
} }
.flex { .flex {
display: flex; display: flex;
} }
.h-20 { .h-20 {
height: 5rem; height: 5rem;
} }
.w-40 { .w-40 {
width: 10rem; width: 10rem;
} }
.w-44 { .w-44 {
width: 11rem; width: 11rem;
} }
.w-full { .w-full {
width: 100%; width: 100%;
} }
.w-max { .w-max {
width: max-content; width: max-content;
} }
.flex-row { .flex-row {
flex-direction: row; flex-direction: row;
} }
.flex-col { .flex-col {
flex-direction: column; flex-direction: column;
} }
.justify-center { .justify-center {
justify-content: center; justify-content: center;
} }
.gap-2 { .gap-2 {
gap: 0.5rem; gap: 0.5rem;
} }
.gap-4 { .gap-4 {
gap: 1rem; gap: 1rem;
} }
.rounded-xl { .rounded-xl {
border-radius: 0.75rem; border-radius: 0.75rem;
} }
.rounded-b-xl { .rounded-b-xl {
border-bottom-right-radius: 0.75rem; border-bottom-right-radius: 0.75rem;
border-bottom-left-radius: 0.75rem; border-bottom-left-radius: 0.75rem;
} }
.border-2 { .border-2 {
border-width: 2px; border-width: 2px;
} }
.border-red-200 { .border-red-200 {
border-color: rgb(254 202 202); border-color: rgb(254 202 202);
} }
.border-red-300 { .border-red-300 {
border-color: rgb(252 165 165); border-color: rgb(252 165 165);
} }
.bg-red-500 { .bg-red-500 {
background-color: rgb(239 68 68); background-color: rgb(239 68 68);
} }
.bg-white { .bg-white {
background-color: rgb(255 255 255); background-color: rgb(255 255 255);
} }
.p-8 { .p-8 {
padding: 2rem; padding: 2rem;
} }
.px-3 { .px-3 {
padding-left: 0.75rem; padding-left: 0.75rem;
padding-right: 0.75rem; padding-right: 0.75rem;
} }
.px-4 { .px-4 {
padding-left: 1rem; padding-left: 1rem;
padding-right: 1rem; padding-right: 1rem;
} }
.py-2 { .py-2 {
padding-top: 0.5rem; padding-top: 0.5rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
} }
.py-3 { .py-3 {
padding-top: 0.75rem; padding-top: 0.75rem;
padding-bottom: 0.75rem; padding-bottom: 0.75rem;
} }
.text-3xl { .text-3xl {
font-size: 1.875rem; font-size: 1.875rem;
line-height: 2.25rem; line-height: 2.25rem;
} }
.text-sm { .text-sm {
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.25rem; line-height: 1.25rem;
} }
.font-bold { .font-bold {
font-weight: 700; font-weight: 700;
} }
.text-red-900 { .text-red-900 {
color: rgb(127 29 29); color: rgb(127 29 29);
} }
.text-white { .text-white {
color: rgb(255 255 255); color: rgb(255 255 255);
} }
.opacity-50 { .opacity-50 {
opacity: 0.5; opacity: 0.5;
} }
.shadow-lg { .shadow-lg {
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
} }
.shadow-md { .shadow-md {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
} }
.\*\:my-auto > * { .\*\:my-auto > * {
margin-top: auto; margin-top: auto;
margin-bottom: auto; margin-bottom: auto;
} }
.\*\:mr-auto > * { .\*\:mr-auto > * {
margin-right: auto; margin-right: auto;
} }
.no-underline { .no-underline {
text-decoration-line: none; text-decoration-line: none;
} }
.h-3 { .h-3 {
height: 1.5rem; height: 1.5rem;
} }
</style> </style>
</head> </head>
<body style="background-color: white;"> <body style="background-color: white;">
<div class="m-8 flex flex-col rounded-xl border-2 border-red-200 shadow-lg"> <div class="m-8 flex flex-col rounded-xl border-2 border-red-200 shadow-lg">
<div class="flex flex-col gap-4 p-8 *:mr-auto"> <div class="flex flex-col gap-4 p-8 *:mr-auto">
<img src="https://onedrive.live.com/download?resid=FDC8D8692E9A43C0%21425747&authkey=%21ADT2uhKbMIG4iqw&width=1310&height=212" alt="ecoconnect logo" class="w-44" /> <img src="${ecoconnectEmailLogoUrl}" alt="ecoconnect logo" class="w-44" />
<h1 class="text-3xl font-bold text-red-900">Greetings, ${firstName}!</h1> <h1 class="text-3xl font-bold text-red-900">
<p>We have received your request to reset the password.<br />Click the button below to do so.</p> Greetings, ${firstName}!
<a href="https://example.com/reset-password" class="rounded-xl border-2 border-red-300 bg-red-500 px-4 py-3 text-white shadow-md w-max no-underline">RESET PASSWORD</a> </h1>
<p class="text-sm opacity-50">If you have not made a request to reset the password, feel free to ignore this email.</p> <p>
<p>Best regards,<br/><span class="font-bold text-red-900">ecoconnect administrators</span></p> We have received your request to reset the password.<br />Click the
button below to do so.
</p>
<div class="flex flex-col">
<a
href="http://localhost:5173/reset-password/${resetToken}"
class="rounded-xl border-2 border-red-300 bg-red-500 px-4 py-3 text-white font-bold shadow-md w-max no-underline"
>RESET PASSWORD</a
>
<p class="text-sm">This reset portal is opened on ${dateTimeNow}, for 60 minutes.</p>
<p class="text-sm opacity-50">
If you have not made a request to reset the password, feel free to
ignore this email.
</p>
</div>
<p>
Best regards,<br /><span class="font-bold text-red-900"
>ecoconnect administrators</span
>
</p>
</div> </div>
<div class="flex flex-col justify-center h-20 w-full rounded-b-xl bg-red-500 text-white"> <div
class="flex flex-col justify-center h-20 w-full rounded-b-xl bg-red-500 text-white"
>
<div class="mx-auto flex w-max flex-row gap-2 *:my-auto"> <div class="mx-auto flex w-max flex-row gap-2 *:my-auto">
<img src="https://onedrive.live.com/download?resid=FDC8D8692E9A43C0%21425747&authkey=%21ADT2uhKbMIG4iqw&width=1310&height=212" alt="ecoconnect logo" class=" h-3 py-2 px-3 bg-white rounded-xl" /> <img
src="${ecoconnectEmailLogoUrl}"
alt="ecoconnect logo"
class="h-3 py-2 px-3 bg-white rounded-xl"
/>
<p>· Connecting neighbourhoods together</p> <p>· Connecting neighbourhoods together</p>
</div> </div>
</div> </div>
</div> </div>
</body> </body>
</html> </html>
`; `;
await sendEmail( await sendEmail(
email, email,

View File

@@ -43,13 +43,21 @@ module.exports = (sequelize) => {
accountType: { accountType: {
type: DataTypes.TINYINT(2), type: DataTypes.TINYINT(2),
allowNull: false, allowNull: false,
defaultValue: 0 defaultValue: 0,
}, },
isArchived: { isArchived: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
allowNull: false, allowNull: false,
defaultValue: false, defaultValue: false,
}, },
resetPasswordToken: {
type: DataTypes.STRING(64),
allowNull: true,
},
resetPasswordExpireTime: {
type: DataTypes.DATE,
allowNull: true,
},
}, },
{ {
tableName: "users", tableName: "users",

View File

@@ -1,6 +1,6 @@
const express = require("express"); const express = require("express");
const yup = require("yup"); const yup = require("yup");
const { Op } = require("sequelize"); const { Op, Sequelize } = require("sequelize");
const { User } = require("../models"); const { User } = require("../models");
const { validateToken } = require("../middlewares/auth"); const { validateToken } = require("../middlewares/auth");
const argon2 = require("argon2"); const argon2 = require("argon2");
@@ -8,6 +8,10 @@ const router = express.Router();
const { sign } = require("jsonwebtoken"); const { sign } = require("jsonwebtoken");
const multer = require("multer"); const multer = require("multer");
const sharp = require("sharp"); const sharp = require("sharp");
const { sendPasswordResetEmail } = require("../connections/mailersend");
const {
generatePasswordResetToken,
} = require("../security/generatePasswordResetToken");
require("dotenv").config(); require("dotenv").config();
@@ -333,4 +337,113 @@ router.put(
} }
); );
router.put("/request-reset-password/:email", async (req, res) => {
let email = req.params.email;
try {
console.log(email);
let user = await User.findOne({
where: { email: email },
});
if (!user) {
res.sendStatus(404);
return;
}
if (user.isArchived) {
res.status(400).json({
message: `ERR_ACC_IS_ARCHIVED`,
});
} else {
const token = await generatePasswordResetToken();
let num = await User.update(
{
resetPasswordToken: token,
resetPasswordExpireTime: Date.now() + 3600000,
},
{
where: { id: user.id },
}
);
if (num == 1) {
res.json({
message: "User was updated successfully.",
});
} else {
res.status(400).json({
message: `Something went wrong setting token for user with id ${id}.`,
});
}
await sendPasswordResetEmail(user.email, user.firstName, token);
res.status(200).json({ message: "Email sent successfully" });
return;
}
} catch (error) {
// Silence.
}
});
router.post("/reset-password", async (req, res) => {
let token = req.body.token;
let newPassword = req.body.password;
try {
resetPassword(token, newPassword).then(() => {
res.sendStatus(200);
});
} catch (err) {
res.status(400).json({ errors: err.errors });
}
});
router.get("/reset-password/:token", async (req, res) => {
let token = req.params.token;
try {
let user = await validateResetPasswordToken(token);
if (!user) {
console.log("no");
res.sendStatus(404);
return;
} else {
console.log("yes");
res.sendStatus(200);
}
} catch {
res.status(401);
}
});
async function validateResetPasswordToken(token) {
const user = await User.findOne({
where: {
resetPasswordToken: token,
resetPasswordExpireTime: { [Sequelize.Op.gt]: Date.now() },
},
});
if (!user) {
return undefined;
} else {
return user;
}
}
async function resetPassword(token, newPassword) {
const user = await validateResetPasswordToken(token);
if (!user) {
return undefined;
}
const hashedPassword = await argon2.hash(newPassword);
user.password = hashedPassword;
user.resetPasswordToken = null;
user.resetPasswordExpires = null;
await user.save();
}
module.exports = router; module.exports = router;

View File

@@ -0,0 +1,16 @@
const crypto = require("crypto");
function generatePasswordResetToken() {
return new Promise((resolve, reject) => {
crypto.randomBytes(32, (err, buffer) => {
if (err) {
reject(err);
} else {
const token = buffer.toString("hex");
resolve(token);
}
});
});
}
module.exports = { generatePasswordResetToken };