Skip to content

Commit

Permalink
feat(passcodes): add countdown timer to "send again" functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
coldlink committed Jan 27, 2025
1 parent 7148500 commit 176a2c6
Show file tree
Hide file tree
Showing 17 changed files with 179 additions and 55 deletions.
5 changes: 5 additions & 0 deletions cypress/integration/ete/registration_1.2.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const existingUserSendEmailAndValidatePasscode = ({
expectedEmailBody?: 'Your one-time passcode' | 'Your verification code';
additionalTests?: 'passcode-incorrect' | 'resend-email' | 'change-email';
}) => {
cy.setCookie('cypress-mock-state', '1'); // passcode send again timer

cy.visit(`/register/email?${params}`);
cy.get('input[name=email]').clear().type(emailAddress);

Expand All @@ -46,6 +48,7 @@ const existingUserSendEmailAndValidatePasscode = ({
case 'resend-email':
{
const timeRequestWasMade2 = new Date();
cy.wait(1000); // wait for the send again button to be enabled
cy.contains('send again').click();

cy.checkForEmailAndGetDetails(
Expand Down Expand Up @@ -430,6 +433,7 @@ describe('Registration flow - Split 1/2', () => {
});

it('resend email functionality', () => {
cy.setCookie('cypress-mock-state', '1'); // passcode send again timer
const unregisteredEmail = randomMailosaurEmail();
cy.visit(`/register/email`);

Expand All @@ -455,6 +459,7 @@ describe('Registration flow - Split 1/2', () => {
// passcode page
cy.url().should('include', '/register/email-sent');
const timeRequestWasMade2 = new Date();
cy.wait(1000); // wait for the send again button to be enabled
cy.contains('send again').click();
cy.checkForEmailAndGetDetails(
unregisteredEmail,
Expand Down
4 changes: 3 additions & 1 deletion cypress/integration/ete/registration_2.6.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1264,6 +1264,7 @@ describe('Registration flow - Split 2/2', () => {
});

it('shows reCAPTCHA errors when the request fails', () => {
cy.setCookie('cypress-mock-state', '1'); // passcode send again timer
cy
.createTestUser({
isUserEmailValidated: false,
Expand All @@ -1287,12 +1288,13 @@ describe('Registration flow - Split 2/2', () => {
);

cy.interceptRecaptcha();

cy.wait(1000); // wait for the send again button to be enabled
cy.contains('send again').click();
cy.contains('Google reCAPTCHA verification failed.');
cy.contains('If the problem persists please try the following:');

const timeRequestWasMade = new Date();
cy.wait(1000); // wait for the send again button to be enabled
cy.contains('send again').click();

cy.contains('Google reCAPTCHA verification failed.').should(
Expand Down
2 changes: 2 additions & 0 deletions cypress/integration/ete/reset_password_passcode.7.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ describe('Password reset recovery flows - with Passcodes', () => {
cy.createTestUser({
isUserEmailValidated: true,
}).then(({ emailAddress }) => {
cy.setCookie('cypress-mock-state', '1'); // passcode send again timer
cy.visit(`/reset-password`);

const timeRequestWasMade = new Date();
Expand All @@ -282,6 +283,7 @@ describe('Password reset recovery flows - with Passcodes', () => {

// resend email
const timeRequestWasMade2 = new Date();
cy.wait(1000); // wait for the send again button to be enabled
cy.contains('send again').click();

cy.checkForEmailAndGetDetails(
Expand Down
2 changes: 2 additions & 0 deletions cypress/integration/ete/sign_in_passcode.8.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('Sign In flow, with passcode', () => {
expectedEmailBody?: 'Your one-time passcode' | 'Your verification code';
additionalTests?: 'passcode-incorrect' | 'resend-email' | 'change-email';
}) => {
cy.setCookie('cypress-mock-state', '1'); // passcode send again timer
cy.visit(`/signin?${params ? `${params}&` : ''}usePasscodeSignIn=true`);
cy.get('input[name=email]').clear().type(emailAddress);

Expand All @@ -47,6 +48,7 @@ describe('Sign In flow, with passcode', () => {
case 'resend-email':
{
const timeRequestWasMade2 = new Date();
cy.wait(1000); // wait for the send again button to be enabled
cy.contains('send again').click();

cy.checkForEmailAndGetDetails(
Expand Down
1 change: 1 addition & 0 deletions cypress/integration/mocked/resetPasswordController.4.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,7 @@ userStatuses.forEach((status) => {
).toISOString(),
email: 'test@example.com',
});
cy.setCookie('cypress-mock-state', '0'); // passcode send again timer
cy.visit(`/reset-password/email-sent`);
});
switch (status) {
Expand Down
15 changes: 15 additions & 0 deletions src/client/components/EmailSentInformationBox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,18 @@ export const WithNoAccountInfo = () => {
);
};
WithNoAccountInfo.storyName = 'with noAccountInfo';

export const WithTimer = () => {
return (
<EmailSentInformationBox
setRecaptchaErrorContext={() => {}}
setRecaptchaErrorMessage={() => {}}
changeEmailPage="#"
email="test@example.com"
resendEmailAction="#"
noAccountInfo
sendAgainTimerInSeconds={10}
/>
);
};
WithTimer.storyName = 'with timer';
114 changes: 65 additions & 49 deletions src/client/components/EmailSentInformationBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { css } from '@emotion/react';
import ThemedLink from '@/client/components/ThemedLink';
import { EmailSentProps } from '@/client/pages/EmailSent';
import { useCountdownTimer } from '@/client/lib/hooks/useCountdownTimer';

type EmailSentInformationBoxProps = Pick<
EmailSentProps,
Expand All @@ -29,6 +30,7 @@ type EmailSentInformationBoxProps = Pick<
React.SetStateAction<React.ReactNode>
>;
setRecaptchaErrorMessage: React.Dispatch<React.SetStateAction<string>>;
sendAgainTimerInSeconds?: number;
};

const sendAgainFormWrapperStyles = css`
Expand All @@ -47,56 +49,70 @@ export const EmailSentInformationBox = ({
setRecaptchaErrorMessage,
queryString,
shortRequestId,
}: EmailSentInformationBoxProps) => (
<InformationBox>
<InformationBoxText>
Didn’t get the email? Check your spam&#8288;
{email && resendEmailAction && (
<span css={sendAgainFormWrapperStyles}>
,{!changeEmailPage ? <> or </> : <> </>}
<MainForm
formAction={`${resendEmailAction}${queryString}`}
submitButtonText={'send again'}
recaptchaSiteKey={recaptchaSiteKey}
setRecaptchaErrorContext={setRecaptchaErrorContext}
setRecaptchaErrorMessage={setRecaptchaErrorMessage}
formTrackingName={formTrackingName}
disableOnSubmit
formErrorMessageFromParent={formError}
displayInline
submitButtonLink
hideRecaptchaMessage
shortRequestId={shortRequestId}
>
<EmailInput defaultValue={email} hidden hideLabel />
</MainForm>
</span>
)}
{changeEmailPage && (
<>
, or{' '}
<ThemedLink href={`${changeEmailPage}${queryString}`}>
try another address
sendAgainTimerInSeconds,
}: EmailSentInformationBoxProps) => {
const timer = useCountdownTimer(sendAgainTimerInSeconds || 0);

return (
<InformationBox>
<InformationBoxText>
Didn’t get the email? Check your spam&#8288;
{email && resendEmailAction && (
<span css={sendAgainFormWrapperStyles}>
,{!changeEmailPage ? <> or </> : <> </>}
{sendAgainTimerInSeconds && !timer.isComplete ? (
<span>
{' '}
send again after {timer.timeRemaining} second
{timer.timeRemaining > 1 ? 's' : ''}
</span>
) : (
<MainForm
formAction={`${resendEmailAction}${queryString}`}
submitButtonText={'send again'}
recaptchaSiteKey={recaptchaSiteKey}
setRecaptchaErrorContext={setRecaptchaErrorContext}
setRecaptchaErrorMessage={setRecaptchaErrorMessage}
formTrackingName={formTrackingName}
disableOnSubmit
formErrorMessageFromParent={formError}
displayInline
submitButtonLink
hideRecaptchaMessage
shortRequestId={shortRequestId}
disabled={!!(sendAgainTimerInSeconds && !timer.isComplete)}
>
<EmailInput defaultValue={email} hidden hideLabel />
</MainForm>
)}
</span>
)}
{changeEmailPage && (
<>
, or{' '}
<ThemedLink href={`${changeEmailPage}${queryString}`}>
try another address
</ThemedLink>
</>
)}
.
</InformationBoxText>
{noAccountInfo && (
<InformationBoxText>
If you don’t receive an email within 2 minutes you may not have an
account. Don’t have an account?{' '}
<ThemedLink href={`${buildUrl('/register')}${queryString}`}>
Create an account for free
</ThemedLink>
</>
.
</InformationBoxText>
)}
.
</InformationBoxText>
{noAccountInfo && (
<InformationBoxText>
If you don’t receive an email within 2 minutes you may not have an
account. Don’t have an account?{' '}
<ThemedLink href={`${buildUrl('/register')}${queryString}`}>
Create an account for free
</ThemedLink>
.
For further assistance, email our customer service team at{' '}
<ExternalLink href={locations.SUPPORT_EMAIL_MAILTO}>
{SUPPORT_EMAIL}
</ExternalLink>
</InformationBoxText>
)}
<InformationBoxText>
For further assistance, email our customer service team at{' '}
<ExternalLink href={locations.SUPPORT_EMAIL_MAILTO}>
{SUPPORT_EMAIL}
</ExternalLink>
</InformationBoxText>
</InformationBox>
);
</InformationBox>
);
};
4 changes: 3 additions & 1 deletion src/client/components/MainForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export interface MainFormProps {
hideRecaptchaMessage?: boolean;
additionalTerms?: ReactNode;
shortRequestId?: string;
disabled?: boolean;
}

const formStyles = (displayInline: boolean) => css`
Expand Down Expand Up @@ -110,6 +111,7 @@ export const MainForm = ({
hideRecaptchaMessage,
additionalTerms,
shortRequestId,
disabled = false,
}: PropsWithChildren<MainFormProps>) => {
const recaptchaEnabled = !!recaptchaSiteKey;

Expand All @@ -129,7 +131,7 @@ export const MainForm = ({
const [recaptchaState, setRecaptchaState] =
useState<UseRecaptchaReturnValue>();

const [isFormDisabled, setIsFormDisabled] = useState(false);
const [isFormDisabled, setIsFormDisabled] = useState(disabled);

const showFormLevelReportUrl = !!formLevelErrorContext;

Expand Down
30 changes: 30 additions & 0 deletions src/client/lib/hooks/useCountdownTimer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';

interface CountdownTimer {
timeRemaining: number;
isComplete: boolean;
}

export const useCountdownTimer = (
durationInSeconds: number,
): CountdownTimer => {
const [timeRemaining, setTimeRemaining] = useState(durationInSeconds);
const [isComplete, setIsComplete] = useState(false);

useEffect(() => {
const timer = setInterval(() => {
setTimeRemaining((prevTime) => {
if (prevTime <= 1) {
clearInterval(timer);
setIsComplete(true);
return 0;
}
return prevTime - 1;
});
}, 1000);

return () => clearInterval(timer);
}, []);

return { timeRemaining, isComplete };
};
3 changes: 3 additions & 0 deletions src/client/pages/PasscodeEmailSent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type Props = {
noAccountInfo?: boolean;
textType?: TextType;
showSignInWithPasswordOption?: boolean;
sendAgainTimerInSeconds?: number;
};

type PasscodeEmailSentProps = EmailSentProps & Props;
Expand Down Expand Up @@ -106,6 +107,7 @@ export const PasscodeEmailSent = ({
noAccountInfo,
textType = 'generic',
showSignInWithPasswordOption,
sendAgainTimerInSeconds,
}: PasscodeEmailSentProps) => {
const [recaptchaErrorMessage, setRecaptchaErrorMessage] = useState('');
const [recaptchaErrorContext, setRecaptchaErrorContext] =
Expand Down Expand Up @@ -197,6 +199,7 @@ export const PasscodeEmailSent = ({
queryString={queryString}
shortRequestId={shortRequestId}
noAccountInfo={noAccountInfo}
sendAgainTimerInSeconds={sendAgainTimerInSeconds}
/>
</MinimalLayout>
);
Expand Down
10 changes: 9 additions & 1 deletion src/client/pages/RegistrationEmailSentPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@ export const RegistrationEmailSentPage = () => {
recaptchaConfig,
shortRequestId,
} = clientState;
const { email, hasStateHandle, fieldErrors, token, passcodeUsed } = pageData;
const {
email,
hasStateHandle,
fieldErrors,
token,
passcodeUsed,
passcodeSendAgainTimer,
} = pageData;
const { emailSentSuccess } = queryParams;
const { error } = globalMessage;
const { recaptchaSiteKey } = recaptchaConfig;
Expand Down Expand Up @@ -51,6 +58,7 @@ export const RegistrationEmailSentPage = () => {
shortRequestId={shortRequestId}
expiredPage={buildUrl('/welcome/expired')}
textType="verification"
sendAgainTimerInSeconds={passcodeSendAgainTimer}
/>
);
}
Expand Down
10 changes: 9 additions & 1 deletion src/client/pages/ResetPasswordEmailSentPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ export const ResetPasswordEmailSentPage = () => {
globalMessage = {},
recaptchaConfig,
} = clientState;
const { email, hasStateHandle, fieldErrors, token, passcodeUsed } = pageData;
const {
email,
hasStateHandle,
fieldErrors,
token,
passcodeUsed,
passcodeSendAgainTimer,
} = pageData;
const { emailSentSuccess } = queryParams;
const { error } = globalMessage;
const { recaptchaSiteKey } = recaptchaConfig;
Expand Down Expand Up @@ -44,6 +51,7 @@ export const ResetPasswordEmailSentPage = () => {
expiredPage={buildUrl('/reset-password/expired')}
noAccountInfo
textType="generic"
sendAgainTimerInSeconds={passcodeSendAgainTimer}
/>
);
}
Expand Down
4 changes: 3 additions & 1 deletion src/client/pages/SignInPasscodeEmailSentPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ export const SignInPasscodeEmailSentPage = () => {
recaptchaConfig,
shortRequestId,
} = clientState;
const { email, fieldErrors, token, passcodeUsed } = pageData;
const { email, fieldErrors, token, passcodeUsed, passcodeSendAgainTimer } =
pageData;
const { emailSentSuccess } = queryParams;
const { error } = globalMessage;
const { recaptchaSiteKey } = recaptchaConfig;
Expand Down Expand Up @@ -45,6 +46,7 @@ export const SignInPasscodeEmailSentPage = () => {
textType="signin"
shortRequestId={shortRequestId}
showSignInWithPasswordOption
sendAgainTimerInSeconds={passcodeSendAgainTimer}
/>
);
};
Loading

0 comments on commit 176a2c6

Please sign in to comment.