Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: email resend sign up view fix #2421

Merged
merged 18 commits into from
Aug 2, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions backend/app/src/main/kotlin/io/tolgee/ExceptionHandlers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.tolgee.constants.Message
import io.tolgee.dtos.request.validators.ValidationErrorType
import io.tolgee.dtos.request.validators.exceptions.ValidationException
import io.tolgee.exceptions.BadRequestException
import io.tolgee.exceptions.ErrorException
import io.tolgee.exceptions.ErrorResponseBody
import io.tolgee.exceptions.ErrorResponseTyped
import io.tolgee.exceptions.NotFoundException
import io.tolgee.exceptions.*
import io.tolgee.security.ratelimit.RateLimitResponseBody
import io.tolgee.security.ratelimit.RateLimitedException
import jakarta.persistence.EntityNotFoundException
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ import io.tolgee.constants.Caches
import io.tolgee.fixtures.andIsOk
import io.tolgee.fixtures.andIsRateLimited
import io.tolgee.fixtures.andIsUnauthorized
import io.tolgee.security.ratelimit.RateLimitedException
import io.tolgee.testing.AuthorizedControllerTest
import io.tolgee.testing.ContextRecreatingTest
import jakarta.servlet.http.HttpServletRequest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.mockito.Mockito
import org.mockito.kotlin.whenever
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest

Expand All @@ -20,6 +25,8 @@ import org.springframework.boot.test.context.SpringBootTest
"tolgee.rate-limits.ip-request-window=10000",
"tolgee.rate-limits.user-request-limit=15",
"tolgee.rate-limits.user-request-window=10000",
"tolgee.rate-limits.email-verification-request-limit=5",
"tolgee.rate-limits.email-verification-request-window=10000",
],
)
class RateLimitsTest : AuthorizedControllerTest() {
Expand All @@ -28,6 +35,20 @@ class RateLimitsTest : AuthorizedControllerTest() {
cacheManager.getCache(Caches.RATE_LIMITS)?.clear()
}

@Test
fun `email verification request limit works`() {
val createUser = dbPopulator.createUserIfNotExists(initialUsername)
val mockedRequest = Mockito.mock<HttpServletRequest>()
whenever(mockedRequest.remoteAddr).thenAnswer { "0.0.0.0" }

(0..4).forEach { _ ->
emailVerificationService.resendEmailVerification(createUser, mockedRequest, newEmail = "newEmail@gmail.com")
}
assertThrows<RateLimitedException> {
emailVerificationService.resendEmailVerification(createUser, mockedRequest, newEmail = "newEmail@gmail.com")
}
}

@Test
fun `ip request limit works`() {
(1..10).forEach { _ ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ class RateLimitProperties(
defaultExplanation = "= 1 minute",
)
var userRequestWindow: Long = 1 * 60 * 1000,
var emailVerificationRequestLimit: Int = 5,
var emailVerificationRequestLimit: Int = 2,
var emailVerificationRequestWindow: Long = 1 * 60 * 1000,
var emailVerificationRequestLimitEnabled: Boolean = true,
)
Original file line number Diff line number Diff line change
Expand Up @@ -139,20 +139,20 @@ class RateLimitService(
)
}

fun getIEmailVerificationIpRateLimitPolicy(
fun getEmailVerificationIpRateLimitPolicy(
request: HttpServletRequest,
email: String?,
): RateLimitPolicy? {
if (!rateLimitProperties.emailVerificationRequestLimitEnabled || email.isNullOrEmpty()) return null
huglx marked this conversation as resolved.
Show resolved Hide resolved

val ip = request.remoteAddr
val key = "global.ip.$ip::auth"
val key = "global.ip.$ip::email_verification"

return RateLimitPolicy(
key,
rateLimitProperties.emailVerificationRequestLimit,
Duration.ofMillis(rateLimitProperties.emailVerificationRequestWindow),
true,
false,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,12 @@ class EmailVerificationService(
}

val email = newEmail ?: getEmail(userAccount)
val policy = rateLimitService.getIEmailVerificationIpRateLimitPolicy(request, email)
val policy = rateLimitService.getEmailVerificationIpRateLimitPolicy(request, email)

if (policy != null) {
rateLimitService.consumeBucketUnless(policy) {
createForUser(userAccount, callbackUrl, email)
isVerified(userAccount)
}
rateLimitService.consumeBucket(policy)
}
createForUser(userAccount, callbackUrl, newEmail)
}

fun getEmail(userAccount: UserAccount): String {
Expand Down
8 changes: 4 additions & 4 deletions e2e/cypress/e2e/security/signUp.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ context('Sign up', () => {
cy.wait(['@signUp']);
cy.contains('Thank you for signing up!').should('be.visible');

cy.contains('Verify your email now');
cy.contains('Check your inbox');
setProperty('recaptcha.siteKey', recaptchaSiteKey);
});
});
Expand All @@ -118,7 +118,7 @@ context('Sign up', () => {
cy.wait(['@signUp']);
cy.contains('Thank you for signing up!').should('be.visible');

cy.contains('Verify your email now');
cy.contains('Check your inbox');

getUser(TEST_USERNAME).then((u) => {
expect(u[0]).be.equal(TEST_USERNAME);
Expand All @@ -138,12 +138,12 @@ context('Sign up', () => {
fillAndSubmitSignUpForm(TEST_USERNAME);
cy.contains('Thank you for signing up!').should('be.visible');

cy.contains('Verify your email now');
cy.contains('Check your inbox');

gcy('resend-email-button').click();
cy.contains('Your verification link has been resent.');

// Emails sent after registration are no longer valid
// Email sent after registration are no longer valid
getParsedEmailVerificationByIndex(1).then((r) => {
cy.wrap(r.fromAddress).should('contain', 'no-reply@tolgee.io');
cy.wrap(r.toAddress).should('contain', TEST_USERNAME);
Expand Down
101 changes: 71 additions & 30 deletions webapp/src/component/EmailNotVerifiedView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Button, styled, Typography, useTheme } from '@mui/material';
import { Link as MuiLink, styled, Typography, useTheme } from '@mui/material';
import { useApiMutation } from 'tg.service/http/useQueryApi';
import { messageService } from 'tg.service/MessageService';
import { T, useTranslate } from '@tolgee/react';
Expand All @@ -9,6 +9,8 @@ import { StyledWrapper } from 'tg.component/searchSelect/SearchStyled';
import { DashboardPage } from 'tg.component/layout/DashboardPage';
import { BaseView } from 'tg.component/layout/BaseView';
import { Redirect } from 'react-router-dom';
import { useState } from 'react';
import { useTimerCountdown } from 'tg.fixtures/useTimerCountdown';

const StyledContainer = styled('div')`
display: flex;
Expand All @@ -30,19 +32,29 @@ const StyledHeader = styled(Typography)`
const StyledDescription = styled(Typography)`
color: ${({ theme }) => theme.palette.text.primary}
margin-bottom: 40px;
text-align: center;
width: 80%;
margin-left: auto;
margin-right: auto;
max-width: 60%;
`;

const StyledHint = styled(Typography)`
margin-bottom: 20px;
font-weight: bold;
export const StyledLink = styled(MuiLink)`
cursor: pointer;
font-weight: 400;

&.disabled {
color: ${({ theme }) => theme.palette.emphasis[400]};
pointer-events: none;
}
`;

const StyledImg = styled('img')`
margin-top: 20px;
margin-bottom: 30px;
`;

const StyledEnabled = styled('span')`
const BoldSpan = styled('span')`
font-weight: 500;
`;

Expand All @@ -66,6 +78,33 @@ export const EmailNotVerifiedView = () => {
method: 'post',
});

const handleResendEmail = () => {
resendEmail.mutate(
{},
{
onSuccess: () => {
setDelay(10000);
setEnabled(true);
StartTimer();

messageService.success(<T keyName="verify_email_resend_message" />);
},
}
);
};

const [enabled, setEnabled] = useState(false);
const [delay, setDelay] = useState(0);

const { StartTimer, remainingTime } = useTimerCountdown({
callback: () => {
setEnabled(false);
},
delay: delay,
enabled,
});
const remainingSeconds = Math.floor(remainingTime / 1000);

return (
<StyledWrapper>
<DashboardPage>
Expand All @@ -78,37 +117,39 @@ export const EmailNotVerifiedView = () => {
>
<StyledContainer>
<StyledHeader variant="h4">
<T keyName="verify_email_title" />
<T keyName="verify_email_check_inbox" />
</StyledHeader>
<StyledDescription variant="body1" mb={2}>
<T
keyName="verify_email_description"
params={{ email: email, b: <StyledEnabled /> }}
keyName="verify_email_we_sent_email"
params={{ email: email, b: <BoldSpan /> }}
/>
</StyledDescription>
<StyledImg src={imageSrc} alt="Verify email" />
<StyledHint variant="body2">
<T keyName="verify_email_didnt_receive_email_hint" />
</StyledHint>
<Button
variant="contained"
data-cy="resend-email-button"
onClick={() =>
resendEmail.mutate(
{},
{
onSuccess: () => {
messageService.success(
<T keyName="verify_email_resend_message" />
);
},
}
)
}
color="primary"
>
<T keyName="verify_email_resend_button" />
</Button>

<StyledDescription variant="body1">
{enabled ? (
<T
keyName="verify_email_resend_link_retry_after"
params={{
seconds: remainingSeconds,
link: <StyledLink className="disabled"></StyledLink>,
}}
huglx marked this conversation as resolved.
Show resolved Hide resolved
/>
) : (
<T
keyName="verify_email_resend_link"
params={{
link: (
<StyledLink
data-cy="resend-email-button"
onClick={handleResendEmail}
></StyledLink>
huglx marked this conversation as resolved.
Show resolved Hide resolved
),
}}
/>
)}
</StyledDescription>
</StyledContainer>
</BaseView>
</DashboardPage>
Expand Down
4 changes: 2 additions & 2 deletions webapp/src/component/layout/TopBanner/TopBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ export function TopBanner() {
<StyledContent data-cy="top-banner-content">
{!isEmailVerified ? (
<Announcement
content={t('verify_email_announcement')}
title={t('verify_email_now_title')}
content={null}
title={t('verify_email_account_not_verified_title')}
icon={<img src={mailImage} alt="Mail Icon" />}
/>
) : (
Expand Down
12 changes: 2 additions & 10 deletions webapp/src/component/security/SignUp/SignUpView.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import { FunctionComponent } from 'react';
import { T, useTranslate } from '@tolgee/react';
import { Link, Redirect } from 'react-router-dom';
import { styled, useMediaQuery, Link as MuiLink } from '@mui/material';
import { Link as MuiLink, styled, useMediaQuery } from '@mui/material';

import { LINKS } from 'tg.constants/links';
import { useConfig } from 'tg.globalContext/helpers';
import {
CompactView,
SPLIT_CONTENT_BREAK_POINT,
} from 'tg.component/layout/CompactView';

import { Alert } from '../../common/Alert';
import { DashboardPage } from '../../layout/DashboardPage';
import { SignUpForm } from './SignUpForm';
import { SignUpProviders } from './SignUpProviders';
Expand Down Expand Up @@ -76,13 +74,7 @@ export const SignUpView: FunctionComponent = () => {
/>
}
primaryContent={
signUpMutation.isSuccess && config.needsEmailVerification ? (
<Alert severity="success">
<T keyName="sign_up_success_needs_verification_message" />
</Alert>
) : (
<SignUpForm onSubmit={onSubmit} loadable={signUpMutation} />
)
<SignUpForm onSubmit={onSubmit} loadable={signUpMutation} />
}
secondaryContent={
<StyledRightPart>
Expand Down
61 changes: 61 additions & 0 deletions webapp/src/fixtures/useTimerCountdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useEffect, useRef, useState } from 'react';

type TimerCountdownProps = {
callback: () => any;
delay: number;
enabled: boolean;
};

export const useTimerCountdown = ({
callback,
delay,
enabled,
}: TimerCountdownProps) => {
const [remainingTime, setRemainingTime] = useState(delay);
const timerRef = useRef<NodeJS.Timeout>();
const intervalRef = useRef<NodeJS.Timeout>();

const clearTimer = () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = undefined;
}
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = undefined;
}
setRemainingTime(delay);
};

const startTimer = () => {
clearTimer();
if (enabled) {
setRemainingTime(delay);
timerRef.current = setTimeout(() => {
callback();
clearTimer();
}, delay);
intervalRef.current = setInterval(() => {
setRemainingTime((prevTime) => prevTime - 1000);
}, 1000);
}
};

useEffect(() => {
return clearTimer;
}, []);

useEffect(() => {
if (enabled) {
startTimer();
} else {
clearTimer();
}
}, [enabled]);

return {
StartTimer: startTimer,
huglx marked this conversation as resolved.
Show resolved Hide resolved
clearTimer,
remainingTime,
};
};
Loading