Merge backend + frontend #1
@@ -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 = () => (
 | 
			
		||||
  <BrowserRouter>
 | 
			
		||||
@@ -10,6 +16,18 @@ const Router = () => (
 | 
			
		||||
    <TopBar />
 | 
			
		||||
    <Routes>
 | 
			
		||||
      <Route path="/register/" element={<Register />} />
 | 
			
		||||
      <Route path="/confirm-email/:token/" element={<ConfirmEmail />} />
 | 
			
		||||
      <Route path="/login/" element={<Login />} />
 | 
			
		||||
      <Route
 | 
			
		||||
        path="/change-password/"
 | 
			
		||||
        element={
 | 
			
		||||
          <RequireAuth>
 | 
			
		||||
            <ChangePassword />
 | 
			
		||||
          </RequireAuth>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
      <Route path="/forgotten-password/" element={<ForgottenPassword />} />
 | 
			
		||||
      <Route path="/reset-password/:token/" element={<ResetPassword />} />
 | 
			
		||||
    </Routes>
 | 
			
		||||
  </BrowserRouter>
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										90
									
								
								frontend/src/pages/ChangePassword.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								frontend/src/pages/ChangePassword.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -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<IForm>) => {
 | 
			
		||||
    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 (
 | 
			
		||||
    <>
 | 
			
		||||
      <Title title="Change Password" />
 | 
			
		||||
      <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;
 | 
			
		||||
							
								
								
									
										44
									
								
								frontend/src/pages/ConfirmEmail.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								frontend/src/pages/ConfirmEmail.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -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;
 | 
			
		||||
							
								
								
									
										82
									
								
								frontend/src/pages/ForgottenPassword.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								frontend/src/pages/ForgottenPassword.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -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;
 | 
			
		||||
							
								
								
									
										99
									
								
								frontend/src/pages/Login.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								frontend/src/pages/Login.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -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;
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										90
									
								
								frontend/src/pages/ResetPassword.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								frontend/src/pages/ResetPassword.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -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;
 | 
			
		||||
		Reference in New Issue
	
	Block a user