From 056401bb22ce1a898346d765d97448cad448dd9b Mon Sep 17 00:00:00 2001 From: minhtrannhat Date: Mon, 4 Sep 2023 16:38:53 -0400 Subject: [PATCH] Feat(frontend): email, login, password for user --- frontend/src/Router.tsx | 18 +++++ frontend/src/pages/ChangePassword.tsx | 90 +++++++++++++++++++++ frontend/src/pages/ConfirmEmail.tsx | 44 +++++++++++ frontend/src/pages/ForgottenPassword.tsx | 82 ++++++++++++++++++++ frontend/src/pages/Login.tsx | 99 ++++++++++++++++++++++++ frontend/src/pages/Register.tsx | 12 +-- frontend/src/pages/ResetPassword.tsx | 90 +++++++++++++++++++++ 7 files changed, 429 insertions(+), 6 deletions(-) create mode 100644 frontend/src/pages/ChangePassword.tsx create mode 100644 frontend/src/pages/ConfirmEmail.tsx create mode 100644 frontend/src/pages/ForgottenPassword.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/ResetPassword.tsx diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index 7cf53d6..9d01e4b 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -3,6 +3,12 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import ScrollToTop from "./components/ScrollToTop"; import TopBar from "./components/TopBar"; import Register from "./pages/Register"; +import ConfirmEmail from "./pages/ConfirmEmail"; +import Login from "./pages/Login"; +import RequireAuth from "./components/RequireAuth"; +import ChangePassword from "./pages/ChangePassword"; +import ForgottenPassword from "./pages/ForgottenPassword"; +import ResetPassword from "./pages/ResetPassword"; const Router = () => ( @@ -10,6 +16,18 @@ const Router = () => ( } /> + } /> + } /> + + + + } + /> + } /> + } /> ); diff --git a/frontend/src/pages/ChangePassword.tsx b/frontend/src/pages/ChangePassword.tsx new file mode 100644 index 0000000..d0c6e3f --- /dev/null +++ b/frontend/src/pages/ChangePassword.tsx @@ -0,0 +1,90 @@ +import axios from "axios"; +import { Form, Formik, FormikHelpers } from "formik"; +import { useContext } from "react"; +import { useNavigate } from "react-router-dom"; +import * as yup from "yup"; + +import FormActions from "src/components/FormActions"; +import LazyPasswordWithStrengthField from "src/components/LazyPasswordWithStrengthField"; +import PasswordField from "src/components/PasswordField"; +import Title from "src/components/Title"; +import { ToastContext } from "src/ToastContext"; +import { useMutation } from "src/query"; + +interface IForm { + currentPassword: string; + newPassword: string; +} + +const useChangePassword = () => { + const { addToast } = useContext(ToastContext); + const { mutateAsync: changePassword } = useMutation( + async (data: IForm) => await axios.put("/members/password/", data), + ); + const navigate = useNavigate(); + + return async (data: IForm, { setFieldError }: FormikHelpers) => { + try { + await changePassword(data); + addToast("Changed", "success"); + navigate("/"); + } catch (error: any) { + if (axios.isAxiosError(error)) { + if (error.response?.status === 400) { + setFieldError("newPassword", "Password is too weak"); + } else if (error.response?.status === 401) { + setFieldError("currentPassword", "Incorrect password"); + } + } else { + addToast("Try again", "error"); + } + } + }; +}; + +const validationSchema = yup.object({ + currentPassword: yup.string().required("Required"), + newPassword: yup.string().required("Required"), +}); + +const ChangePassword = () => { + const onSubmit = useChangePassword(); + + return ( + <> + + <Formik<IForm> + initialValues={{ currentPassword: "", newPassword: "" }} + onSubmit={onSubmit} + validationSchema={validationSchema} + > + {({ dirty, isSubmitting }) => ( + <Form> + <PasswordField + autoComplete="current-password" + fullWidth + label="Current password" + name="currentPassword" + required + /> + <LazyPasswordWithStrengthField + autoComplete="new-password" + fullWidth + label="New password" + name="newPassword" + required + /> + <FormActions + disabled={!dirty} + isSubmitting={isSubmitting} + label="Change" + links={[{ label: "Back", to: "/" }]} + /> + </Form> + )} + </Formik> + </> + ); +}; + +export default ChangePassword; diff --git a/frontend/src/pages/ConfirmEmail.tsx b/frontend/src/pages/ConfirmEmail.tsx new file mode 100644 index 0000000..4663142 --- /dev/null +++ b/frontend/src/pages/ConfirmEmail.tsx @@ -0,0 +1,44 @@ +import LinearProgress from "@mui/material/LinearProgress"; +import axios from "axios"; +import { useContext } from "react"; +import { useParams } from "react-router"; +import { Navigate } from "react-router-dom"; + +import { useQuery } from "../query"; +import { ToastContext } from "../ToastContext"; + +interface IParams { + token?: string; +} + +const ConfirmEmail = () => { + const { addToast } = useContext(ToastContext); + const params = useParams() as IParams; + const token = params.token ?? ""; + const { isLoading } = useQuery( + ["Email"], + async () => await axios.put("/members/email/", { token }), + { + onError: (error: any) => { + if (error.response?.status === 400) { + if (error.response?.data.code === "TOKEN_INVALID") { + addToast("Invalid token", "error"); + } else if (error.response?.data.code === "TOKEN_EXPIRED") { + addToast("Token expired", "error"); + } + } else { + addToast("Try again", "error"); + } + }, + onSuccess: () => addToast("Thanks", "success"), + }, + ); + + if (isLoading) { + return <LinearProgress />; + } else { + return <Navigate to="/" />; + } +}; + +export default ConfirmEmail; diff --git a/frontend/src/pages/ForgottenPassword.tsx b/frontend/src/pages/ForgottenPassword.tsx new file mode 100644 index 0000000..e5ef44f --- /dev/null +++ b/frontend/src/pages/ForgottenPassword.tsx @@ -0,0 +1,82 @@ +import axios from "axios"; +import { Form, Formik } from "formik"; +import { useContext } from "react"; +import { useLocation, useNavigate } from "react-router"; +import * as yup from "yup"; + +import EmailField from "src/components/EmailField"; +import FormActions from "src/components/FormActions"; +import Title from "src/components/Title"; +import { useMutation } from "src/query"; +import { ToastContext } from "src/ToastContext"; + +interface IForm { + email: string; +} + +const useForgottenPassword = () => { + const navigate = useNavigate(); + const { addToast } = useContext(ToastContext); + + const { mutateAsync: forgottenPassword } = useMutation( + async (data: IForm) => + await axios.post("/members/forgotten-password/", data), + ); + + return async (data: IForm) => { + try { + await forgottenPassword(data); + addToast("Reset link sent to your email", "success"); + navigate("/login/"); + } catch { + addToast("Try again", "error"); + } + }; +}; + +const validationSchema = yup.object({ + email: yup.string().email("Email invalid").required("Required"), +}); + +const ForgottenPassword = () => { + const onSubmit = useForgottenPassword(); + const location = useLocation(); + + return ( + <> + <Title title="Forgotten password" /> + <Formik<IForm> + initialValues={{ + email: (location.state as any)?.email ?? "", + }} + onSubmit={onSubmit} + validationSchema={validationSchema} + > + {({ dirty, isSubmitting, values }) => ( + <Form> + <EmailField fullWidth label="Email" name="email" required /> + <FormActions + disabled={!dirty} + isSubmitting={isSubmitting} + label="Send email" + links={[ + { + label: "Login", + to: "/login/", + state: { email: values.email }, + }, + { + label: "Register", + to: "/register/", + state: { email: values.email }, + }, + ]} + /> + </Form> + )} + </Formik> + </> + ); +}; + +export default ForgottenPassword; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..75b3225 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,99 @@ +import axios from "axios"; +import { Form, Formik, FormikHelpers } from "formik"; +import { useContext } from "react"; +import { useLocation, useNavigate } from "react-router"; +import * as yup from "yup"; + +import { AuthContext } from "../AuthContext"; +import EmailField from "../components/EmailField"; +import FormActions from "../components/FormActions"; +import PasswordField from "../components/PasswordField"; +import Title from "../components/Title"; +import { ToastContext } from "../ToastContext"; +import { useMutation } from "../query"; + +interface IForm { + email: string; + password: string; +} + +const useLogin = () => { + const location = useLocation(); + const navigate = useNavigate(); + const { addToast } = useContext(ToastContext); + const { setAuthenticated } = useContext(AuthContext); + const { mutateAsync: login } = useMutation( + async (data: IForm) => await axios.post("/sessions/", data), + ); + + return async (data: IForm, { setFieldError }: FormikHelpers<IForm>) => { + try { + await login(data); + setAuthenticated(true); + navigate((location.state as any)?.from ?? "/"); + } catch (error: any) { + if (error.response?.status === 401) { + setFieldError("email", "Invalid credentials"); + setFieldError("password", "Invalid credentials"); + } else { + addToast("Try again", "error"); + } + } + }; +}; + +const validationSchema = yup.object({ + email: yup.string().email("Email invalid").required("Required"), + password: yup.string().required("Required"), +}); + +const Login = () => { + const onSubmit = useLogin(); + const location = useLocation(); + + return ( + <> + <Title title="Login" /> + <Formik<IForm> + initialValues={{ + email: (location.state as any)?.email ?? "", + password: "", + }} + onSubmit={onSubmit} + validationSchema={validationSchema} + > + {({ dirty, isSubmitting, values }) => ( + <Form> + <EmailField fullWidth label="Email" name="email" required /> + <PasswordField + autoComplete="password" + fullWidth + label="Password" + name="password" + required + /> + <FormActions + disabled={!dirty} + isSubmitting={isSubmitting} + label="Login" + links={[ + { + label: "Reset password", + to: "/forgotten-password/", + state: { email: values.email }, + }, + { + label: "Register", + to: "/register/", + state: { email: values.email }, + }, + ]} + /> + </Form> + )} + </Formik> + </> + ); +}; + +export default Login; diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index d776085..33fec26 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -5,12 +5,12 @@ import { useNavigate } from "react-router"; import { useLocation } from "react-router-dom"; import * as yup from "yup"; -import EmailField from "../components/EmailField"; -import FormActions from "../components/FormActions"; -import LazyPasswordWithStrengthField from "../components/LazyPasswordWithStrengthField"; -import Title from "../components/Title"; -import { ToastContext } from "../ToastContext"; -import { useMutation } from "../query"; +import EmailField from "src/components/EmailField"; +import FormActions from "src/components/FormActions"; +import LazyPasswordWithStrengthField from "src/components/LazyPasswordWithStrengthField"; +import Title from "src/components/Title"; +import { ToastContext } from "src/ToastContext"; +import { useMutation } from "src/query"; interface IForm { email: string; diff --git a/frontend/src/pages/ResetPassword.tsx b/frontend/src/pages/ResetPassword.tsx new file mode 100644 index 0000000..4757d8c --- /dev/null +++ b/frontend/src/pages/ResetPassword.tsx @@ -0,0 +1,90 @@ +import axios from "axios"; +import { Form, Formik, FormikHelpers } from "formik"; +import { useContext } from "react"; +import { useNavigate, useParams } from "react-router"; +import * as yup from "yup"; + +import LazyPasswordWithStrengthField from "src/components/LazyPasswordWithStrengthField"; +import FormActions from "src/components/FormActions"; +import Title from "src/components/Title"; +import { useMutation } from "src/query"; +import { ToastContext } from "src/ToastContext"; + +interface IForm { + password: string; +} + +interface IParams { + token?: string; +} + +const useResetPassword = () => { + const navigate = useNavigate(); + const params = useParams() as IParams; + const token = params.token ?? ""; + const { addToast } = useContext(ToastContext); + + const { mutateAsync: reset } = useMutation( + async (password: string) => + await axios.put("/members/reset-password/", { password, token }), + ); + + return async (data: IForm, { setFieldError }: FormikHelpers<IForm>) => { + try { + await reset(data.password); + addToast("Success", "success"); + navigate("/login/"); + } catch (error: any) { + if (error.response?.status === 400) { + if (error.response?.data.code === "WEAK_PASSWORD") { + setFieldError("newPassword", "Password is too weak"); + } else if (error.response?.data.code === "TOKEN_INVALID") { + addToast("Invalid token", "error"); + } else if (error.response?.data.code === "TOKEN_EXPIRED") { + addToast("Token expired", "error"); + } + } else { + addToast("Try again", "error"); + } + } + }; +}; + +const validationSchema = yup.object({ + email: yup.string().email("Email invalid").required("Required"), +}); + +const ResetPassword = () => { + const onSubmit = useResetPassword(); + + return ( + <> + <Title title="Reset password" /> + <Formik<IForm> + initialValues={{ password: "" }} + onSubmit={onSubmit} + validationSchema={validationSchema} + > + {({ dirty, isSubmitting, values }) => ( + <Form> + <LazyPasswordWithStrengthField + autoComplete="new-password" + fullWidth + label="Password" + name="password" + required + /> + <FormActions + disabled={!dirty} + isSubmitting={isSubmitting} + label="Reset password" + links={[{ label: "Login", to: "/login/" }]} + /> + </Form> + )} + </Formik> + </> + ); +}; + +export default ResetPassword;