From 4ab7303071c06756a15aaee510ec221f1afcd580 Mon Sep 17 00:00:00 2001 From: Myzel394 <50424412+Myzel394@users.noreply.github.com> Date: Sat, 17 Dec 2022 21:38:57 +0100 Subject: [PATCH] added time handling for email login's confirmation codes --- public/locales/de-DE/translation.json | 1 + public/locales/en-US/translation.json | 1 + .../ConfirmCodeForm/ConfirmCodeForm.tsx | 232 +++++++++++------- src/route-widgets/LoginRoute/EmailForm.tsx | 5 +- src/routes/LoginRoute.tsx | 12 +- src/server-types.ts | 1 + 6 files changed, 153 insertions(+), 99 deletions(-) diff --git a/public/locales/de-DE/translation.json b/public/locales/de-DE/translation.json index 5b97c6a..1f431b7 100644 --- a/public/locales/de-DE/translation.json +++ b/public/locales/de-DE/translation.json @@ -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", diff --git a/public/locales/en-US/translation.json b/public/locales/en-US/translation.json index c1e9d39..a1bcf18 100644 --- a/public/locales/en-US/translation.json +++ b/public/locales/en-US/translation.json @@ -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", diff --git a/src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx b/src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx index d959a4d..fd56241 100644 --- a/src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx +++ b/src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx @@ -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(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 ( - -
- - - - {t("routes.LoginRoute.forms.confirmCode.title")} - - - - - - - - - - {t("routes.LoginRoute.forms.confirmCode.description")} - - - - - - - changeAllowLoginFromDifferentDevice( - !allowLoginFromDifferentDevices, - ) - } - /> - } - labelPlacement="end" - label={t( - "routes.LoginRoute.forms.confirmCode.allowLoginFromDifferentDevices", - )} - /> + <> + + + + + + {t("routes.LoginRoute.forms.confirmCode.title")} + + + + + + + + + + {t("routes.LoginRoute.forms.confirmCode.description")} + + + + + + + changeAllowLoginFromDifferentDevice( + !allowLoginFromDifferentDevices, + ) + } + /> + } + labelPlacement="end" + label={t( + "routes.LoginRoute.forms.confirmCode.allowLoginFromDifferentDevices", + )} + /> + + + { + 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: ( + + + + ), + }} + /> + - - { - 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: ( - - - - ), - }} - /> + + + + + + + + } + > + {t("routes.LoginRoute.forms.confirmCode.continueAction")} + + - - - - - - - } - > - {t("routes.LoginRoute.forms.confirmCode.continueAction")} - - - - - - - + +
+ + + {t("routes.LoginRoute.forms.confirmCode.expiringSoon")} + + + ) } diff --git a/src/route-widgets/LoginRoute/EmailForm.tsx b/src/route-widgets/LoginRoute/EmailForm.tsx index 1ce5d38..69f07f6 100644 --- a/src/route-widgets/LoginRoute/EmailForm.tsx +++ b/src/route-widgets/LoginRoute/EmailForm.tsx @@ -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(null) @@ -40,7 +41,7 @@ export default function EmailForm({onLogin}: EmailFormProps): ReactElement { const formik = useFormik
({ validationSchema: schema, initialValues: { - email: "", + email: preFilledEmail, detail: "", }, onSubmit: async (values, {setErrors}) => { diff --git a/src/routes/LoginRoute.tsx b/src/routes/LoginRoute.tsx index ab17e29..33fba20 100644 --- a/src/routes/LoginRoute.tsx +++ b/src/routes/LoginRoute.tsx @@ -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(0) const [email, setEmail] = useState("") const [sameRequestToken, setSameRequestToken] = useState("") @@ -38,19 +39,24 @@ export default function LoginRoute(): ReactElement { steps={[ { setEmail(email) + setStep(1) setSameRequestToken(sameRequestToken) }} />, { + setStep(0) + }} />, ]} - index={email === "" ? 0 : 1} + index={step} /> ) } diff --git a/src/server-types.ts b/src/server-types.ts index b776d83..ec2074e 100644 --- a/src/server-types.ts +++ b/src/server-types.ts @@ -77,6 +77,7 @@ export interface ServerSettings { emailLoginTokenChars: string emailLoginTokenLength: number emailResendWaitTime: number + emailLoginExpirationInSeconds: number customAliasSuffixLength: number }