Reset password

The reset password page is only accessed via a special link we've emailed the user. This link contains a token that the backend will use to prove the user is allowed to reset the token. Therefore on the frontend we need to allow the user to enter a new password and send it with the token to the backend.

To make it easier for the user we'll use the metered password field, and we'll let them know if their link is invalid or has expired. Lets add the following to frontend/src/pages/ResetPassword.tsx,

import Box from "@material-ui/core/Box";
import Typography from "@material-ui/core/Typography";
import axios from "axios";
import { Form, Formik } from "formik";
import React, { Suspense, useContext } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router";
import * as yup from "yup";

import PasswordField from "src/components/PasswordField";
import SecondaryButton from "src/components/SecondaryButton";
import SubmitButton from "src/components/SubmitButton";
import { useMutation } from "src/query";
import { ToastContext } from "src/ToastContext";

const MeteredPasswordField = React.lazy(
  () => import("src/components/MeteredPasswordField"),
);

interface IForm {
  password: string;
}

interface IParams {
  token?: string;
}

const ResetPassowrd = () => {
  const { t } = useTranslation();
  const { addToast } = useContext(ToastContext);
  const params = useParams<IParams>();
  const token = params.token ?? "";
  const { mutateAsync: reset } = useMutation(async (password: string) => {
    await axios.put("/members/reset-password/", { password, token });
  });

  const validationSchema = yup.object({
    password: yup.string().required(t("generic.required")),
  });

  const onSubmit = async (data: IForm) => {
    try {
      await reset(data.password);
    } catch (error) {
      if (error.response?.status === 400) {
        if (error.response.data.code === "WEAK_PASSWORD") {
          setFieldError("password", t("generic.weakPassword"));
        } else if (error.response.data.code === "TOKEN_INVALID") {
          addToast({ category: "error", message: t("generic.invalidToken") });
        } else if (error.response.data.code === "TOKEN_EXPIRED") {
          addToast({ category: "error", message: t("generic.expiredToken") });
        }
      } else {
        addToast({ category: "error", message: t("generic.tryAgainError") });
      }
    }
  };

  return (
    <>
      <Box mb={1} mt={2}>
        <Typography component="h1" variant="h5">
          {t("ResetPassword.lead")}
        </Typography>
      </Box>
      <Formik<IForm>
        initialValues={{
          password: "",
        }}
        onSubmit={onSubmit}
        validationSchema={validationSchema}
      >
        {({ isSubmitting }) => (
          <Form>
            <Suspense
              fallback={
                <PasswordField
                  autoComplete="new-password"
                  fullWidth={true}
                  label={t("generic.password")}
                  name="password"
                  required={true}
                />
              }
            >
              <MeteredPasswordField
                autoComplete="new-password"
                fullWidth={true}
                label={t("generic.password")}
                name="password"
                required={true}
              />
            </Suspense>
            <SubmitButton
              label={t("ResetPassword.reset")}
              submitting={isSubmitting}
            />
            <SecondaryButton label={t("generic.login")} to="/login/" />
          </Form>
        )}
      </Formik>
    </>
  );
};

export default ResetPassowrd;

Then we can add the page to the routing by adding the following to frontend/src/Router.tsx,

import ResetPassword from "src/pages/ResetPassword";
...

const Router = () => (
  <BrowserRouter>
    ...
    <Route exact={true} path="/reset-password/:token/">
      <ResetPassword />
    </Route>
  </BrowserRouter>
);