| import React, { useState, useCallback } from 'react'; | |
| import { useSearchParams } from 'react-router-dom'; | |
| import { useToastContext } from '@librechat/client'; | |
| import { useForm, Controller } from 'react-hook-form'; | |
| import { REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp'; | |
| import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, Label } from '@librechat/client'; | |
| import { useVerifyTwoFactorTempMutation } from '~/data-provider'; | |
| import { useLocalize } from '~/hooks'; | |
| interface VerifyPayload { | |
| tempToken: string; | |
| token?: string; | |
| backupCode?: string; | |
| } | |
| type TwoFactorFormInputs = { | |
| token?: string; | |
| backupCode?: string; | |
| }; | |
| const TwoFactorScreen: React.FC = React.memo(() => { | |
| const [searchParams] = useSearchParams(); | |
| const tempTokenRaw = searchParams.get('tempToken'); | |
| const tempToken = tempTokenRaw !== null && tempTokenRaw !== '' ? tempTokenRaw : ''; | |
| const { | |
| control, | |
| handleSubmit, | |
| formState: { errors }, | |
| } = useForm<TwoFactorFormInputs>(); | |
| const localize = useLocalize(); | |
| const { showToast } = useToastContext(); | |
| const [useBackup, setUseBackup] = useState<boolean>(false); | |
| const [isLoading, setIsLoading] = useState<boolean>(false); | |
| const { mutate: verifyTempMutate } = useVerifyTwoFactorTempMutation({ | |
| onSuccess: (result) => { | |
| if (result.token != null && result.token !== '') { | |
| window.location.href = '/'; | |
| } | |
| }, | |
| onMutate: () => { | |
| setIsLoading(true); | |
| }, | |
| onError: (error: unknown) => { | |
| setIsLoading(false); | |
| const err = error as { response?: { data?: { message?: unknown } } }; | |
| const errorMsg = | |
| typeof err.response?.data?.message === 'string' | |
| ? err.response.data.message | |
| : 'Error verifying 2FA'; | |
| showToast({ message: errorMsg, status: 'error' }); | |
| }, | |
| }); | |
| const onSubmit = useCallback( | |
| (data: TwoFactorFormInputs) => { | |
| const payload: VerifyPayload = { tempToken }; | |
| if (useBackup && data.backupCode != null && data.backupCode !== '') { | |
| payload.backupCode = data.backupCode; | |
| } else if (data.token != null && data.token !== '') { | |
| payload.token = data.token; | |
| } | |
| verifyTempMutate(payload); | |
| }, | |
| [tempToken, useBackup, verifyTempMutate], | |
| ); | |
| const toggleBackupOn = useCallback(() => { | |
| setUseBackup(true); | |
| }, []); | |
| const toggleBackupOff = useCallback(() => { | |
| setUseBackup(false); | |
| }, []); | |
| return ( | |
| <div className="mt-4"> | |
| <form onSubmit={handleSubmit(onSubmit)}> | |
| <Label className="flex justify-center break-keep text-center text-sm text-text-primary"> | |
| {localize('com_auth_two_factor')} | |
| </Label> | |
| {!useBackup && ( | |
| <div className="my-4 flex justify-center text-text-primary"> | |
| <Controller | |
| name="token" | |
| control={control} | |
| render={({ field: { onChange, value } }) => ( | |
| <InputOTP | |
| maxLength={6} | |
| value={value != null ? value : ''} | |
| onChange={onChange} | |
| pattern={REGEXP_ONLY_DIGITS} | |
| > | |
| <InputOTPGroup> | |
| <InputOTPSlot index={0} /> | |
| <InputOTPSlot index={1} /> | |
| <InputOTPSlot index={2} /> | |
| </InputOTPGroup> | |
| <InputOTPSeparator /> | |
| <InputOTPGroup> | |
| <InputOTPSlot index={3} /> | |
| <InputOTPSlot index={4} /> | |
| <InputOTPSlot index={5} /> | |
| </InputOTPGroup> | |
| </InputOTP> | |
| )} | |
| /> | |
| {errors.token && <span className="text-sm text-red-500">{errors.token.message}</span>} | |
| </div> | |
| )} | |
| {useBackup && ( | |
| <div className="my-4 flex justify-center text-text-primary"> | |
| <Controller | |
| name="backupCode" | |
| control={control} | |
| render={({ field: { onChange, value } }) => ( | |
| <InputOTP | |
| maxLength={8} | |
| value={value != null ? value : ''} | |
| onChange={onChange} | |
| pattern={REGEXP_ONLY_DIGITS_AND_CHARS} | |
| > | |
| <InputOTPGroup> | |
| <InputOTPSlot index={0} /> | |
| <InputOTPSlot index={1} /> | |
| <InputOTPSlot index={2} /> | |
| <InputOTPSlot index={3} /> | |
| <InputOTPSlot index={4} /> | |
| <InputOTPSlot index={5} /> | |
| <InputOTPSlot index={6} /> | |
| <InputOTPSlot index={7} /> | |
| </InputOTPGroup> | |
| </InputOTP> | |
| )} | |
| /> | |
| {errors.backupCode && ( | |
| <span className="text-sm text-red-500">{errors.backupCode.message}</span> | |
| )} | |
| </div> | |
| )} | |
| <div className="flex items-center justify-between"> | |
| <button | |
| type="submit" | |
| aria-label={localize('com_auth_continue')} | |
| data-testid="login-button" | |
| disabled={isLoading} | |
| className="w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:opacity-80 dark:bg-green-600 dark:hover:bg-green-700" | |
| > | |
| {isLoading ? localize('com_auth_email_verifying_ellipsis') : localize('com_ui_verify')} | |
| </button> | |
| </div> | |
| <div className="mt-4 flex justify-center"> | |
| {!useBackup ? ( | |
| <button | |
| type="button" | |
| onClick={toggleBackupOn} | |
| className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300" | |
| > | |
| {localize('com_ui_use_backup_code')} | |
| </button> | |
| ) : ( | |
| <button | |
| type="button" | |
| onClick={toggleBackupOff} | |
| className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300" | |
| > | |
| {localize('com_ui_use_2fa_code')} | |
| </button> | |
| )} | |
| </div> | |
| </form> | |
| </div> | |
| ); | |
| }); | |
| export default TwoFactorScreen; | |