From 2083c0bdb6a5c236064d05ece15e09ee02e88672 Mon Sep 17 00:00:00 2001 From: Myzel394 <50424412+Myzel394@users.noreply.github.com> Date: Thu, 20 Oct 2022 18:56:05 +0200 Subject: [PATCH] added login page; improved resend mails --- src/App.tsx | 10 +- src/apis/index.ts | 6 + src/apis/login-with-email.ts | 19 +++ src/apis/login.ts | 1 - src/apis/logout.ts | 2 +- src/apis/resend-email-login-code.ts | 24 +++ src/apis/resend-email-verification-code.ts | 4 +- src/apis/verify-login-with-email.ts | 32 ++++ src/components/MutationStatusSnackbar.tsx | 4 +- src/components/TimedButton.tsx | 37 +++++ src/components/index.ts | 2 + .../ConfirmCodeForm/ConfirmCodeForm.tsx | 147 ++++++++++++++++++ .../ConfirmCodeForm/ResendMailButton.tsx | 43 +++++ .../LoginRoute/ConfirmCodeForm/use-schema.ts | 25 +++ src/route-widgets/LoginRoute/EmailForm.tsx | 91 +++++++++++ .../YouGotMail/ResendMailButton.tsx | 43 ++--- src/routes/LoginRoute.tsx | 36 +++++ src/server-types.ts | 14 +- 18 files changed, 497 insertions(+), 43 deletions(-) create mode 100644 src/apis/login-with-email.ts delete mode 100644 src/apis/login.ts create mode 100644 src/apis/resend-email-login-code.ts create mode 100644 src/apis/verify-login-with-email.ts create mode 100644 src/components/TimedButton.tsx create mode 100644 src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx create mode 100644 src/route-widgets/LoginRoute/ConfirmCodeForm/ResendMailButton.tsx create mode 100644 src/route-widgets/LoginRoute/ConfirmCodeForm/use-schema.ts create mode 100644 src/route-widgets/LoginRoute/EmailForm.tsx create mode 100644 src/routes/LoginRoute.tsx diff --git a/src/App.tsx b/src/App.tsx index baecd98..8567bc5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import AuthContextProvider from "~/AuthContext/AuthContextProvider" import AuthenticateRoute from "~/routes/AuthenticateRoute" import AuthenticatedRoute from "~/routes/AuthenticatedRoute" import CompleteAccountRoute from "~/routes/CompleteAccountRoute" +import LoginRoute from "~/routes/LoginRoute" import RootRoute from "~/routes/Root" import SignupRoute from "~/routes/SignupRoute" import VerifyEmailRoute from "~/routes/VerifyEmailRoute" @@ -28,14 +29,19 @@ const router = createBrowserRouter([ children: [ { loader: getServerSettings, - path: "/auth/verify-email", - element: , + path: "/auth/login", + element: , }, { loader: getServerSettings, path: "/auth/signup", element: , }, + { + loader: getServerSettings, + path: "/auth/verify-email", + element: , + }, { path: "/auth/complete-account", element: , diff --git a/src/apis/index.ts b/src/apis/index.ts index 3256d5b..2381b03 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -18,3 +18,9 @@ export * from "./create-alias" export {default as createAlias} from "./create-alias" export * from "./update-account" export {default as updateAccount} from "./update-account" +export * from "./login-with-email" +export {default as loginWithEmail} from "./login-with-email" +export * from "./verify-login-with-email" +export {default as verifyLoginWithEmail} from "./verify-login-with-email" +export * from "./resend-email-login-code" +export {default as resendEmailLoginCode} from "./resend-email-login-code" diff --git a/src/apis/login-with-email.ts b/src/apis/login-with-email.ts new file mode 100644 index 0000000..f3a4e95 --- /dev/null +++ b/src/apis/login-with-email.ts @@ -0,0 +1,19 @@ +import {client} from "~/constants/axios-client" + +export interface LoginWithEmailResult { + detail: string + sameRequestToken: string +} + +export default async function loginWithEmail( + email: string, +): Promise { + const {data} = await client.post( + `${import.meta.env.VITE_SERVER_BASE_URL}/auth/login/email-token`, + { + email, + }, + ) + + return data +} diff --git a/src/apis/login.ts b/src/apis/login.ts deleted file mode 100644 index 63ce312..0000000 --- a/src/apis/login.ts +++ /dev/null @@ -1 +0,0 @@ -export default function login() {} diff --git a/src/apis/logout.ts b/src/apis/logout.ts index 293532d..a12ad95 100644 --- a/src/apis/logout.ts +++ b/src/apis/logout.ts @@ -1,7 +1,7 @@ import {MinimumServerResponse} from "~/server-types" import {client} from "~/constants/axios-client" -export default async function logout(): Promise { +export default async function logout(): Promise { const {data} = await client.post( `${import.meta.env.VITE_SERVER_BASE_URL}/auth/logout`, ) diff --git a/src/apis/resend-email-login-code.ts b/src/apis/resend-email-login-code.ts new file mode 100644 index 0000000..374345e --- /dev/null +++ b/src/apis/resend-email-login-code.ts @@ -0,0 +1,24 @@ +import {SimpleDetailResponse} from "~/server-types" +import {client} from "~/constants/axios-client" + +export interface ResendEmailLoginCodeData { + email: string + sameRequestToken: string +} + +export default async function resendEmailLoginCode({ + email, + sameRequestToken, +}: ResendEmailLoginCodeData): Promise { + const {data} = await client.post( + `${ + import.meta.env.VITE_SERVER_BASE_URL + }/auth/login/email-token/resend-email`, + { + email, + sameRequestToken, + }, + ) + + return data +} diff --git a/src/apis/resend-email-verification-code.ts b/src/apis/resend-email-verification-code.ts index 78f03c3..4682c5f 100644 --- a/src/apis/resend-email-verification-code.ts +++ b/src/apis/resend-email-verification-code.ts @@ -1,9 +1,9 @@ -import {MinimumServerResponse} from "~/server-types" +import {SimpleDetailResponse} from "~/server-types" import {client} from "~/constants/axios-client" export default async function resendEmailVerificationCode( email: string, -): Promise { +): Promise { const {data} = await client.post( `${import.meta.env.VITE_SERVER_BASE_URL}/auth/resend-email`, { diff --git a/src/apis/verify-login-with-email.ts b/src/apis/verify-login-with-email.ts new file mode 100644 index 0000000..c74b68a --- /dev/null +++ b/src/apis/verify-login-with-email.ts @@ -0,0 +1,32 @@ +import {AuthenticationDetails} from "~/server-types" +import {client} from "~/constants/axios-client" +import parseUser from "~/apis/helpers/parse-user" + +export interface VerifyLoginWithEmailData { + email: string + token: string + sameRequestToken: string +} + +export default async function verifyLoginWithEmail({ + email, + token, + sameRequestToken, +}: VerifyLoginWithEmailData): Promise { + const {data} = await client.post( + `${import.meta.env.VITE_SERVER_BASE_URL}/auth/login/email-token/verify`, + { + email, + token, + sameRequestToken, + }, + { + withCredentials: true, + }, + ) + + return { + ...data, + user: parseUser(data.user), + } +} diff --git a/src/components/MutationStatusSnackbar.tsx b/src/components/MutationStatusSnackbar.tsx index 6d3d28b..85e85a8 100644 --- a/src/components/MutationStatusSnackbar.tsx +++ b/src/components/MutationStatusSnackbar.tsx @@ -5,7 +5,7 @@ import {UseMutationResult} from "@tanstack/react-query" import {Alert, AlertProps, Snackbar} from "@mui/material" import {FastAPIError} from "~/utils" -import {MinimumServerResponse} from "~/server-types" +import {SimpleDetailResponse} from "~/server-types" import getErrorMessage from "~/utils/get-error-message" export interface MutationStatusSnackbarProps< @@ -21,7 +21,7 @@ export interface MutationStatusSnackbarProps< } export default function MutationStatusSnackbar< - TData extends MinimumServerResponse = MinimumServerResponse, + TData extends SimpleDetailResponse = SimpleDetailResponse, TError extends AxiosError = AxiosError, TVariables = unknown, TContext = unknown, diff --git a/src/components/TimedButton.tsx b/src/components/TimedButton.tsx new file mode 100644 index 0000000..dcc56f2 --- /dev/null +++ b/src/components/TimedButton.tsx @@ -0,0 +1,37 @@ +import {ReactElement} from "react" +import differenceInSeconds from "date-fns/differenceInSeconds" + +import {LoadingButton, LoadingButtonProps} from "@mui/lab" + +import {useIntervalUpdate} from "~/hooks" +import {isDev} from "~/constants/development" + +export interface TimedButtonProps extends LoadingButtonProps { + interval: number +} + +export default function TimedButton({ + interval, + children, + onClick, + disabled: parentDisabled = false, + ...props +}: TimedButtonProps): ReactElement { + const [startDate, resetInterval] = useIntervalUpdate(1000) + const secondsPassed = differenceInSeconds(new Date(), startDate) + const secondsLeft = (isDev ? 3 : interval) - secondsPassed + + return ( + 0} + onClick={event => { + resetInterval() + onClick?.(event) + }} + > + {children} + {secondsLeft > 0 && ({secondsLeft})} + + ) +} diff --git a/src/components/index.ts b/src/components/index.ts index 702f9f5..a27bd0d 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -10,3 +10,5 @@ export * from "./SimpleForm" export {default as SimpleForm} from "./SimpleForm" export * from "./MutationStatusSnackbar" export {default as MutationStatusSnackbar} from "./MutationStatusSnackbar" +export * from "./TimedButton" +export {default as TimedButton} from "./TimedButton" diff --git a/src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx b/src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx new file mode 100644 index 0000000..fde920f --- /dev/null +++ b/src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx @@ -0,0 +1,147 @@ +import {AxiosError} from "axios" +import {ReactElement} from "react" +import {useFormik} from "formik" +import {FaHashtag} from "react-icons/fa" +import {MdChevronRight, MdMail} from "react-icons/md" + +import {useMutation} from "@tanstack/react-query" +import {Box, Grid, InputAdornment, TextField, Typography} from "@mui/material" +import {LoadingButton} from "@mui/lab" + +import {AuthenticationDetails, ServerUser} from "~/server-types" +import {VerifyLoginWithEmailData, verifyLoginWithEmail} from "~/apis" +import {MultiStepFormElement} from "~/components" +import {parseFastapiError} from "~/utils" + +import ResendMailButton from "./ResendMailButton" +import useSchema from "./use-schema" + +export interface ConfirmCodeFormProps { + onConfirm: (user: ServerUser) => void + email: string + sameRequestToken: string +} + +interface Form { + code: string + detail: string +} + +export default function ConfirmCodeForm({ + onConfirm, + email, + sameRequestToken, +}: ConfirmCodeFormProps): ReactElement { + const schema = useSchema() + const {mutateAsync} = useMutation< + AuthenticationDetails, + AxiosError, + VerifyLoginWithEmailData + >(verifyLoginWithEmail, { + onSuccess: ({user}) => onConfirm(user), + }) + const formik = useFormik
({ + validationSchema: schema, + initialValues: { + code: "", + detail: "", + }, + onSubmit: async (values, {setErrors}) => { + try { + await mutateAsync({ + email, + sameRequestToken, + token: values.code, + }) + } catch (error) { + setErrors(parseFastapiError(error as AxiosError)) + } + }, + }) + + return ( + + + + + + You got mail! + + + + + + + + + + We sent you a code to your email. Enter it below to + login. + + + + + + + ), + }} + /> + + + + + + + + } + > + Login + + + + + + +
+ ) +} diff --git a/src/route-widgets/LoginRoute/ConfirmCodeForm/ResendMailButton.tsx b/src/route-widgets/LoginRoute/ConfirmCodeForm/ResendMailButton.tsx new file mode 100644 index 0000000..f8a606c --- /dev/null +++ b/src/route-widgets/LoginRoute/ConfirmCodeForm/ResendMailButton.tsx @@ -0,0 +1,43 @@ +import {AxiosError} from "axios" +import {useLoaderData} from "react-router-dom" +import React, {ReactElement} from "react" + +import {useMutation} from "@tanstack/react-query" + +import {resendEmailLoginCode} from "~/apis" +import {MutationStatusSnackbar, TimedButton} from "~/components" +import {ServerSettings, SimpleDetailResponse} from "~/server-types" +import {MdMail} from "react-icons/md" + +export interface ResendMailButtonProps { + email: string + sameRequestToken: string +} + +export default function ResendMailButton({ + email, + sameRequestToken, +}: ResendMailButtonProps): ReactElement { + const settings = useLoaderData() as ServerSettings + + const mutation = useMutation(() => + resendEmailLoginCode({ + email, + sameRequestToken, + }), + ) + const {mutate} = mutation + + return ( + <> + } + onClick={() => mutate()} + > + Resend Mail + + + + ) +} diff --git a/src/route-widgets/LoginRoute/ConfirmCodeForm/use-schema.ts b/src/route-widgets/LoginRoute/ConfirmCodeForm/use-schema.ts new file mode 100644 index 0000000..64f4b67 --- /dev/null +++ b/src/route-widgets/LoginRoute/ConfirmCodeForm/use-schema.ts @@ -0,0 +1,25 @@ +import * as yup from "yup" +import {useLoaderData} from "react-router-dom" + +import {ServerSettings} from "~/server-types" + +export default function useSchema(): yup.ObjectSchema { + const settings = useLoaderData() as ServerSettings + + return yup.object().shape({ + code: yup + .string() + .required() + .min(settings.emailLoginTokenLength) + .max(settings.emailLoginTokenLength) + .test("chars", "This code is not valid.", code => { + if (!code) { + return false + } + + const chars = settings.emailLoginTokenChars.split("") + + return code.split("").every(char => chars.includes(char)) + }), + }) +} diff --git a/src/route-widgets/LoginRoute/EmailForm.tsx b/src/route-widgets/LoginRoute/EmailForm.tsx new file mode 100644 index 0000000..31980be --- /dev/null +++ b/src/route-widgets/LoginRoute/EmailForm.tsx @@ -0,0 +1,91 @@ +import * as yup from "yup" +import {ReactElement} from "react" +import {AxiosError} from "axios" +import {useFormik} from "formik" +import {MdEmail} from "react-icons/md" + +import {useMutation} from "@tanstack/react-query" +import {InputAdornment, TextField} from "@mui/material" + +import {LoginWithEmailResult, loginWithEmail} from "~/apis" +import {parseFastapiError} from "~/utils" +import {MultiStepFormElement, SimpleForm} from "~/components" + +export interface EmailFormProps { + onLogin: (email: string, sameRequestToken: string) => void +} + +interface Form { + email: string + detail: string +} + +const SCHEMA = yup.object().shape({ + email: yup.string().email().required(), +}) + +export default function EmailForm({onLogin}: EmailFormProps): ReactElement { + const {mutateAsync} = useMutation( + loginWithEmail, + { + onSuccess: ({sameRequestToken}) => + onLogin(formik.values.email, sameRequestToken), + }, + ) + const formik = useFormik
({ + validationSchema: SCHEMA, + initialValues: { + email: "", + detail: "", + }, + onSubmit: async (values, {setErrors}) => { + try { + await mutateAsync(values.email) + } catch (error) { + setErrors(parseFastapiError(error as AxiosError)) + } + }, + }) + + return ( + + + + {[ + + + + ), + }} + />, + ]} + + +
+ ) +} diff --git a/src/route-widgets/SignupRoute/YouGotMail/ResendMailButton.tsx b/src/route-widgets/SignupRoute/YouGotMail/ResendMailButton.tsx index 87d1880..6764404 100644 --- a/src/route-widgets/SignupRoute/YouGotMail/ResendMailButton.tsx +++ b/src/route-widgets/SignupRoute/YouGotMail/ResendMailButton.tsx @@ -1,52 +1,37 @@ -import {MdEmail} from "react-icons/md" import {AxiosError} from "axios" +import {useLoaderData} from "react-router-dom" +import {MdMail} from "react-icons/md" import React, {ReactElement} from "react" -import differenceInSeconds from "date-fns/differenceInSeconds" import {useMutation} from "@tanstack/react-query" -import {LoadingButton} from "@mui/lab" -import {useIntervalUpdate} from "~/hooks" import {resendEmailVerificationCode} from "~/apis" -import {isDev} from "~/constants/development" -import {MutationStatusSnackbar} from "~/components" -import {MinimumServerResponse} from "~/server-types" +import {MutationStatusSnackbar, TimedButton} from "~/components" +import {ServerSettings, SimpleDetailResponse} from "~/server-types" export interface ResendMailButtonProps { email: string } -const RESEND_INTERVAL = isDev ? 3 : 60 - export default function ResendMailButton({ email, }: ResendMailButtonProps): ReactElement { - const [startDate, resetInterval] = useIntervalUpdate(1000) - const secondsPassed = differenceInSeconds(new Date(), startDate) - const secondsLeft = RESEND_INTERVAL - secondsPassed + const settings = useLoaderData() as ServerSettings - const mutation = useMutation( - resendEmailVerificationCode, - { - onSettled: resetInterval, - }, + const mutation = useMutation(() => + resendEmailVerificationCode(email), ) - const {mutate: resendEmail, isLoading} = mutation + const {mutate} = mutation return ( <> - } - onClick={() => resendEmail(email)} - loading={isLoading} - disabled={secondsLeft > 0 || isLoading} + } + onClick={() => mutate()} > - - Resend Email - {secondsLeft > 0 && ({secondsLeft})} - - + Resend Mail + ) diff --git a/src/routes/LoginRoute.tsx b/src/routes/LoginRoute.tsx new file mode 100644 index 0000000..0077d22 --- /dev/null +++ b/src/routes/LoginRoute.tsx @@ -0,0 +1,36 @@ +import {ReactElement, useContext, useState} from "react" +import {useNavigate} from "react-router-dom" + +import {MultiStepForm} from "~/components" +import AuthContext from "~/AuthContext/AuthContext" +import ConfirmCodeForm from "~/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm" +import EmailForm from "~/route-widgets/LoginRoute/EmailForm" + +export default function LoginRoute(): ReactElement { + const navigate = useNavigate() + const {login} = useContext(AuthContext) + + const [email, setEmail] = useState("") + const [sameRequestToken, setSameRequestToken] = useState("") + + return ( + { + setEmail(email) + setSameRequestToken(sameRequestToken) + }} + />, + login(user, () => navigate("/"))} + email={email} + sameRequestToken={sameRequestToken} + />, + ]} + index={email === "" ? 0 : 1} + /> + ) +} diff --git a/src/server-types.ts b/src/server-types.ts index a84e2bd..e9e2898 100644 --- a/src/server-types.ts +++ b/src/server-types.ts @@ -52,11 +52,14 @@ export interface ServerUser { } } -export interface AuthenticationDetails { - user: ServerUser +export interface SimpleDetailResponse { detail: string } +export interface AuthenticationDetails extends SimpleDetailResponse { + user: ServerUser +} + export interface ServerSettings { mailDomain: string randomEmailIdMinLength: number @@ -68,10 +71,9 @@ export interface ServerSettings { otherRelayDomains: Array emailVerificationChars: string emailVerificationLength: number -} - -export interface MinimumServerResponse { - detail?: string + emailLoginTokenChars: string + emailLoginTokenLength: number + emailResendWaitTime: number } export interface Alias {