added time handling for email login's confirmation codes

This commit is contained in:
Myzel394 2022-12-17 21:38:57 +01:00
parent a8799095cd
commit 4ab7303071
6 changed files with 153 additions and 99 deletions

View File

@ -40,6 +40,7 @@
"description": "Wir haben einen Code an deine E-Mail gesendet. Gib ihn hier ein, um dich anzumelden.", "description": "Wir haben einen Code an deine E-Mail gesendet. Gib ihn hier ein, um dich anzumelden.",
"continueAction": "Anmelden", "continueAction": "Anmelden",
"allowLoginFromDifferentDevices": "Anmelden von anderen Geräten erlauben", "allowLoginFromDifferentDevices": "Anmelden von anderen Geräten erlauben",
"expiringSoon": "Dein Code läuft in weniger als einer Minute ab.",
"form": { "form": {
"code": { "code": {
"label": "Verifizierungscode", "label": "Verifizierungscode",

View File

@ -40,6 +40,7 @@
"description": "We sent you a code to your email. Enter it below to login", "description": "We sent you a code to your email. Enter it below to login",
"continueAction": "Log in", "continueAction": "Log in",
"allowLoginFromDifferentDevices": "Allow login from different devices", "allowLoginFromDifferentDevices": "Allow login from different devices",
"expiringSoon": "Your code will expire in less than a minute.",
"form": { "form": {
"code": { "code": {
"label": "Verification Code", "label": "Verification Code",

View File

@ -1,18 +1,23 @@
import * as yup from "yup" import * as yup from "yup"
import {AxiosError} from "axios" import {AxiosError} from "axios"
import {ReactElement, useState} from "react" import {ReactElement, useCallback, useMemo, useState} from "react"
import {useFormik} from "formik" import {useFormik} from "formik"
import {FaHashtag} from "react-icons/fa" import {FaHashtag} from "react-icons/fa"
import {MdChevronRight, MdMail} from "react-icons/md" import {MdChevronRight, MdMail} from "react-icons/md"
import {useLoaderData} from "react-router-dom" import {useLoaderData} from "react-router-dom"
import {useTranslation} from "react-i18next" import {useTranslation} from "react-i18next"
import {useEffectOnce} from "react-use"
import differenceInSeconds from "date-fns/differenceInSeconds"
import inMilliseconds from "in-milliseconds"
import {useMutation} from "@tanstack/react-query" import {useMutation} from "@tanstack/react-query"
import { import {
Alert,
Box, Box,
FormControlLabel, FormControlLabel,
Grid, Grid,
InputAdornment, InputAdornment,
Snackbar,
Switch, Switch,
TextField, TextField,
Typography, Typography,
@ -28,12 +33,14 @@ import {
import {VerifyLoginWithEmailData, verifyLoginWithEmail} from "~/apis" import {VerifyLoginWithEmailData, verifyLoginWithEmail} from "~/apis"
import {MultiStepFormElement} from "~/components" import {MultiStepFormElement} from "~/components"
import {parseFastAPIError} from "~/utils" import {parseFastAPIError} from "~/utils"
import {isDev} from "~/constants/development"
import changeAllowEmailLoginFromDifferentDevices from "~/apis/change-allow-email-login-from-different-devices" import changeAllowEmailLoginFromDifferentDevices from "~/apis/change-allow-email-login-from-different-devices"
import ResendMailButton from "./ResendMailButton" import ResendMailButton from "./ResendMailButton"
export interface ConfirmCodeFormProps { export interface ConfirmCodeFormProps {
onConfirm: (user: ServerUser) => void onConfirm: (user: ServerUser) => void
onCodeExpired: () => void
email: string email: string
sameRequestToken: string sameRequestToken: string
} }
@ -45,11 +52,15 @@ interface Form {
export default function ConfirmCodeForm({ export default function ConfirmCodeForm({
onConfirm, onConfirm,
onCodeExpired,
email, email,
sameRequestToken, sameRequestToken,
}: ConfirmCodeFormProps): ReactElement { }: ConfirmCodeFormProps): ReactElement {
const settings = useLoaderData() as ServerSettings const settings = useLoaderData() as ServerSettings
const expirationTime = isDev ? 9 : settings.emailLoginExpirationInSeconds
const {t} = useTranslation() const {t} = useTranslation()
const requestDate = useMemo(() => new Date(), [])
const [isExpiringSoon, setIsExpiringSoon] = useState<boolean>(false)
const schema = yup.object().shape({ const schema = yup.object().shape({
code: yup code: yup
@ -117,104 +128,137 @@ export default function ConfirmCodeForm({
}, },
}, },
) )
const checkExpiration = useCallback(() => {
const diff = differenceInSeconds(new Date(), requestDate)
if (diff >= expirationTime) {
onCodeExpired()
} else if (diff >= expirationTime - 60) {
setIsExpiringSoon(true)
}
}, [requestDate])
useEffectOnce(() => {
const preCheck = setInterval(checkExpiration, inMilliseconds.seconds(isDev ? 1 : 20))
const finalCheck = setTimeout(checkExpiration, inMilliseconds.seconds(expirationTime))
return () => {
clearInterval(preCheck)
clearTimeout(finalCheck)
}
})
return ( return (
<MultiStepFormElement> <>
<form onSubmit={formik.handleSubmit}> <MultiStepFormElement>
<Grid <form onSubmit={formik.handleSubmit}>
container <Grid
spacing={4} container
padding={4} spacing={4}
justifyContent="center" padding={4}
flexDirection="column" justifyContent="center"
> flexDirection="column"
<Grid item> >
<Typography variant="h6" component="h1" align="center"> <Grid item>
{t("routes.LoginRoute.forms.confirmCode.title")} <Typography variant="h6" component="h1" align="center">
</Typography> {t("routes.LoginRoute.forms.confirmCode.title")}
</Grid> </Typography>
<Grid item> </Grid>
<Box display="flex" justifyContent="center"> <Grid item>
<MdMail size={64} /> <Box display="flex" justifyContent="center">
</Box> <MdMail size={64} />
</Grid> </Box>
<Grid item> </Grid>
<Typography variant="subtitle1" component="p" align="center"> <Grid item>
{t("routes.LoginRoute.forms.confirmCode.description")} <Typography variant="subtitle1" component="p" align="center">
</Typography> {t("routes.LoginRoute.forms.confirmCode.description")}
</Grid> </Typography>
<Grid item> </Grid>
<Grid container spacing={2} direction="column"> <Grid item>
<Grid item> <Grid container spacing={2} direction="column">
<FormControlLabel <Grid item>
disabled={isLoading} <FormControlLabel
control={ disabled={isLoading}
<Switch control={
disabled={isLoading} <Switch
checked={allowLoginFromDifferentDevices} disabled={isLoading}
onChange={() => checked={allowLoginFromDifferentDevices}
changeAllowLoginFromDifferentDevice( onChange={() =>
!allowLoginFromDifferentDevices, changeAllowLoginFromDifferentDevice(
) !allowLoginFromDifferentDevices,
} )
/> }
} />
labelPlacement="end" }
label={t( labelPlacement="end"
"routes.LoginRoute.forms.confirmCode.allowLoginFromDifferentDevices", label={t(
)} "routes.LoginRoute.forms.confirmCode.allowLoginFromDifferentDevices",
/> )}
/>
</Grid>
<Grid item>
<TextField
key="code"
fullWidth
name="code"
id="code"
label={t(
"routes.LoginRoute.forms.confirmCode.form.code.label",
)}
value={formik.values.code}
onChange={event => {
formik.setFieldValue(
"code",
event.target.value.replace(/\D/g, ""),
)
}}
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> </Grid>
<Grid item> </Grid>
<TextField <Grid item>
key="code" <Grid
fullWidth width="100%"
name="code" container
id="code" display="flex"
label={t("routes.LoginRoute.forms.confirmCode.form.code.label")} justifyContent="space-between"
value={formik.values.code} >
onChange={event => { <Grid item>
formik.setFieldValue( <ResendMailButton
"code", email={email}
event.target.value.replace(/\D/g, ""), sameRequestToken={sameRequestToken}
) />
}} </Grid>
disabled={formik.isSubmitting} <Grid item>
error={formik.touched.code && Boolean(formik.errors.code)} <LoadingButton
helperText={formik.touched.code && formik.errors.code} loading={formik.isSubmitting}
InputProps={{ variant="contained"
startAdornment: ( type="submit"
<InputAdornment position="start"> startIcon={<MdChevronRight />}
<FaHashtag /> >
</InputAdornment> {t("routes.LoginRoute.forms.confirmCode.continueAction")}
), </LoadingButton>
}} </Grid>
/>
</Grid> </Grid>
</Grid> </Grid>
</Grid> </Grid>
<Grid item> </form>
<Grid width="100%" container display="flex" justifyContent="space-between"> </MultiStepFormElement>
<Grid item> <Snackbar open={isExpiringSoon}>
<ResendMailButton <Alert severity="warning" variant="filled">
email={email} {t("routes.LoginRoute.forms.confirmCode.expiringSoon")}
sameRequestToken={sameRequestToken} </Alert>
/> </Snackbar>
</Grid> </>
<Grid item>
<LoadingButton
loading={formik.isSubmitting}
variant="contained"
type="submit"
startIcon={<MdChevronRight />}
>
{t("routes.LoginRoute.forms.confirmCode.continueAction")}
</LoadingButton>
</Grid>
</Grid>
</Grid>
</Grid>
</form>
</MultiStepFormElement>
) )
} }

View File

@ -14,6 +14,7 @@ import {MultiStepFormElement, SimpleForm} from "~/components"
import {useExtensionHandler} from "~/hooks" import {useExtensionHandler} from "~/hooks"
export interface EmailFormProps { export interface EmailFormProps {
email: string
onLogin: (email: string, sameRequestToken: string) => void onLogin: (email: string, sameRequestToken: string) => void
} }
@ -22,7 +23,7 @@ interface Form {
detail: string detail: string
} }
export default function EmailForm({onLogin}: EmailFormProps): ReactElement { export default function EmailForm({onLogin, email: preFilledEmail}: EmailFormProps): ReactElement {
const {t} = useTranslation() const {t} = useTranslation()
const $password = useRef<HTMLInputElement | null>(null) const $password = useRef<HTMLInputElement | null>(null)
@ -40,7 +41,7 @@ export default function EmailForm({onLogin}: EmailFormProps): ReactElement {
const formik = useFormik<Form>({ const formik = useFormik<Form>({
validationSchema: schema, validationSchema: schema,
initialValues: { initialValues: {
email: "", email: preFilledEmail,
detail: "", detail: "",
}, },
onSubmit: async (values, {setErrors}) => { onSubmit: async (values, {setErrors}) => {

View File

@ -14,6 +14,7 @@ export default function LoginRoute(): ReactElement {
const {token, email: queryEmail} = useQueryParams<{token: string; email: string}>() const {token, email: queryEmail} = useQueryParams<{token: string; email: string}>()
const {login, user} = useContext(AuthContext) const {login, user} = useContext(AuthContext)
const [step, setStep] = useState<number>(0)
const [email, setEmail] = useState<string>("") const [email, setEmail] = useState<string>("")
const [sameRequestToken, setSameRequestToken] = useState<string>("") const [sameRequestToken, setSameRequestToken] = useState<string>("")
@ -38,19 +39,24 @@ export default function LoginRoute(): ReactElement {
steps={[ steps={[
<EmailForm <EmailForm
key="email_form" key="email_form"
email={email}
onLogin={(email, sameRequestToken) => { onLogin={(email, sameRequestToken) => {
setEmail(email) setEmail(email)
setStep(1)
setSameRequestToken(sameRequestToken) setSameRequestToken(sameRequestToken)
}} }}
/>, />,
<ConfirmCodeForm <ConfirmCodeForm
key="confirm_code_form" key={`confirm_code_form:${email}:${step}`}
onConfirm={login}
email={email} email={email}
sameRequestToken={sameRequestToken} sameRequestToken={sameRequestToken}
onConfirm={login}
onCodeExpired={() => {
setStep(0)
}}
/>, />,
]} ]}
index={email === "" ? 0 : 1} index={step}
/> />
) )
} }

View File

@ -77,6 +77,7 @@ export interface ServerSettings {
emailLoginTokenChars: string emailLoginTokenChars: string
emailLoginTokenLength: number emailLoginTokenLength: number
emailResendWaitTime: number emailResendWaitTime: number
emailLoginExpirationInSeconds: number
customAliasSuffixLength: number customAliasSuffixLength: number
} }