User registration

The registration page must allow users to signup to use the app. For our app this means they must supply an email address and a password. Once the user has supplied these we'll use the members API to create the user and then redirect the user to the login page or if the API call fails display a relevant error.

Whilst the registration page is necessary it doesn't add any value to the user, so we'll need to make it as quick and easy to complete as possible, starting by only asking for information we need, namely an email and password to login. Then making use of the metered password to show the user if their password is strong enough without having to contact the server. We'll also add form validation and the correct autocomplete values, which should encourage the browser to do most of the work for the user.

User's are likely to navigate between the registration, login, and forgotten password pages as they often can't remember if they have an account or what the password was. For this reason we can reduce their effort by persisting any email address they've typed in across these pages and offer links between the pages.

The following code to do all this should be placed in frontend/src/pages/Register.tsx,

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

import EmailField from "src/components/EmailField";
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 {
  email: string;
  password: string;
}

interface ILocationState {
  email?: string;
}

const Register = () => {
  const { t } = useTranslation();
  const history = useHistory();
  const location = useLocation<ILocationState>();
  const { addToast } = useContext(ToastContext);

  const { mutateAsync: register } = useMutation(async (data: IForm) => {
    await axios.post("/members/", data);
  });

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

  const onSubmit = async (
    data: IForm,
    { setFieldError }: FormikHelpers<IForm>,
  ) => {
    try {
      await register(data);
      addToast({ category: "success", message: t("Register.registered") });
      history.push({
        pathname: "/login/",
        state: { email: data.email },
      });
    } catch (error) {
      if (
        error.response &&
        error.response.status === 400 &&
        error.response.data.code === "WEAK_PASSWORD"
      ) {
        setFieldError("password", t("Registration.weakPassword"));
      } else {
        addToast({ category: "error", message: t("generic.tryAgainError") });
      }
    }
  };

  return (
    <>
      <Box mb={1} mt={2}>
        <Typography component="h1" variant="h5">
          {t("Register.lead")}
        </Typography>
      </Box>
      <Formik<IForm>
        initialValues={{
          email: location.state?.email ?? "",
          password: "",
        }}
        onSubmit={onSubmit}
        validationSchema={validationSchema}
      >
        {({ isSubmitting, values }) => (
          <Form>
            <EmailField
              fullWidth={true}
              label={t("generic.email")}
              name="email"
              required={true}
            />
            <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("generic.register")}
              submitting={isSubmitting}
            />
            <SecondaryButton
              label={t("generic.login")}
              to={{
                pathname: "/login/",
                state: { email: values.email },
              }}
            />
            <SecondaryButton
              label={t("generic.forgottenPassword")}
              to={{
                pathname: "/forgotten-password/",
                state: { email: values.email },
              }}
            />
          </Form>
        )}
      </Formik>
    </>
  );
};

export default Register;

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

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

const Router = () => (
  <BrowserRouter>
    ...
    <Route exact={true} path="/register/">
      <Register />
    </Route>
  </BrowserRouter>
);