feat: resend email countdown

This commit is contained in:
Ephraim Atta-Duncan
2025-04-30 22:23:02 +00:00
parent d48705024e
commit 30a4f2c7b4

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
@ -6,10 +6,9 @@ import { RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog'; import { DialogFooter } from '@documenso/ui/primitives/dialog';
import { import {
@ -66,14 +65,33 @@ export const DocumentSigningAuth2FA = ({
const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false); const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false);
const [isEmailCodeSent, setIsEmailCodeSent] = useState(false); const [isEmailCodeSent, setIsEmailCodeSent] = useState(false);
const [isEmailCodeSending, setIsEmailCodeSending] = useState(false); const [isEmailCodeSending, setIsEmailCodeSending] = useState(false);
const [formErrorCode, setFormErrorCode] = useState<string | null>(null); const [canResendEmail, setCanResendEmail] = useState(true);
const [resendCountdown, setResendCountdown] = useState(0);
const countdownTimerRef = useRef<NodeJS.Timeout | null>(null);
const [verificationMethod, setVerificationMethod] = useState<'app' | 'email'>( const [verificationMethod, setVerificationMethod] = useState<'app' | 'email'>(
user?.twoFactorEnabled ? 'app' : 'email', user?.twoFactorEnabled ? 'app' : 'email',
); );
const emailSendInitiatedRef = useRef(false);
const sendVerificationMutation = trpc.auth.sendEmailVerification.useMutation({ const sendVerificationMutation = trpc.auth.sendEmailVerification.useMutation({
onSuccess: () => { onSuccess: () => {
setIsEmailCodeSent(true); setIsEmailCodeSent(true);
setCanResendEmail(false);
setResendCountdown(60);
countdownTimerRef.current = setInterval(() => {
setResendCountdown((prev) => {
if (prev <= 1) {
if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current);
}
setCanResendEmail(true);
return 0;
}
return prev - 1;
});
}, 1000);
toast({ toast({
title: 'Verification code sent', title: 'Verification code sent',
description: `A verification code has been sent to ${recipient.email}`, description: `A verification code has been sent to ${recipient.email}`,
@ -101,16 +119,27 @@ export const DocumentSigningAuth2FA = ({
recipientId: recipient.id, recipientId: recipient.id,
}); });
} catch (error) { } catch (error) {
// Error is handled in the mutation callbacks toast({
title: 'Failed to send verification code',
description: 'Please try again.',
variant: 'destructive',
});
} }
}; };
useEffect(() => {
return () => {
if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current);
}
};
}, []);
const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => { const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => {
try { try {
setIsCurrentlyAuthenticating(true); setIsCurrentlyAuthenticating(true);
if (verificationMethod === 'email') { if (verificationMethod === 'email') {
// Verify the email code first
await verifyCodeMutation.mutateAsync({ await verifyCodeMutation.mutateAsync({
code: token, code: token,
recipientId: recipient.id, recipientId: recipient.id,
@ -127,8 +156,11 @@ export const DocumentSigningAuth2FA = ({
} catch (err) { } catch (err) {
setIsCurrentlyAuthenticating(false); setIsCurrentlyAuthenticating(false);
const error = AppError.parseError(err); toast({
setFormErrorCode(error.code); title: 'Unauthorized',
description: 'We were unable to verify your details.',
variant: 'destructive',
});
} }
}; };
@ -138,20 +170,26 @@ export const DocumentSigningAuth2FA = ({
}); });
setIs2FASetupSuccessful(false); setIs2FASetupSuccessful(false);
setFormErrorCode(null);
setIsEmailCodeSent(false); setIsEmailCodeSent(false);
if (open && !user?.twoFactorEnabled) { if (open && !user?.twoFactorEnabled) {
setVerificationMethod('email'); setVerificationMethod('email');
} }
}, [open, user?.twoFactorEnabled, form]);
// eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => {
}, [open, user?.twoFactorEnabled]); if (!open || verificationMethod !== 'email') {
emailSendInitiatedRef.current = false;
}
}, [open, verificationMethod]);
useEffect(() => { useEffect(() => {
if (open && verificationMethod === 'email' && !isEmailCodeSent && !isEmailCodeSending) { if (open && verificationMethod === 'email' && !isEmailCodeSent && !isEmailCodeSending) {
if (!emailSendInitiatedRef.current) {
emailSendInitiatedRef.current = true;
void sendEmailVerificationCode(); void sendEmailVerificationCode();
} }
}
}, [open, verificationMethod, isEmailCodeSent, isEmailCodeSending]); }, [open, verificationMethod, isEmailCodeSent, isEmailCodeSending]);
if (verificationMethod === 'app' && !user?.twoFactorEnabled && !is2FASetupSuccessful) { if (verificationMethod === 'app' && !user?.twoFactorEnabled && !is2FASetupSuccessful) {
@ -270,27 +308,20 @@ export const DocumentSigningAuth2FA = ({
<Button <Button
type="button" type="button"
variant="link" variant="link"
disabled={isEmailCodeSending} disabled={isEmailCodeSending || !canResendEmail}
onClick={() => void sendEmailVerificationCode()} onClick={() => void sendEmailVerificationCode()}
> >
{isEmailCodeSending ? <Trans>Sending...</Trans> : <Trans>Resend code</Trans>} {isEmailCodeSending ? (
<Trans>Sending...</Trans>
) : !canResendEmail ? (
<Trans>Resend code ({resendCountdown}s)</Trans>
) : (
<Trans>Resend code</Trans>
)}
</Button> </Button>
</div> </div>
)} )}
{formErrorCode && (
<Alert variant="destructive">
<AlertTitle>
<Trans>Unauthorized</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
We were unable to verify your details. Please try again or contact support
</Trans>
</AlertDescription>
</Alert>
)}
<DialogFooter> <DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> <Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>