added login page; improved resend mails

This commit is contained in:
Myzel394 2022-10-20 18:56:05 +02:00
parent bb4c58508d
commit 2083c0bdb6
18 changed files with 497 additions and 43 deletions

View File

@ -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: <VerifyEmailRoute />,
path: "/auth/login",
element: <LoginRoute />,
},
{
loader: getServerSettings,
path: "/auth/signup",
element: <SignupRoute />,
},
{
loader: getServerSettings,
path: "/auth/verify-email",
element: <VerifyEmailRoute />,
},
{
path: "/auth/complete-account",
element: <CompleteAccountRoute />,

View File

@ -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"

View File

@ -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<LoginWithEmailResult> {
const {data} = await client.post(
`${import.meta.env.VITE_SERVER_BASE_URL}/auth/login/email-token`,
{
email,
},
)
return data
}

View File

@ -1 +0,0 @@
export default function login() {}

View File

@ -1,7 +1,7 @@
import {MinimumServerResponse} from "~/server-types"
import {client} from "~/constants/axios-client"
export default async function logout(): Promise<MinimumServerResponse> {
export default async function logout(): Promise<SimpleDetailResponse> {
const {data} = await client.post(
`${import.meta.env.VITE_SERVER_BASE_URL}/auth/logout`,
)

View File

@ -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<SimpleDetailResponse> {
const {data} = await client.post(
`${
import.meta.env.VITE_SERVER_BASE_URL
}/auth/login/email-token/resend-email`,
{
email,
sameRequestToken,
},
)
return data
}

View File

@ -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<MinimumServerResponse> {
): Promise<SimpleDetailResponse> {
const {data} = await client.post(
`${import.meta.env.VITE_SERVER_BASE_URL}/auth/resend-email`,
{

View File

@ -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<AuthenticationDetails> {
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),
}
}

View File

@ -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<FastAPIError>,
TVariables = unknown,
TContext = unknown,

View File

@ -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 (
<LoadingButton
{...props}
disabled={parentDisabled || secondsLeft > 0}
onClick={event => {
resetInterval()
onClick?.(event)
}}
>
<span>{children}</span>
{secondsLeft > 0 && <span> ({secondsLeft})</span>}
</LoadingButton>
)
}

View File

@ -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"

View File

@ -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<Form>({
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 (
<MultiStepFormElement>
<form onSubmit={formik.handleSubmit}>
<Grid
container
spacing={4}
padding={4}
justifyContent="center"
flexDirection="column"
>
<Grid item>
<Typography variant="h6" component="h1" align="center">
You got mail!
</Typography>
</Grid>
<Grid item>
<Box display="flex" justifyContent="center">
<MdMail size={64} />
</Box>
</Grid>
<Grid item>
<Typography
variant="subtitle1"
component="p"
align="center"
>
We sent you a code to your email. Enter it below to
login.
</Typography>
</Grid>
<Grid item>
<TextField
key="code"
fullWidth
name="code"
id="code"
label="code"
value={formik.values.code}
onChange={formik.handleChange}
disabled={formik.isSubmitting}
error={
formik.touched.code &&
Boolean(formik.errors.code)
}
helperText={
formik.touched.code && formik.errors.code
}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<FaHashtag />
</InputAdornment>
),
}}
/>
</Grid>
<Grid item>
<Grid
width="100%"
container
display="flex"
justifyContent="space-between"
>
<Grid item>
<ResendMailButton
email={email}
sameRequestToken={sameRequestToken}
/>
</Grid>
<Grid item>
<LoadingButton
loading={formik.isSubmitting}
variant="contained"
type="submit"
startIcon={<MdChevronRight />}
>
Login
</LoadingButton>
</Grid>
</Grid>
</Grid>
</Grid>
</form>
</MultiStepFormElement>
)
}

View File

@ -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<SimpleDetailResponse, AxiosError, void>(() =>
resendEmailLoginCode({
email,
sameRequestToken,
}),
)
const {mutate} = mutation
return (
<>
<TimedButton
interval={settings.emailResendWaitTime}
startIcon={<MdMail />}
onClick={() => mutate()}
>
Resend Mail
</TimedButton>
<MutationStatusSnackbar mutation={mutation} />
</>
)
}

View File

@ -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<any> {
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))
}),
})
}

View File

@ -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<LoginWithEmailResult, AxiosError, string>(
loginWithEmail,
{
onSuccess: ({sameRequestToken}) =>
onLogin(formik.values.email, sameRequestToken),
},
)
const formik = useFormik<Form>({
validationSchema: SCHEMA,
initialValues: {
email: "",
detail: "",
},
onSubmit: async (values, {setErrors}) => {
try {
await mutateAsync(values.email)
} catch (error) {
setErrors(parseFastapiError(error as AxiosError))
}
},
})
return (
<MultiStepFormElement>
<form onSubmit={formik.handleSubmit}>
<SimpleForm
title="Sign in"
description="We'll send you a verification code to your email."
continueActionLabel="Send code"
nonFieldError={formik.errors.detail}
isSubmitting={formik.isSubmitting}
>
{[
<TextField
key="email"
fullWidth
name="email"
id="email"
label="Email"
inputMode="email"
value={formik.values.email}
onChange={formik.handleChange}
disabled={formik.isSubmitting}
error={
formik.touched.email &&
Boolean(formik.errors.email)
}
helperText={
formik.touched.email && formik.errors.email
}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<MdEmail />
</InputAdornment>
),
}}
/>,
]}
</SimpleForm>
</form>
</MultiStepFormElement>
)
}

View File

@ -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<MinimumServerResponse, AxiosError, string>(
resendEmailVerificationCode,
{
onSettled: resetInterval,
},
const mutation = useMutation<SimpleDetailResponse, AxiosError, void>(() =>
resendEmailVerificationCode(email),
)
const {mutate: resendEmail, isLoading} = mutation
const {mutate} = mutation
return (
<>
<LoadingButton
variant="contained"
startIcon={<MdEmail />}
onClick={() => resendEmail(email)}
loading={isLoading}
disabled={secondsLeft > 0 || isLoading}
<TimedButton
interval={settings.emailResendWaitTime}
startIcon={<MdMail />}
onClick={() => mutate()}
>
<span>
<span>Resend Email</span>
{secondsLeft > 0 && <span> ({secondsLeft})</span>}
</span>
</LoadingButton>
Resend Mail
</TimedButton>
<MutationStatusSnackbar mutation={mutation} />
</>
)

36
src/routes/LoginRoute.tsx Normal file
View File

@ -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<string>("")
const [sameRequestToken, setSameRequestToken] = useState<string>("")
return (
<MultiStepForm
steps={[
<EmailForm
key="email_form"
onLogin={(email, sameRequestToken) => {
setEmail(email)
setSameRequestToken(sameRequestToken)
}}
/>,
<ConfirmCodeForm
key="confirm_code_form"
onConfirm={user => login(user, () => navigate("/"))}
email={email}
sameRequestToken={sameRequestToken}
/>,
]}
index={email === "" ? 0 : 1}
/>
)
}

View File

@ -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<string>
emailVerificationChars: string
emailVerificationLength: number
}
export interface MinimumServerResponse {
detail?: string
emailLoginTokenChars: string
emailLoginTokenLength: number
emailResendWaitTime: number
}
export interface Alias {