Files
qlockify-frontend-deployment/src/pages/auth/LoginOtpPage.tsx

238 lines
7.0 KiB
TypeScript

import { Loader2 } from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import { Link, Navigate, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { loginWithOtp, sendOtp } from "../../api/users"
import { Button } from "../../components/ui/button"
import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation"
import { AuthOtpInput } from "./AuthOtpInput"
import { AuthPanel } from "./AuthPanel"
import {
completeAuthentication,
formatCooldown,
getApiErrorMessage,
getOtpRemainingSeconds,
handleThrottleError,
} from "./utils"
export function LoginOtpPage() {
const navigate = useNavigate()
const { t, lang } = useTranslation()
const {
state,
setCode,
setCooldown,
clearCooldown,
setOtpDelivery,
clearOtpDelivery,
} = useAuthFlow()
const isRtl = lang === "fa"
const autoSendStartedRef = useRef(false)
const activeSubmitCodeRef = useRef("")
const lastFailedCodeRef = useRef("")
const [isSendingOtp, setIsSendingOtp] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [now, setNow] = useState(Date.now())
if (!state.login.mobile) {
return <Navigate to="/auth/login" replace />
}
useEffect(() => {
if (!state.login.otpExpiresAt || getOtpRemainingSeconds(state.login.otpExpiresAt) <= 0) {
return
}
const timer = window.setInterval(() => {
setNow(Date.now())
}, 1000)
return () => window.clearInterval(timer)
}, [state.login.otpExpiresAt])
const sendLoginOtp = async () => {
setIsSendingOtp(true)
try {
const response = await sendOtp(state.login.mobile, "login")
clearCooldown("loginOtpSend")
clearCooldown("loginOtpVerify")
lastFailedCodeRef.current = ""
setCode("login", "")
setOtpDelivery("login", response.expires_in_seconds)
setNow(Date.now())
toast.success(t.login.toasts.verifySent)
} catch (error) {
clearOtpDelivery("login")
if (
!handleThrottleError({
error,
cooldownKey: "loginOtpSend",
setCooldown,
formatTime: (seconds) => formatCooldown(seconds, isRtl),
throttleCopy: t.login.throttle,
})
) {
toast.error(getApiErrorMessage(error, t.login.toasts.failedOtp))
}
} finally {
setIsSendingOtp(false)
}
}
useEffect(() => {
if (!state.login.pendingOtpSend || autoSendStartedRef.current) {
return
}
autoSendStartedRef.current = true
void sendLoginOtp()
}, [state.login.pendingOtpSend])
const otpRemainingSeconds = state.login.otpExpiresAt
? Math.max(0, Math.ceil((state.login.otpExpiresAt - now) / 1000))
: 0
const throttleAlert = useMemo(() => {
if (state.cooldowns.loginOtpVerify > 0) {
const formatted = formatCooldown(state.cooldowns.loginOtpVerify, isRtl)
return {
title: t.login.throttle.title,
description: t.login.throttle.otpLoginMessage(formatted),
}
}
if (state.cooldowns.loginOtpSend > 0) {
const formatted = formatCooldown(state.cooldowns.loginOtpSend, isRtl)
return {
title: t.login.throttle.title,
description: t.login.throttle.otpSendMessage(formatted),
}
}
return null
}, [isRtl, state.cooldowns.loginOtpSend, state.cooldowns.loginOtpVerify, t.login.throttle])
const expiryMessage =
otpRemainingSeconds > 0
? t.login.otpExpiresIn(formatCooldown(otpRemainingSeconds, isRtl))
: state.login.otpExpiresAt
? t.login.otpExpired
: null
const submitCode = async (code: string) => {
if (code.length !== 5) {
toast.error(t.login.toasts.enterOtp)
return
}
if (isSendingOtp || isSubmitting || code === activeSubmitCodeRef.current) {
return
}
if (code === lastFailedCodeRef.current) {
toast.error(t.login.toasts.invalidOtp)
return
}
activeSubmitCodeRef.current = code
setIsSubmitting(true)
try {
const data = await loginWithOtp(state.login.mobile, code)
clearCooldown("loginOtpVerify")
activeSubmitCodeRef.current = ""
lastFailedCodeRef.current = ""
completeAuthentication({
access: data.access,
refresh: data.refresh,
successMessage: t.login.toasts.successLogin,
redirectTo: "/profile",
navigate,
})
} catch (error) {
if (
!handleThrottleError({
error,
cooldownKey: "loginOtpVerify",
setCooldown,
formatTime: (seconds) => formatCooldown(seconds, isRtl),
throttleCopy: t.login.throttle,
})
) {
lastFailedCodeRef.current = code
toast.error(getApiErrorMessage(error, t.login.toasts.invalidOtp))
}
} finally {
activeSubmitCodeRef.current = ""
setIsSubmitting(false)
}
}
const handleResend = async () => {
autoSendStartedRef.current = false
await sendLoginOtp()
}
const isBusy = isSendingOtp || isSubmitting
const isRepeatedInvalidCode = state.login.code.length === 5 && state.login.code === lastFailedCodeRef.current
return (
<AuthPanel
title={t.login.loginOtpTitle}
description={t.login.sentCodeDesc(state.login.mobile)}
alert={throttleAlert}
>
<form
onSubmit={(event) => {
event.preventDefault()
void submitCode(state.login.code)
}}
className="grid gap-4"
>
<AuthOtpInput
id="login-otp"
value={state.login.code}
disabled={isBusy}
onChange={(value) => setCode("login", value)}
onComplete={(value) => void submitCode(value)}
/>
{expiryMessage ? (
<p className="text-center text-sm text-slate-500 dark:text-slate-400">{expiryMessage}</p>
) : null}
<Button
type="submit"
className="h-11 w-full"
disabled={isBusy || isRepeatedInvalidCode || state.cooldowns.loginOtpVerify > 0}
>
{(isSubmitting || isSendingOtp) && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{isSubmitting ? t.login.verifyingOtp : isSendingOtp ? t.login.sendingOtp : t.login.verifyAndContinue}
</Button>
<Button
type="button"
variant="outline"
className="h-11 w-full"
disabled={isBusy || otpRemainingSeconds > 0 || state.cooldowns.loginOtpSend > 0}
onClick={() => void handleResend()}
>
{state.cooldowns.loginOtpSend > 0
? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.loginOtpSend, isRtl))
: t.login.resendOtp}
</Button>
<div className="text-center text-sm text-slate-500 dark:text-slate-400 underline">
<Link
to="/auth/login/password"
className="font-medium text-blue-600 transition-colors hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
{t.login.usePasswordInstead}
</Link>
</div>
</form>
</AuthPanel>
)
}