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.",
"continueAction": "Anmelden",
"allowLoginFromDifferentDevices": "Anmelden von anderen Geräten erlauben",
"expiringSoon": "Dein Code läuft in weniger als einer Minute ab.",
"form": {
"code": {
"label": "Verifizierungscode",

View File

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

View File

@ -1,18 +1,23 @@
import * as yup from "yup"
import {AxiosError} from "axios"
import {ReactElement, useState} from "react"
import {ReactElement, useCallback, useMemo, useState} from "react"
import {useFormik} from "formik"
import {FaHashtag} from "react-icons/fa"
import {MdChevronRight, MdMail} from "react-icons/md"
import {useLoaderData} from "react-router-dom"
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 {
Alert,
Box,
FormControlLabel,
Grid,
InputAdornment,
Snackbar,
Switch,
TextField,
Typography,
@ -28,12 +33,14 @@ import {
import {VerifyLoginWithEmailData, verifyLoginWithEmail} from "~/apis"
import {MultiStepFormElement} from "~/components"
import {parseFastAPIError} from "~/utils"
import {isDev} from "~/constants/development"
import changeAllowEmailLoginFromDifferentDevices from "~/apis/change-allow-email-login-from-different-devices"
import ResendMailButton from "./ResendMailButton"
export interface ConfirmCodeFormProps {
onConfirm: (user: ServerUser) => void
onCodeExpired: () => void
email: string
sameRequestToken: string
}
@ -45,11 +52,15 @@ interface Form {
export default function ConfirmCodeForm({
onConfirm,
onCodeExpired,
email,
sameRequestToken,
}: ConfirmCodeFormProps): ReactElement {
const settings = useLoaderData() as ServerSettings
const expirationTime = isDev ? 9 : settings.emailLoginExpirationInSeconds
const {t} = useTranslation()
const requestDate = useMemo(() => new Date(), [])
const [isExpiringSoon, setIsExpiringSoon] = useState<boolean>(false)
const schema = yup.object().shape({
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 (
<MultiStepFormElement>
<form onSubmit={formik.handleSubmit}>
<Grid
container
spacing={4}
padding={4}
justifyContent="center"
flexDirection="column"
>
<Grid item>
<Typography variant="h6" component="h1" align="center">
{t("routes.LoginRoute.forms.confirmCode.title")}
</Typography>
</Grid>
<Grid item>
<Box display="flex" justifyContent="center">
<MdMail size={64} />
</Box>
</Grid>
<Grid item>
<Typography variant="subtitle1" component="p" align="center">
{t("routes.LoginRoute.forms.confirmCode.description")}
</Typography>
</Grid>
<Grid item>
<Grid container spacing={2} direction="column">
<Grid item>
<FormControlLabel
disabled={isLoading}
control={
<Switch
disabled={isLoading}
checked={allowLoginFromDifferentDevices}
onChange={() =>
changeAllowLoginFromDifferentDevice(
!allowLoginFromDifferentDevices,
)
}
/>
}
labelPlacement="end"
label={t(
"routes.LoginRoute.forms.confirmCode.allowLoginFromDifferentDevices",
)}
/>
<>
<MultiStepFormElement>
<form onSubmit={formik.handleSubmit}>
<Grid
container
spacing={4}
padding={4}
justifyContent="center"
flexDirection="column"
>
<Grid item>
<Typography variant="h6" component="h1" align="center">
{t("routes.LoginRoute.forms.confirmCode.title")}
</Typography>
</Grid>
<Grid item>
<Box display="flex" justifyContent="center">
<MdMail size={64} />
</Box>
</Grid>
<Grid item>
<Typography variant="subtitle1" component="p" align="center">
{t("routes.LoginRoute.forms.confirmCode.description")}
</Typography>
</Grid>
<Grid item>
<Grid container spacing={2} direction="column">
<Grid item>
<FormControlLabel
disabled={isLoading}
control={
<Switch
disabled={isLoading}
checked={allowLoginFromDifferentDevices}
onChange={() =>
changeAllowLoginFromDifferentDevice(
!allowLoginFromDifferentDevices,
)
}
/>
}
labelPlacement="end"
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 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 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 />}
>
{t("routes.LoginRoute.forms.confirmCode.continueAction")}
</LoadingButton>
</Grid>
</Grid>
</Grid>
</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 />}
>
{t("routes.LoginRoute.forms.confirmCode.continueAction")}
</LoadingButton>
</Grid>
</Grid>
</Grid>
</Grid>
</form>
</MultiStepFormElement>
</form>
</MultiStepFormElement>
<Snackbar open={isExpiringSoon}>
<Alert severity="warning" variant="filled">
{t("routes.LoginRoute.forms.confirmCode.expiringSoon")}
</Alert>
</Snackbar>
</>
)
}

View File

@ -14,6 +14,7 @@ import {MultiStepFormElement, SimpleForm} from "~/components"
import {useExtensionHandler} from "~/hooks"
export interface EmailFormProps {
email: string
onLogin: (email: string, sameRequestToken: string) => void
}
@ -22,7 +23,7 @@ interface Form {
detail: string
}
export default function EmailForm({onLogin}: EmailFormProps): ReactElement {
export default function EmailForm({onLogin, email: preFilledEmail}: EmailFormProps): ReactElement {
const {t} = useTranslation()
const $password = useRef<HTMLInputElement | null>(null)
@ -40,7 +41,7 @@ export default function EmailForm({onLogin}: EmailFormProps): ReactElement {
const formik = useFormik<Form>({
validationSchema: schema,
initialValues: {
email: "",
email: preFilledEmail,
detail: "",
},
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 {login, user} = useContext(AuthContext)
const [step, setStep] = useState<number>(0)
const [email, setEmail] = useState<string>("")
const [sameRequestToken, setSameRequestToken] = useState<string>("")
@ -38,19 +39,24 @@ export default function LoginRoute(): ReactElement {
steps={[
<EmailForm
key="email_form"
email={email}
onLogin={(email, sameRequestToken) => {
setEmail(email)
setStep(1)
setSameRequestToken(sameRequestToken)
}}
/>,
<ConfirmCodeForm
key="confirm_code_form"
onConfirm={login}
key={`confirm_code_form:${email}:${step}`}
email={email}
sameRequestToken={sameRequestToken}
onConfirm={login}
onCodeExpired={() => {
setStep(0)
}}
/>,
]}
index={email === "" ? 0 : 1}
index={step}
/>
)
}

View File

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