diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2UserController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2UserController.kt index f94c006afc..14badac005 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2UserController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/V2UserController.kt @@ -2,6 +2,7 @@ package io.tolgee.api.v2.controllers import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.activity.ActivityHolder import io.tolgee.constants.Message import io.tolgee.dtos.request.SuperTokenRequest import io.tolgee.dtos.request.UserUpdatePasswordRequestDto @@ -18,25 +19,20 @@ import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.security.authentication.JwtService import io.tolgee.security.authentication.RequiresSuperAuthentication import io.tolgee.security.payload.JwtAuthenticationResponse +import io.tolgee.service.EmailVerificationService import io.tolgee.service.ImageUploadService import io.tolgee.service.organization.OrganizationService import io.tolgee.service.security.MfaService import io.tolgee.service.security.UserAccountService +import jakarta.servlet.http.HttpServletRequest import jakarta.validation.Valid +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.hateoas.CollectionModel import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.security.crypto.password.PasswordEncoder -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.PutMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.ResponseStatus -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile @RestController @@ -52,7 +48,19 @@ class V2UserController( private val passwordEncoder: PasswordEncoder, private val jwtService: JwtService, private val mfaService: MfaService, + private val emailVerificationService: EmailVerificationService, + @Qualifier("requestActivityHolder") private val request: ActivityHolder, ) { + @Operation( + summary = "Resend email verification", + description = "Resends email verification email to currently authenticated user.", + ) + @PostMapping("/send-email-verification") + fun sendEmailVerification(request: HttpServletRequest) { + val user = authenticationFacade.authenticatedUserEntity + emailVerificationService.resendEmailVerification(user, request) + } + @Operation( summary = "Get user info", description = "Returns information about currently authenticated user.", @@ -71,8 +79,9 @@ class V2UserController( fun updateUser( @RequestBody @Valid dto: UserUpdateRequestDto?, + request: HttpServletRequest, ): PrivateUserAccountModel { - val userAccount = userAccountService.update(authenticationFacade.authenticatedUserEntity, dto!!) + val userAccount = userAccountService.update(authenticationFacade.authenticatedUserEntity, dto!!, request) val view = userAccountService.findActiveView(userAccount.id) ?: throw IllegalStateException("User not found") @@ -138,7 +147,8 @@ class V2UserController( fun updateUserOld( @RequestBody @Valid dto: UserUpdateRequestDto?, - ): PrivateUserAccountModel = updateUser(dto) + request: HttpServletRequest, + ): PrivateUserAccountModel = updateUser(dto, request) @GetMapping("/single-owned-organizations") @Operation( diff --git a/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt b/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt index d73eedc6ee..2956671d9e 100644 --- a/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/controllers/PublicController.kt @@ -16,30 +16,21 @@ import io.tolgee.exceptions.NotFoundException import io.tolgee.model.UserAccount import io.tolgee.openApiDocs.OpenApiHideFromPublicDocs import io.tolgee.security.authentication.JwtService +import io.tolgee.security.authorization.BypassEmailVerification import io.tolgee.security.payload.JwtAuthenticationResponse import io.tolgee.security.ratelimit.RateLimited import io.tolgee.security.thirdParty.GithubOAuthDelegate import io.tolgee.security.thirdParty.GoogleOAuthDelegate import io.tolgee.security.thirdParty.OAuth2Delegate import io.tolgee.service.EmailVerificationService -import io.tolgee.service.security.MfaService -import io.tolgee.service.security.ReCaptchaValidationService -import io.tolgee.service.security.SignUpService -import io.tolgee.service.security.UserAccountService -import io.tolgee.service.security.UserCredentialsService +import io.tolgee.service.security.* import jakarta.validation.Valid import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull import org.apache.commons.lang3.RandomStringUtils import org.springframework.http.MediaType import org.springframework.transaction.annotation.Transactional -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* import java.util.* @RestController @@ -67,7 +58,6 @@ class PublicController( loginRequest: LoginRequest, ): JwtAuthenticationResponse { val userAccount = userCredentialsService.checkUserCredentials(loginRequest.username, loginRequest.password) - emailVerificationService.check(userAccount) mfaService.checkMfa(userAccount, loginRequest.otp) // two factor passed, so we can generate super token @@ -157,6 +147,7 @@ class PublicController( description = "It checks whether the code from email is valid", ) @OpenApiHideFromPublicDocs + @BypassEmailVerification fun verifyEmail( @PathVariable("userId") @NotNull userId: Long, @PathVariable("code") @NotBlank code: String, diff --git a/backend/app/src/test/kotlin/io/tolgee/controllers/MarketingEmailingTest.kt b/backend/app/src/test/kotlin/io/tolgee/controllers/MarketingEmailingTest.kt index cbfde89b36..2d1fbe2213 100644 --- a/backend/app/src/test/kotlin/io/tolgee/controllers/MarketingEmailingTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/controllers/MarketingEmailingTest.kt @@ -12,11 +12,13 @@ import io.tolgee.model.UserAccount import io.tolgee.testing.AuthorizedControllerTest import io.tolgee.testing.assert import io.tolgee.util.GitHubAuthUtil +import jakarta.servlet.http.HttpServletRequest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.Mockito +import org.mockito.Mockito.mock import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.times @@ -127,7 +129,8 @@ class MarketingEmailingTest : AuthorizedControllerTest() { val updatedUser = executeInNewTransaction { val updatedUser = userAccountService.get(user.id) - userAccountService.update(userAccountService.get(user.id), updateRequestDto) + val request = mock() + userAccountService.update(userAccountService.get(user.id), updateRequestDto, request) updatedUser } Thread.sleep(100) diff --git a/backend/app/src/test/kotlin/io/tolgee/security/EmailVerificationTest.kt b/backend/app/src/test/kotlin/io/tolgee/security/EmailVerificationTest.kt index 887ea06167..40be051008 100644 --- a/backend/app/src/test/kotlin/io/tolgee/security/EmailVerificationTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/security/EmailVerificationTest.kt @@ -43,12 +43,12 @@ class EmailVerificationTest : AbstractControllerTest() { } @Test - fun doesNotLoginWhenNotVerified() { + fun loginWhenNotVerified() { val createUser = dbPopulator.createUserIfNotExists(initialUsername) emailVerificationService.createForUser(createUser) val response = doAuthentication(initialUsername, initialPassword) - assertThat(response.andReturn()).error().hasCode("email_not_verified") + assertThat(response.andReturn().response.status).isEqualTo(200) } @Test @@ -84,7 +84,7 @@ class EmailVerificationTest : AbstractControllerTest() { val createUser = dbPopulator.createUserIfNotExists(initialUsername) val emailVerification = emailVerificationService.createForUser(createUser) mvc.perform(get("/api/public/verify_email/${createUser.id}/wrong_code")) - .andExpect(status().isNotFound).andReturn() + .andExpect(status().isBadRequest).andReturn() assertThat(emailVerificationRepository.findById(emailVerification!!.id!!)).isPresent } @@ -141,11 +141,6 @@ class EmailVerificationTest : AbstractControllerTest() { return emailTestUtil.messageContents.single() } - @Test - fun signUpDoesNotReturnToken() { - assertThat(perform().response.contentAsString).isEqualTo("") - } - @Test fun `uses callback url when no frontendUrl provided`() { signUpDto.callbackUrl = "dummyCallbackUrl" diff --git a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/RateLimitProperties.kt b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/RateLimitProperties.kt index 2ee2ded573..4d134225f6 100644 --- a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/RateLimitProperties.kt +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/RateLimitProperties.kt @@ -56,4 +56,7 @@ class RateLimitProperties( defaultExplanation = "= 1 minute", ) var userRequestWindow: Long = 1 * 60 * 1000, + var emailVerificationRequestLimit: Int = 5, + var emailVerificationRequestWindow: Long = 1 * 60 * 1000, + var emailVerificationRequestLimitEnabled: Boolean = true, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt index 24f9f79da4..0e05d76d7f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -33,6 +33,7 @@ enum class Message { THIRD_PARTY_AUTH_NO_EMAIL, THIRD_PARTY_AUTH_NO_SUB, THIRD_PARTY_AUTH_UNKNOWN_ERROR, + EMAIL_ALREADY_VERIFIED, THIRD_PARTY_UNAUTHORIZED, THIRD_PARTY_GOOGLE_WORKSPACE_MISMATCH, USERNAME_ALREADY_EXISTS, @@ -233,6 +234,7 @@ enum class Message { SLACK_NOT_CONFIGURED, SLACK_WORKSPACE_ALREADY_CONNECTED, SLACK_CONNECTION_ERROR, + EMAIL_VERIFICATION_CODE_NOT_VALID, ; val code: String diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/UserAccountDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/UserAccountDto.kt index bffc807234..c94bea552a 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/UserAccountDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/cacheable/UserAccountDto.kt @@ -1,5 +1,6 @@ package io.tolgee.dtos.cacheable +import io.tolgee.model.EmailVerification import io.tolgee.model.UserAccount import java.io.Serializable import java.util.* @@ -13,6 +14,7 @@ data class UserAccountDto( val avatarHash: String?, val deleted: Boolean, val tokensValidNotBefore: Date?, + val emailVerification: EmailVerification? = null, ) : Serializable { companion object { fun fromEntity(entity: UserAccount) = @@ -25,6 +27,7 @@ data class UserAccountDto( avatarHash = entity.avatarHash, deleted = entity.deletedAt != null, tokensValidNotBefore = entity.tokensValidNotBefore, + emailVerification = entity.emailVerification, ) } diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt index c62e30375b..413f0b02fa 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/UserAccountRepository.kt @@ -53,7 +53,7 @@ interface UserAccountRepository : JpaRepository { ua.id, ua.username, ua.name, - ev.newEmail, + case when ev is not null then coalesce(ev.newEmail, ua.username) else null end, ua.avatarHash, ua.accountType, ua.role, diff --git a/backend/data/src/main/kotlin/io/tolgee/security/ratelimit/RateLimitService.kt b/backend/data/src/main/kotlin/io/tolgee/security/ratelimit/RateLimitService.kt index 43e36d6042..ed16c97809 100644 --- a/backend/data/src/main/kotlin/io/tolgee/security/ratelimit/RateLimitService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/security/ratelimit/RateLimitService.kt @@ -139,6 +139,23 @@ class RateLimitService( ) } + fun getIEmailVerificationIpRateLimitPolicy( + request: HttpServletRequest, + email: String?, + ): RateLimitPolicy? { + if (!rateLimitProperties.emailVerificationRequestLimitEnabled || email.isNullOrEmpty()) return null + + val ip = request.remoteAddr + val key = "global.ip.$ip::auth" + + return RateLimitPolicy( + key, + rateLimitProperties.emailVerificationRequestLimit, + Duration.ofMillis(rateLimitProperties.emailVerificationRequestWindow), + true, + ) + } + /** * Consumes a token from a bucket according to the rate limit policy. * diff --git a/backend/data/src/main/kotlin/io/tolgee/service/EmailVerificationService.kt b/backend/data/src/main/kotlin/io/tolgee/service/EmailVerificationService.kt index e227c043e2..ddd6235a6e 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/EmailVerificationService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/EmailVerificationService.kt @@ -6,6 +6,7 @@ package io.tolgee.service import io.tolgee.component.email.EmailVerificationSender import io.tolgee.configuration.tolgee.TolgeeProperties +import io.tolgee.constants.Message import io.tolgee.dtos.cacheable.UserAccountDto import io.tolgee.events.user.OnUserEmailVerifiedFirst import io.tolgee.events.user.OnUserUpdated @@ -15,7 +16,9 @@ import io.tolgee.exceptions.NotFoundException import io.tolgee.model.EmailVerification import io.tolgee.model.UserAccount import io.tolgee.repository.EmailVerificationRepository +import io.tolgee.security.ratelimit.RateLimitService import io.tolgee.service.security.UserAccountService +import jakarta.servlet.http.HttpServletRequest import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.ApplicationEventPublisher import org.springframework.context.annotation.Lazy @@ -29,6 +32,7 @@ class EmailVerificationService( private val emailVerificationRepository: EmailVerificationRepository, private val applicationEventPublisher: ApplicationEventPublisher, private val emailVerificationSender: EmailVerificationSender, + private val rateLimitService: RateLimitService, ) { @Lazy @Autowired @@ -52,6 +56,7 @@ class EmailVerificationService( emailVerificationRepository.save(emailVerification) userAccount.emailVerification = emailVerification + userAccountService.saveAndFlush(userAccount) if (newEmail != null) { emailVerificationSender.sendEmailVerification(userAccount.id, newEmail, resultCallbackUrl, code, false) @@ -63,6 +68,46 @@ class EmailVerificationService( return null } + @Transactional + fun resendEmailVerification( + userAccount: UserAccount, + request: HttpServletRequest, + callbackUrl: String? = null, + newEmail: String? = null, + ) { + if (newEmail == null && isVerified(userAccount)) { + throw BadRequestException(io.tolgee.constants.Message.EMAIL_ALREADY_VERIFIED) + } + + val email = newEmail ?: getEmail(userAccount) + val policy = rateLimitService.getIEmailVerificationIpRateLimitPolicy(request, email) + + if (policy != null) { + rateLimitService.consumeBucketUnless(policy) { + createForUser(userAccount, callbackUrl, email) + isVerified(userAccount) + } + } + } + + fun getEmail(userAccount: UserAccount): String { + return userAccount.emailVerification?.newEmail ?: userAccount.username + } + + fun isVerified(userAccount: UserAccountDto): Boolean { + return !( + tolgeeProperties.authentication.needsEmailVerification && + userAccount.emailVerification != null + ) + } + + fun isVerified(userAccount: UserAccount): Boolean { + return !( + tolgeeProperties.authentication.needsEmailVerification && + userAccount.emailVerification != null + ) + } + fun check(userAccount: UserAccount) { if ( tolgeeProperties.authentication.needsEmailVerification && @@ -79,10 +124,10 @@ class EmailVerificationService( ) { val user = userAccountService.findActive(userId) ?: throw NotFoundException() val old = UserAccountDto.fromEntity(user) - val emailVerification = user.emailVerification + val emailVerification = user.emailVerification ?: throw BadRequestException(Message.EMAIL_ALREADY_VERIFIED) - if (emailVerification == null || emailVerification.code != code) { - throw NotFoundException() + if (emailVerification.code != code) { + throw BadRequestException(Message.EMAIL_VERIFICATION_CODE_NOT_VALID) } val newEmail = user.emailVerification?.newEmail diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/SignUpService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/SignUpService.kt index 74a32003ce..ed022a7b9b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/SignUpService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/SignUpService.kt @@ -42,8 +42,7 @@ class SignUpService( } emailVerificationService.createForUser(user, dto.callbackUrl) - - return null + return JwtAuthenticationResponse(jwtService.emitToken(user.id, true)) } fun signUp( diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt index 1182b37d2f..cc5fb94bea 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt @@ -27,6 +27,7 @@ import io.tolgee.service.EmailVerificationService import io.tolgee.service.organization.OrganizationService import io.tolgee.util.Logging import jakarta.persistence.EntityManager +import jakarta.servlet.http.HttpServletRequest import org.apache.commons.lang3.time.DateUtils import org.hibernate.validator.internal.constraintvalidators.bv.EmailValidator import org.springframework.beans.factory.annotation.Autowired @@ -376,6 +377,7 @@ class UserAccountService( fun update( userAccount: UserAccount, dto: UserUpdateRequestDto, + request: HttpServletRequest, ): UserAccount { // Current password required to change email or password if (dto.email != userAccount.username) { @@ -389,7 +391,7 @@ class UserAccountService( } val old = UserAccountDto.fromEntity(userAccount) - updateUserEmail(userAccount, dto) + updateUserEmail(userAccount, dto, request) userAccount.name = dto.name publishUserInfoUpdatedEvent(old, userAccount) @@ -417,6 +419,7 @@ class UserAccountService( private fun updateUserEmail( userAccount: UserAccount, dto: UserUpdateRequestDto, + request: HttpServletRequest, ) { if (userAccount.username != dto.email) { if (!emailValidator.isValid(dto.email, null)) { @@ -426,7 +429,7 @@ class UserAccountService( this.findActive(dto.email)?.let { throw ValidationException(Message.USERNAME_ALREADY_EXISTS) } if (tolgeeProperties.authentication.needsEmailVerification) { - emailVerificationService.createForUser(userAccount, dto.callbackUrl, dto.email) + emailVerificationService.resendEmailVerification(userAccount, request, dto.callbackUrl, dto.email) } else { userAccount.username = dto.email } diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authorization/AbstractAuthorizationInterceptor.kt b/backend/security/src/main/kotlin/io/tolgee/security/authorization/AbstractAuthorizationInterceptor.kt index 2661997599..89c7bc8e11 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authorization/AbstractAuthorizationInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authorization/AbstractAuthorizationInterceptor.kt @@ -16,6 +16,9 @@ package io.tolgee.security.authorization +import io.tolgee.constants.Message +import io.tolgee.dtos.cacheable.UserAccountDto +import io.tolgee.exceptions.PermissionException import jakarta.servlet.DispatcherType import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse @@ -59,4 +62,17 @@ abstract class AbstractAuthorizationInterceptor : HandlerInterceptor, Ordered { val annotation = AnnotationUtils.getAnnotation(handler.method, IsGlobalRoute::class.java) return annotation != null } + + fun checkEmailVerificationOrThrow( + isEmailVerified: (UserAccountDto) -> Boolean, + userAccount: UserAccountDto, + handler: HandlerMethod, + ) { + if (!isEmailVerified( + userAccount, + ) && !handler.hasMethodAnnotation(BypassEmailVerification::class.java) + ) { + throw PermissionException(Message.EMAIL_NOT_VERIFIED) + } + } } diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authorization/BypassEmailVerification.kt b/backend/security/src/main/kotlin/io/tolgee/security/authorization/BypassEmailVerification.kt new file mode 100644 index 0000000000..92fcb06f60 --- /dev/null +++ b/backend/security/src/main/kotlin/io/tolgee/security/authorization/BypassEmailVerification.kt @@ -0,0 +1,5 @@ +package io.tolgee.security.authorization + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class BypassEmailVerification diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptor.kt b/backend/security/src/main/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptor.kt index 8b7fabd2e8..50838f4b05 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptor.kt @@ -23,6 +23,7 @@ import io.tolgee.model.enums.OrganizationRoleType import io.tolgee.security.OrganizationHolder import io.tolgee.security.RequestContextService import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.service.EmailVerificationService import io.tolgee.service.organization.OrganizationRoleService import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse @@ -44,6 +45,8 @@ class OrganizationAuthorizationInterceptor( @Lazy private val requestContextService: RequestContextService, private val organizationHolder: OrganizationHolder, + @Lazy + private val emailVerificationService: EmailVerificationService, ) : AbstractAuthorizationInterceptor() { private val logger = LoggerFactory.getLogger(this::class.java) @@ -52,7 +55,10 @@ class OrganizationAuthorizationInterceptor( response: HttpServletResponse, handler: HandlerMethod, ): Boolean { - val userId = authenticationFacade.authenticatedUser.id + val user = authenticationFacade.authenticatedUser + checkEmailVerificationOrThrow(emailVerificationService::isVerified, user, handler) + + val userId = user.id val organization = requestContextService.getTargetOrganization(request) // Two possible scenarios: we're on `GET/POST /v2/organization`, or the organization was not found. diff --git a/backend/security/src/main/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptor.kt b/backend/security/src/main/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptor.kt index a10f4fcd42..7757f21723 100644 --- a/backend/security/src/main/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptor.kt +++ b/backend/security/src/main/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptor.kt @@ -26,11 +26,13 @@ import io.tolgee.security.OrganizationHolder import io.tolgee.security.ProjectHolder import io.tolgee.security.RequestContextService import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.service.EmailVerificationService import io.tolgee.service.organization.OrganizationService import io.tolgee.service.security.SecurityService import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Lazy import org.springframework.core.annotation.AnnotationUtils import org.springframework.stereotype.Component import org.springframework.web.method.HandlerMethod @@ -47,6 +49,8 @@ class ProjectAuthorizationInterceptor( private val projectHolder: ProjectHolder, private val organizationHolder: OrganizationHolder, private val activityHolder: ActivityHolder, + @Lazy + private val emailVerificationService: EmailVerificationService, ) : AbstractAuthorizationInterceptor() { private val logger = LoggerFactory.getLogger(this::class.java) @@ -55,7 +59,10 @@ class ProjectAuthorizationInterceptor( response: HttpServletResponse, handler: HandlerMethod, ): Boolean { - val userId = authenticationFacade.authenticatedUser.id + val user = authenticationFacade.authenticatedUser + checkEmailVerificationOrThrow(emailVerificationService::isVerified, user, handler) + + val userId = user.id val project = requestContextService.getTargetProject(request) // Two possible scenarios: we're on a "global" route, or the project was not found. diff --git a/backend/security/src/test/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptorTest.kt b/backend/security/src/test/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptorTest.kt index 05a7dcc0cf..40df83dffe 100644 --- a/backend/security/src/test/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptorTest.kt +++ b/backend/security/src/test/kotlin/io/tolgee/security/authorization/OrganizationAuthorizationInterceptorTest.kt @@ -26,6 +26,7 @@ import io.tolgee.security.OrganizationHolder import io.tolgee.security.RequestContextService import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.security.authentication.TolgeeAuthentication +import io.tolgee.service.EmailVerificationService import io.tolgee.service.organization.OrganizationRoleService import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -50,12 +51,15 @@ class OrganizationAuthorizationInterceptorTest { private val userAccount = Mockito.mock(UserAccountDto::class.java) + private val emailVerificationService = Mockito.mock(EmailVerificationService::class.java) + private val organizationAuthenticationInterceptor = OrganizationAuthorizationInterceptor( authenticationFacade, organizationRoleService, requestContextService, Mockito.mock(OrganizationHolder::class.java), + emailVerificationService, ) private val mockMvc = @@ -74,6 +78,7 @@ class OrganizationAuthorizationInterceptorTest { Mockito.`when`(userAccount.id).thenReturn(1337L) Mockito.`when`(organization.id).thenReturn(1337L) + Mockito.`when`(emailVerificationService.isVerified(any())).thenReturn(true) } @AfterEach @@ -132,8 +137,31 @@ class OrganizationAuthorizationInterceptorTest { mockMvc.perform(get("/v2/organizations/1337/requires-admin")).andIsOk } + @Test + fun `rejects access if the user does not have a verified email`() { + Mockito.`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)) + .thenReturn(true) + + Mockito.`when`(emailVerificationService.isVerified(any())).thenReturn(false) + mockMvc.perform(get("/v2/organizations/1337/default-perms")).andIsForbidden + } + + @Test + fun `not throw when annotated by email verification bypass`() { + Mockito.`when`(organizationRoleService.canUserViewStrict(1337L, 1337L)) + .thenReturn(true) + + Mockito.`when`(emailVerificationService.isVerified(any())).thenReturn(false) + mockMvc.perform(get("/v2/organizations/email-bypass")).andIsOk + } + @RestController class TestController { + @GetMapping("/v2/organizations/email-bypass") + @BypassEmailVerification + @UseDefaultPermissions + fun emailBypass() = "hello!" + @GetMapping("/v2/organizations") @IsGlobalRoute fun getAll() = "hello!" diff --git a/backend/security/src/test/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptorTest.kt b/backend/security/src/test/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptorTest.kt index b707dd3fa7..d77931e869 100644 --- a/backend/security/src/test/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptorTest.kt +++ b/backend/security/src/test/kotlin/io/tolgee/security/authorization/ProjectAuthorizationInterceptorTest.kt @@ -33,6 +33,7 @@ import io.tolgee.security.ProjectNotSelectedException import io.tolgee.security.RequestContextService import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.security.authentication.TolgeeAuthentication +import io.tolgee.service.EmailVerificationService import io.tolgee.service.organization.OrganizationService import io.tolgee.service.security.SecurityService import org.junit.jupiter.api.AfterEach @@ -64,6 +65,8 @@ class ProjectAuthorizationInterceptorTest { private val apiKey = Mockito.mock(ApiKeyDto::class.java) + private val emailVerificationService = Mockito.mock(EmailVerificationService::class.java) + private val projectAuthenticationInterceptor = ProjectAuthorizationInterceptor( authenticationFacade, @@ -73,6 +76,7 @@ class ProjectAuthorizationInterceptorTest { Mockito.mock(ProjectHolder::class.java), Mockito.mock(OrganizationHolder::class.java), Mockito.mock(ActivityHolder::class.java, Mockito.RETURNS_DEEP_STUBS), + emailVerificationService, ) private val mockMvc = @@ -100,6 +104,7 @@ class ProjectAuthorizationInterceptorTest { Mockito.`when`(apiKey.projectId).thenReturn(1337L) Mockito.`when`(apiKey.scopes).thenReturn(mutableSetOf(Scope.KEYS_CREATE)) Mockito.`when`(securityService.getCurrentPermittedScopes(1337L)).thenReturn(mutableSetOf(Scope.KEYS_CREATE)) + Mockito.`when`(emailVerificationService.isVerified(any())).thenReturn(true) } @AfterEach diff --git a/e2e/cypress/common/apiCalls/common.ts b/e2e/cypress/common/apiCalls/common.ts index e7795928c0..469fdaa491 100644 --- a/e2e/cypress/common/apiCalls/common.ts +++ b/e2e/cypress/common/apiCalls/common.ts @@ -352,6 +352,16 @@ export const getParsedEmailVerification = () => }; }); +export const getParsedEmailVerificationByIndex = (index: number) => + getAllEmails().then((r) => { + return { + verifyEmailLink: r[index].html.replace(/.*(http:\/\/[\w:/]*).*/gs, '$1'), + fromAddress: r[index].from.value[0].address, + toAddress: r[index].to.value[0].address, + text: r[index].text, + }; + }); + export const getParsedEmailInvitationLink = () => getAllEmails().then( (emails) => diff --git a/e2e/cypress/e2e/errorHandling/errorHandlingGet.cy.ts b/e2e/cypress/e2e/errorHandling/errorHandlingGet.cy.ts index 74a35c8897..af4dcf6a28 100644 --- a/e2e/cypress/e2e/errorHandling/errorHandlingGet.cy.ts +++ b/e2e/cypress/e2e/errorHandling/errorHandlingGet.cy.ts @@ -35,7 +35,7 @@ describe('Error handling', () => { it('Handles 404 by redirect', () => { cy.visit(`${HOST}/organizations/not-existant/profile`); assertMessage('Not found'); - cy.url().should('include', '/projects'); + cy.url().should('include', '/'); }); it('Handles 401 by logout', () => { diff --git a/e2e/cypress/e2e/security/signUp.cy.ts b/e2e/cypress/e2e/security/signUp.cy.ts index 86973c6f5e..808560897b 100644 --- a/e2e/cypress/e2e/security/signUp.cy.ts +++ b/e2e/cypress/e2e/security/signUp.cy.ts @@ -8,6 +8,7 @@ import { enableEmailVerification, enableRegistration, getParsedEmailVerification, + getParsedEmailVerificationByIndex, getRecaptchaSiteKey, getUser, login, @@ -18,7 +19,7 @@ import { setRecaptchaSiteKey, v2apiFetch, } from '../../common/apiCalls/common'; -import { assertMessage } from '../../common/shared'; +import { assertMessage, gcy } from '../../common/shared'; import { checkAnonymousIdSet, checkAnonymousIdUnset, @@ -77,7 +78,6 @@ context('Sign up', () => { afterEach(() => { signUpAfter(TEST_USERNAME); }); - describe('without recaptcha', () => { beforeEach(() => { setRecaptchaSiteKey(null); @@ -91,9 +91,9 @@ context('Sign up', () => { }).as('signUp'); fillAndSubmitSignUpForm(TEST_USERNAME); cy.wait(['@signUp']); - cy.contains( - 'Thank you for signing up. To verify your email please follow instructions sent to provided email address.' - ).should('be.visible'); + cy.contains('Thank you for signing up!').should('be.visible'); + + cy.contains('Verify your email now'); setProperty('recaptcha.siteKey', recaptchaSiteKey); }); }); @@ -116,9 +116,10 @@ context('Sign up', () => { }).as('signUp'); fillAndSubmitSignUpForm(TEST_USERNAME); cy.wait(['@signUp']); - cy.contains( - 'Thank you for signing up. To verify your email please follow instructions sent to provided email address.' - ).should('be.visible'); + cy.contains('Thank you for signing up!').should('be.visible'); + + cy.contains('Verify your email now'); + getUser(TEST_USERNAME).then((u) => { expect(u[0]).be.equal(TEST_USERNAME); expect(u[1]).be.not.null; @@ -133,6 +134,32 @@ context('Sign up', () => { checkAnonymousUserIdentified(); }); + it('Signs up and resend email verification', () => { + fillAndSubmitSignUpForm(TEST_USERNAME); + cy.contains('Thank you for signing up!').should('be.visible'); + + cy.contains('Verify your email now'); + + gcy('resend-email-button').click(); + cy.contains('Your verification link has been resent.'); + + // Emails 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); + cy.visit(r.verifyEmailLink); + assertMessage('Validation code or link is invalid'); + }); + + getParsedEmailVerificationByIndex(0).then((r) => { + cy.wrap(r.fromAddress).should('contain', 'no-reply@tolgee.io'); + cy.wrap(r.toAddress).should('contain', TEST_USERNAME); + cy.visit(r.verifyEmailLink); + assertMessage('Email was verified'); + }); + cy.contains('Projects').should('be.visible'); + }); + it('Signs up without email verification', () => { disableEmailVerification(); fillAndSubmitSignUpForm(TEST_USERNAME); diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index ed4abd714c..6bdbeb653c 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -435,6 +435,7 @@ declare namespace DataCy { "quick-start-step" | "regenerate-pat-dialog-content" | "regenerate-pat-dialog-title" | + "resend-email-button" | "screenshot-image" | "screenshot-thumbnail" | "screenshot-thumbnail-delete" | diff --git a/webapp/.env.development b/webapp/.env.development index e1a7ca8363..ec8a1f535f 100644 --- a/webapp/.env.development +++ b/webapp/.env.development @@ -1 +1 @@ -VITE_APP_API_URL=http://localhost:8080 +VITE_APP_API_URL=http://localhost:8080 \ No newline at end of file diff --git a/webapp/public/images/emailVerification.svg b/webapp/public/images/emailVerification.svg new file mode 100644 index 0000000000..278c50123f --- /dev/null +++ b/webapp/public/images/emailVerification.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/webapp/public/images/emailVerificationDark.svg b/webapp/public/images/emailVerificationDark.svg new file mode 100644 index 0000000000..529710eb03 --- /dev/null +++ b/webapp/public/images/emailVerificationDark.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/webapp/public/images/mailDark.svg b/webapp/public/images/mailDark.svg new file mode 100644 index 0000000000..60018c1fa8 --- /dev/null +++ b/webapp/public/images/mailDark.svg @@ -0,0 +1,3 @@ + + + diff --git a/webapp/public/images/mailLight.svg b/webapp/public/images/mailLight.svg new file mode 100644 index 0000000000..d4535d1254 --- /dev/null +++ b/webapp/public/images/mailLight.svg @@ -0,0 +1,3 @@ + + + diff --git a/webapp/src/.DS_Store b/webapp/src/.DS_Store index 783f3dc3ca..88a406cd71 100644 Binary files a/webapp/src/.DS_Store and b/webapp/src/.DS_Store differ diff --git a/webapp/src/RequirePreferredOrganization.tsx b/webapp/src/RequirePreferredOrganization.tsx index a3cac42625..772c470223 100644 --- a/webapp/src/RequirePreferredOrganization.tsx +++ b/webapp/src/RequirePreferredOrganization.tsx @@ -1,5 +1,8 @@ import { FC } from 'react'; -import { usePreferredOrganization } from 'tg.globalContext/helpers'; +import { + useIsEmailVerified, + usePreferredOrganization, +} from 'tg.globalContext/helpers'; import { DashboardPage } from 'tg.component/layout/DashboardPage'; import { CompactView } from 'tg.component/layout/CompactView'; import { T, useTranslate } from '@tolgee/react'; @@ -12,6 +15,10 @@ export const RequirePreferredOrganization: FC = (props) => { const { preferredOrganization, isFetching } = usePreferredOrganization(); + const isEmailVerified = useIsEmailVerified(); + if (!isEmailVerified) { + return <>{props.children}; + } if (allowPrivate && !preferredOrganization && isFetching) { return null; } diff --git a/webapp/src/component/EmailNotVerifiedView.tsx b/webapp/src/component/EmailNotVerifiedView.tsx new file mode 100644 index 0000000000..ab3f069ba0 --- /dev/null +++ b/webapp/src/component/EmailNotVerifiedView.tsx @@ -0,0 +1,117 @@ +import { Button, 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'; +import { useEmailAwaitingVerification } from 'tg.globalContext/helpers'; +import { LINKS } from 'tg.constants/links'; +import { Usage } from 'tg.component/billing/Usage'; +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'; + +const StyledContainer = styled('div')` + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + margin-top: 5vh; +`; + +const StyledHeader = styled(Typography)` + color: ${({ theme }) => theme.palette.text.primary} + font-size: 24px; + font-style: normal; + font-weight: 500; + text-align: center; + margin-bottom: 20px; +`; + +const StyledDescription = styled(Typography)` + color: ${({ theme }) => theme.palette.text.primary} + margin-bottom: 40px; +`; + +const StyledHint = styled(Typography)` + margin-bottom: 20px; + font-weight: bold; +`; + +const StyledImg = styled('img')` + margin-top: 20px; + margin-bottom: 30px; +`; + +const StyledEnabled = styled('span')` + font-weight: 500; +`; + +export const EmailNotVerifiedView = () => { + const theme = useTheme(); + const imageSrc = + theme.palette.mode === 'dark' + ? '/images/emailVerificationDark.svg' + : '/images/emailVerification.svg'; + + const email = useEmailAwaitingVerification(); + + if (email == undefined) { + return ; + } + + const { t } = useTranslate(); + + const resendEmail = useApiMutation({ + url: '/v2/user/send-email-verification', + method: 'post', + }); + + return ( + + + } + > + + + + + + }} + /> + + + + + + + + + + + ); +}; diff --git a/webapp/src/component/RootRouter.tsx b/webapp/src/component/RootRouter.tsx index b763ea2065..3cbe673c60 100644 --- a/webapp/src/component/RootRouter.tsx +++ b/webapp/src/component/RootRouter.tsx @@ -15,6 +15,7 @@ import { RequirePreferredOrganization } from '../RequirePreferredOrganization'; import { HelpMenu } from './HelpMenu'; import { PublicOnlyRoute } from './common/PublicOnlyRoute'; import { PreferredOrganizationRedirect } from './security/PreferredOrganizationRedirect'; +import { RootView } from 'tg.views/RootView'; const LoginRouter = React.lazy( () => import(/* webpackChunkName: "login" */ './security/Login/LoginRouter') @@ -119,7 +120,10 @@ export const RootRouter = () => ( - + + + + diff --git a/webapp/src/component/layout/DashboardPage.tsx b/webapp/src/component/layout/DashboardPage.tsx index dd7bcaf396..1e7b5546eb 100644 --- a/webapp/src/component/layout/DashboardPage.tsx +++ b/webapp/src/component/layout/DashboardPage.tsx @@ -10,6 +10,7 @@ import { } from 'tg.globalContext/GlobalContext'; import { RightSidePanel } from './RightSidePanel'; import { QuickStartGuide } from './QuickStartGuide/QuickStartGuide'; +import { useIsEmailVerified } from 'tg.globalContext/helpers'; const StyledMain = styled(Box)` display: flex; @@ -54,6 +55,8 @@ export const DashboardPage: FunctionComponent = ({ const rightPanelWidth = useGlobalContext((c) => c.layout.rightPanelWidth); + const isEmailVerified = useIsEmailVerified(); + const { setQuickStartOpen } = useGlobalActions(); const quickStartEnabled = useGlobalContext( (c) => c.quickStartGuide.enabled && c.initialData.userInfo @@ -91,15 +94,17 @@ export const DashboardPage: FunctionComponent = ({ {children} - {quickStartEnabled && (quickStartOpen || quickStartFloating) && ( - setQuickStartOpen(false)} - floating={quickStartFloating} - > - - - )} + {quickStartEnabled && + (quickStartOpen || quickStartFloating) && + isEmailVerified && ( + setQuickStartOpen(false)} + floating={quickStartFloating} + > + + + )} ); diff --git a/webapp/src/component/layout/TopBanner/Announcement.tsx b/webapp/src/component/layout/TopBanner/Announcement.tsx index 547418c0cd..3690b5d4aa 100644 --- a/webapp/src/component/layout/TopBanner/Announcement.tsx +++ b/webapp/src/component/layout/TopBanner/Announcement.tsx @@ -6,6 +6,8 @@ import { BannerLink } from './BannerLink'; type Props = { content: React.ReactNode; link?: string; + icon?: React.ReactNode; + title?: string; }; const StyledContent = styled('div')` @@ -14,6 +16,14 @@ const StyledContent = styled('div')` align-items: center; `; +const StyledTitle = styled('div')` + display: flex; + gap: 12px; + align-items: center; + color: ${({ theme }) => + theme.palette.tokens._components.noticeBar.importantColor}; +`; + const StyledWrappableContent = styled('div')` display: flex; gap: 12px; @@ -21,12 +31,13 @@ const StyledWrappableContent = styled('div')` flex-wrap: wrap; `; -export const Announcement = ({ content, link }: Props) => { +export const Announcement = ({ content, link, icon, title }: Props) => { const { t } = useTranslate(); return ( - + {icon ? icon : } + {title && {title}}
{content}
{link && ( diff --git a/webapp/src/component/layout/TopBanner/TopBanner.tsx b/webapp/src/component/layout/TopBanner/TopBanner.tsx index 2b707694da..523af8f8cc 100644 --- a/webapp/src/component/layout/TopBanner/TopBanner.tsx +++ b/webapp/src/component/layout/TopBanner/TopBanner.tsx @@ -1,13 +1,16 @@ -import { styled } from '@mui/material'; +import { styled, useTheme } from '@mui/material'; import { useEffect, useRef } from 'react'; import { useGlobalActions, useGlobalContext, } from 'tg.globalContext/GlobalContext'; import { useAnnouncement } from './useAnnouncement'; - +import { useIsEmailVerified } from 'tg.globalContext/helpers'; import { Close } from '@mui/icons-material'; import { useResizeObserver } from 'usehooks-ts'; +import { Announcement } from 'tg.component/layout/TopBanner/Announcement'; +import { useTranslate } from '@tolgee/react'; +import { tokenService } from 'tg.service/TokenService'; const StyledContainer = styled('div')` position: fixed; @@ -17,9 +20,18 @@ const StyledContainer = styled('div')` display: grid; grid-template-columns: 50px 1fr 50px; width: 100%; - background: ${({ theme }) => theme.palette.topBanner.background}; z-index: ${({ theme }) => theme.zIndex.drawer + 2}; - color: ${({ theme }) => theme.palette.topBanner.mainText}; + &.email-verified { + color: ${(props) => props.theme.palette.topBanner.mainText}; + background: ${(props) => props.theme.palette.topBanner.background}; + } + + &.email-not-verified { + color: ${(props) => + props.theme.palette.tokens._components.noticeBar.importantLink}; + background: ${(props) => + props.theme.palette.tokens._components.noticeBar.importantFill}; + } font-size: 15px; font-weight: 700; @container (max-width: 899px) { @@ -47,9 +59,21 @@ export function TopBanner() { const bannerType = useGlobalContext((c) => c.initialData.announcement?.type); const { setTopBannerHeight, dismissAnnouncement } = useGlobalActions(); const bannerRef = useRef(null); + const isAuthenticated = tokenService.getToken() !== undefined; const getAnnouncement = useAnnouncement(); + const isEmailVerified = useIsEmailVerified(); const announcement = bannerType && getAnnouncement(bannerType); + const showCloseButton = isEmailVerified; + const containerClassName = isEmailVerified + ? 'email-verified' + : 'email-not-verified'; + const theme = useTheme(); + const mailImage = + theme.palette.mode === 'dark' + ? '/images/mailDark.svg' + : '/images/mailLight.svg'; + const { t } = useTranslate(); useResizeObserver({ ref: bannerRef, @@ -61,24 +85,40 @@ export function TopBanner() { useEffect(() => { const height = bannerRef.current?.offsetHeight; setTopBannerHeight(height ?? 0); - }, [announcement]); + }, [announcement, isEmailVerified]); - if (!announcement) { + if (!announcement && (isEmailVerified || !isAuthenticated)) { return null; } return ( - +
- {announcement} - dismissAnnouncement()} - data-cy="top-banner-dismiss-button" - > - - + + {!isEmailVerified ? ( + } + /> + ) : ( + announcement + )} + + {showCloseButton && ( + dismissAnnouncement()} + data-cy="top-banner-dismiss-button" + > + + + )} ); } diff --git a/webapp/src/component/layout/TopBanner/useAnnouncement.tsx b/webapp/src/component/layout/TopBanner/useAnnouncement.tsx index cb36bd4106..4f40a5b34f 100644 --- a/webapp/src/component/layout/TopBanner/useAnnouncement.tsx +++ b/webapp/src/component/layout/TopBanner/useAnnouncement.tsx @@ -8,7 +8,6 @@ type AnnouncementDtoType = components['schemas']['AnnouncementDto']['type']; export function useAnnouncement() { const { t } = useTranslate(); - return function AnnouncementWrapper(value: AnnouncementDtoType) { switch (value) { case 'FEATURE_BATCH_OPERATIONS': diff --git a/webapp/src/component/layout/TopBar/TopBar.tsx b/webapp/src/component/layout/TopBar/TopBar.tsx index 6aa688927a..7742d16ec5 100644 --- a/webapp/src/component/layout/TopBar/TopBar.tsx +++ b/webapp/src/component/layout/TopBar/TopBar.tsx @@ -5,7 +5,11 @@ import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import { useGlobalContext } from 'tg.globalContext/GlobalContext'; -import { useConfig, useUser } from 'tg.globalContext/helpers'; +import { + useConfig, + useIsEmailVerified, + useUser, +} from 'tg.globalContext/helpers'; import { TolgeeLogo } from 'tg.component/common/icons/TolgeeLogo'; import { UserMenu } from '../../security/UserMenu/UserMenu'; @@ -77,6 +81,7 @@ export const TopBar: React.FC = ({ const user = useUser(); const theme = useTheme(); + const isEmailVerified = useIsEmailVerified(); return ( = ({ debuggingCustomerAccount={isDebuggingCustomerAccount} /> - + {isEmailVerified && } {!user && } {user && } diff --git a/webapp/src/component/security/Login/EmailVerificationHandler.tsx b/webapp/src/component/security/Login/EmailVerificationHandler.tsx index 3d803ca19f..e91cb0597a 100644 --- a/webapp/src/component/security/Login/EmailVerificationHandler.tsx +++ b/webapp/src/component/security/Login/EmailVerificationHandler.tsx @@ -16,7 +16,7 @@ export const EmailVerificationHandler: FunctionComponent< > = () => { const match = useRouteMatch(); const history = useHistory(); - const { handleAfterLogin } = useGlobalActions(); + const { handleAfterLogin, refetchInitialData } = useGlobalActions(); useApiQuery({ url: '/api/public/verify_email/{userId}/{code}', @@ -28,6 +28,7 @@ export const EmailVerificationHandler: FunctionComponent< options: { onSuccess(data) { messageService.success(); + refetchInitialData(); handleAfterLogin(data); }, onSettled() { diff --git a/webapp/src/component/security/UserMenu/UserMenu.tsx b/webapp/src/component/security/UserMenu/UserMenu.tsx index 3b527d4871..e31b04a3a6 100644 --- a/webapp/src/component/security/UserMenu/UserMenu.tsx +++ b/webapp/src/component/security/UserMenu/UserMenu.tsx @@ -1,12 +1,22 @@ import React from 'react'; -import { useConfig, useUser } from 'tg.globalContext/helpers'; +import { + useConfig, + useIsEmailVerified, + useUser, +} from 'tg.globalContext/helpers'; import { UserMissingMenu } from './UserMissingMenu'; import { UserPresentMenu } from './UserPresentMenu'; +import { UserUnverifiedEmailMenu } from './UserUnverifiedEmailMenu'; export const UserMenu: React.FC = () => { const config = useConfig(); const user = useUser(); + const isEmailVerified = useIsEmailVerified(); + + if (!isEmailVerified) { + return ; + } if (!config.authentication || !user) { return ; diff --git a/webapp/src/component/security/UserMenu/UserUnverifiedEmailMenu.tsx b/webapp/src/component/security/UserMenu/UserUnverifiedEmailMenu.tsx new file mode 100644 index 0000000000..ced8fa995f --- /dev/null +++ b/webapp/src/component/security/UserMenu/UserUnverifiedEmailMenu.tsx @@ -0,0 +1,97 @@ +import React, { useState } from 'react'; +import { IconButton, MenuItem, Popover, styled } from '@mui/material'; +import { Link } from 'react-router-dom'; + +import { useUserMenuItems } from 'tg.hooks/useUserMenuItems'; +import { UserAvatar } from 'tg.component/common/avatar/UserAvatar'; + +import { ThemeItem } from './ThemeItem'; +import { LanguageItem } from './LanguageItem'; +import { T } from '@tolgee/react'; +import { useGlobalActions } from 'tg.globalContext/GlobalContext'; + +const StyledIconButton = styled(IconButton)` + width: 40px; + height: 40px; +`; + +const StyledPopover = styled(Popover)` + & .paper { + margin-top: 5px; + padding: 2px 0px; + } +`; + +const StyledDivider = styled('div')` + height: 1px; + background: ${({ theme }) => + theme.palette.mode === 'light' + ? theme.palette.divider1 + : theme.palette.emphasis[400]}; +`; + +export const UserUnverifiedEmailMenu = () => { + const [anchorEl, setAnchorEl] = useState(null); + const userMenuItems = useUserMenuItems(); + const { logout } = useGlobalActions(); + + const handleOpen = (event: React.MouseEvent) => { + //@ts-ignore + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + return ( +
+ + + + + {userMenuItems.map((item, index) => ( + + {item.label} + + ))} + + + + + logout()} data-cy="user-menu-logout"> + + + +
+ ); +}; diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index cf84eecadf..603c77e589 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -64,10 +64,11 @@ export enum PARAMS { } export class LINKS { + static ROOT = Link.ofRoot(''); + /** * Authentication */ - static ROOT = Link.ofRoot(''); static LOGIN = Link.ofRoot('login'); @@ -279,7 +280,7 @@ export class LINKS { * Visible with view permissions */ - static AFTER_LOGIN = LINKS.PROJECTS; + static AFTER_LOGIN = LINKS.ROOT; static PROJECT = Link.ofParent(LINKS.PROJECTS, p(PARAMS.PROJECT_ID)); diff --git a/webapp/src/custom.d.ts b/webapp/src/custom.d.ts index ebe8596529..9d303ab81f 100644 --- a/webapp/src/custom.d.ts +++ b/webapp/src/custom.d.ts @@ -2,23 +2,23 @@ import API from '@openreplay/tracker'; import { PaletteColor } from '@mui/material/styles'; import { PaletteColorOptions } from '@mui/material'; import { - Tooltip, Activity, Cell, Editor, Emphasis, ExampleBanner, + Input, + LanguageChips, + Login, Marker, Navbar, Placeholders, QuickStart, + RevisionFilterBanner, Tile, TipsBanner, + Tooltip, TopBanner, - LanguageChips, - Login, - Input, - RevisionFilterBanner, } from './colors'; import { tolgeeColors, tolgeePalette } from 'figmaTheme'; @@ -48,6 +48,7 @@ declare module '@mui/material/styles/createPalette' { globalLoading: PaletteColor; marker: Marker; topBanner: TopBanner; + emailNotVerifiedBanner: EmailNotVerifiedBanner; quickStart: QuickStart; import: typeof all.import; exampleBanner: ExampleBanner; diff --git a/webapp/src/globalContext/GlobalContext.tsx b/webapp/src/globalContext/GlobalContext.tsx index e860fd9d51..7c7a40e01e 100644 --- a/webapp/src/globalContext/GlobalContext.tsx +++ b/webapp/src/globalContext/GlobalContext.tsx @@ -22,10 +22,14 @@ export const [GlobalContext, useGlobalActions, useGlobalContext] = const quickStart = useQuickStartGuideService(initialData); const wsClient = useWebsocketService(auth.state.jwtToken); - + const isVerified = + initialData.state?.userInfo?.emailAwaitingVerification === null || + !initialData.state?.serverConfiguration.needsEmailVerification; const organizationUsage = useOrganizationUsageService({ organization: initialData.state?.preferredOrganization, - enabled: Boolean(initialData.state?.serverConfiguration?.billing.enabled), + enabled: + Boolean(initialData.state?.serverConfiguration?.billing.enabled) && + isVerified, }); const layout = useLayoutService(); diff --git a/webapp/src/globalContext/helpers.tsx b/webapp/src/globalContext/helpers.tsx index 7033fcb7de..e3125d1c93 100644 --- a/webapp/src/globalContext/helpers.tsx +++ b/webapp/src/globalContext/helpers.tsx @@ -12,6 +12,16 @@ export const useConfig = () => export const useUser = () => useGlobalContext((c) => c.initialData.userInfo); +export const useIsEmailVerified = () => + useGlobalContext( + (c) => + c.initialData.userInfo?.emailAwaitingVerification === null || + !c.initialData.serverConfiguration.needsEmailVerification + ); + +export const useEmailAwaitingVerification = () => + useGlobalContext((c) => c.initialData.userInfo?.emailAwaitingVerification); + export const useIsAdmin = () => useGlobalContext((c) => c.initialData.userInfo?.globalServerRole === 'ADMIN'); diff --git a/webapp/src/globalContext/useQuickStartGuideService.tsx b/webapp/src/globalContext/useQuickStartGuideService.tsx index cc09f0ecd7..b7d874c15b 100644 --- a/webapp/src/globalContext/useQuickStartGuideService.tsx +++ b/webapp/src/globalContext/useQuickStartGuideService.tsx @@ -21,7 +21,9 @@ export const useQuickStartGuideService = ( const projectIdParam = match?.params[PARAMS.PROJECT_ID]; const projectId = isNaN(projectIdParam) ? undefined : projectIdParam; const [floatingOpen, setFloatingOpen] = useState(false); - + const isVerified = + initialData.state?.userInfo?.emailAwaitingVerification === null || + !initialData.state?.serverConfiguration.needsEmailVerification; const organizationSlug = initialData.state?.preferredOrganization?.slug; const isOwner = initialData.state?.preferredOrganization?.currentUserRole === 'OWNER'; @@ -32,7 +34,8 @@ export const useQuickStartGuideService = ( path: { slug: organizationSlug! }, query: { size: 1, sort: ['id,desc'] }, options: { - enabled: projectId === undefined && Boolean(organizationSlug), + enabled: + projectId === undefined && Boolean(organizationSlug) && isVerified, }, }); diff --git a/webapp/src/hooks/useUserMenuItems.tsx b/webapp/src/hooks/useUserMenuItems.tsx index db269ab4c9..e03e34f479 100644 --- a/webapp/src/hooks/useUserMenuItems.tsx +++ b/webapp/src/hooks/useUserMenuItems.tsx @@ -2,7 +2,11 @@ import { useTranslate } from '@tolgee/react'; import { useLocation } from 'react-router-dom'; import { LINKS } from '../constants/links'; -import { useConfig, useUser } from 'tg.globalContext/helpers'; +import { + useConfig, + useIsEmailVerified, + useUser, +} from 'tg.globalContext/helpers'; export class UserMenuItem { constructor( @@ -18,6 +22,7 @@ export const useUserMenuItems = (): UserMenuItem[] => { const config = useConfig(); const user = useUser(); + const isEmailVerified = useIsEmailVerified(); const userSettings = !config.authentication || !user @@ -29,6 +34,12 @@ export const useUserMenuItems = (): UserMenuItem[] => { }, ]; + if (!isEmailVerified) { + return [...userSettings].map((i) => { + return new UserMenuItem(i.link, i.label, location.pathname === i.link); + }); + } + return [ ...userSettings, { diff --git a/webapp/src/i18n/en.json b/webapp/src/i18n/en.json index 999ee70b18..ae6cb3e604 100644 --- a/webapp/src/i18n/en.json +++ b/webapp/src/i18n/en.json @@ -1407,5 +1407,5 @@ "webhook_test_success" : "Test request sent to the webhook successfully", "webhook_update_title" : "Edit webhook", "wrong_current_password" : "Wrong current password entered", - "your_email_was_changed_verification_message" : "When you change your email, new email will be set after its verification." + "your_email_was_changed_verification_message" : "When you change your email, new email will be set after its verification.", } \ No newline at end of file diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index fe01f97f5c..0ad67c7fbc 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -300,6 +300,10 @@ export interface paths { /** Set's the global role on the Tolgee Platform server. */ put: operations["setRole"]; }; + "/v2/user/send-email-verification": { + /** Resends email verification email to currently authenticated user. */ + post: operations["sendEmailVerification"]; + }; "/v2/user/generate-super-token": { /** Generates new JWT token permitted to sensitive operations */ post: operations["getSuperToken"]; @@ -858,6 +862,7 @@ export interface components { | "third_party_auth_no_email" | "third_party_auth_no_sub" | "third_party_auth_unknown_error" + | "email_already_verified" | "third_party_unauthorized" | "third_party_google_workspace_mismatch" | "username_already_exists" @@ -2658,6 +2663,7 @@ export interface components { | "third_party_auth_no_email" | "third_party_auth_no_sub" | "third_party_auth_unknown_error" + | "email_already_verified" | "third_party_unauthorized" | "third_party_google_workspace_mismatch" | "username_already_exists" @@ -9388,6 +9394,45 @@ export interface operations { }; }; }; + /** Resends email verification email to currently authenticated user. */ + sendEmailVerification: { + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; /** Generates new JWT token permitted to sensitive operations */ getSuperToken: { responses: { diff --git a/webapp/src/service/http/handleApiError.tsx b/webapp/src/service/http/handleApiError.tsx index bd8c3411b9..c5c8ffa0cb 100644 --- a/webapp/src/service/http/handleApiError.tsx +++ b/webapp/src/service/http/handleApiError.tsx @@ -29,6 +29,11 @@ export const handleApiError = ( return; } if (r.status == 403) { + if (resObject?.code === 'email_not_verified') { + globalContext.actions?.redirectTo(LINKS.ROOT.build()); + return; + } + if (init?.method === undefined || init?.method === 'get') { globalContext.actions?.redirectTo(LINKS.AFTER_LOGIN.build()); } @@ -51,6 +56,12 @@ export const handleApiError = ( messageService.error(); return; } + + if (r.status == 429) { + messageService.error(); + return; + } + if (r.status == 400 && !options.disableErrorNotification) { const parsed = parseErrorResponse(resObject); parsed.forEach((message) => diff --git a/webapp/src/translationTools/useErrorTranslation.ts b/webapp/src/translationTools/useErrorTranslation.ts index 7d05006cd9..3f0f3b5487 100644 --- a/webapp/src/translationTools/useErrorTranslation.ts +++ b/webapp/src/translationTools/useErrorTranslation.ts @@ -123,6 +123,10 @@ export function useErrorTranslation() { return t('slack_not_configured'); case 'slack_workspace_already_connected': return t('slack_workspace_already_connected'); + case 'email_already_verified': + return t('verify_email_already_verified'); + case 'email_verification_code_not_valid': + return t('verify_email_verification_code_not_valid'); default: return code; } diff --git a/webapp/src/views/RootView.tsx b/webapp/src/views/RootView.tsx new file mode 100644 index 0000000000..2984db5dad --- /dev/null +++ b/webapp/src/views/RootView.tsx @@ -0,0 +1,9 @@ +import { EmailNotVerifiedView } from 'tg.component/EmailNotVerifiedView'; +import { useIsEmailVerified } from 'tg.globalContext/helpers'; +import { ProjectListView } from 'tg.views/projects/ProjectListView'; + +export const RootView = () => { + const isEmailVerified = useIsEmailVerified(); + + return isEmailVerified ? : ; +}; diff --git a/webapp/src/views/projects/ProjectsRouter.tsx b/webapp/src/views/projects/ProjectsRouter.tsx index 9a0e4a9085..4e7127c648 100644 --- a/webapp/src/views/projects/ProjectsRouter.tsx +++ b/webapp/src/views/projects/ProjectsRouter.tsx @@ -2,10 +2,10 @@ import { Switch, useRouteMatch } from 'react-router-dom'; import { PrivateRoute } from 'tg.component/common/PrivateRoute'; import { LINKS } from 'tg.constants/links'; - -import { ProjectListView } from './ProjectListView'; import { ProjectRouter } from './ProjectRouter'; import { ProjectCreateView } from './project/ProjectCreateView'; +import React from 'react'; +import { RootView } from 'tg.views/RootView'; export const ProjectsRouter = () => { const match = useRouteMatch(); @@ -13,7 +13,7 @@ export const ProjectsRouter = () => { return ( - + diff --git a/webapp/src/views/userSettings/BaseUserSettingsView.tsx b/webapp/src/views/userSettings/BaseUserSettingsView.tsx index 013ba7aa00..a482158feb 100644 --- a/webapp/src/views/userSettings/BaseUserSettingsView.tsx +++ b/webapp/src/views/userSettings/BaseUserSettingsView.tsx @@ -4,7 +4,7 @@ import { LINKS } from 'tg.constants/links'; import { useTranslate } from '@tolgee/react'; import { BaseSettingsView } from 'tg.component/layout/BaseSettingsView/BaseSettingsView'; import { SettingsMenuItem } from 'tg.component/layout/BaseSettingsView/SettingsMenu'; -import { useConfig } from 'tg.globalContext/helpers'; +import { useConfig, useIsEmailVerified } from 'tg.globalContext/helpers'; type Props = BaseViewProps; @@ -15,6 +15,8 @@ export const BaseUserSettingsView: React.FC = ({ }) => { const { t } = useTranslate(); const { authentication } = useConfig(); + const isEmailVerified = useIsEmailVerified(); + const menuItems: SettingsMenuItem[] = authentication ? [ { @@ -28,15 +30,17 @@ export const BaseUserSettingsView: React.FC = ({ ] : []; - menuItems.push({ - link: LINKS.USER_API_KEYS.build(), - label: t('user_menu_api_keys'), - }); + if (isEmailVerified) { + menuItems.push({ + link: LINKS.USER_API_KEYS.build(), + label: t('user_menu_api_keys'), + }); - menuItems.push({ - link: LINKS.USER_PATS.build(), - label: t('user_menu_pats'), - }); + menuItems.push({ + link: LINKS.USER_PATS.build(), + label: t('user_menu_pats'), + }); + } return (