mirror of
https://github.com/Myzel394/kleckrelay-website.git
synced 2025-06-18 23:45:26 +02:00
added time handling for email login's confirmation codes
This commit is contained in:
parent
a8799095cd
commit
4ab7303071
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -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}) => {
|
||||
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -77,6 +77,7 @@ export interface ServerSettings {
|
||||
emailLoginTokenChars: string
|
||||
emailLoginTokenLength: number
|
||||
emailResendWaitTime: number
|
||||
emailLoginExpirationInSeconds: number
|
||||
customAliasSuffixLength: number
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user