From 8cde2c55cea0342cc4f5e66aed499ad04f0c1e74 Mon Sep 17 00:00:00 2001 From: Jan Cizmar Date: Fri, 22 Dec 2023 13:56:01 +0100 Subject: [PATCH] fix: Onboarding & other UI fixes, security improvements (#2036) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Štěpán Granát --- .../third_party/GithubOAuthDelegate.kt | 23 ++--- .../third_party/GoogleOAuthDelegate.kt | 21 +---- .../security/third_party/OAuth2Delegate.kt | 22 ++--- .../InitialUserCreatorCommandLineRunner.kt | 26 +++++- .../demoProject/DemoProjectCreator.kt | 2 +- .../io/tolgee/development/DbPopulatorReal.kt | 18 ++-- .../io/tolgee/service/InvitationService.kt | 5 +- .../tolgee/service/security/SignUpService.kt | 54 +++++++---- .../service/security/UserAccountService.kt | 49 ++-------- .../main/resources/demoProject/demoAvatar.png | Bin 36315 -> 6740 bytes .../e2e_data/ProjectsE2eDataController.kt | 6 +- e2e/cypress/common/login.ts | 3 +- e2e/cypress/e2e/security/login.cy.ts | 3 +- .../e2e/userSettings/accountSecurity.cy.ts | 4 +- .../kotlin/io/tolgee/ee/UsageReportingTest.kt | 28 +++--- webapp/package-lock.json | 26 +++++- webapp/package.json | 4 +- .../src/component/common/avatar/AvatarImg.tsx | 6 +- webapp/src/component/layout/CompactView.tsx | 1 + .../QuickStartGuide/QuickStartGuide.tsx | 1 - .../security/PasswordFieldWithValidation.tsx | 84 ++++++++++++++++++ .../security/ResetPasswordSetView.tsx | 16 ++-- .../component/security/ResetPasswordView.tsx | 1 + .../component/security/SetPasswordField.tsx | 9 ++ .../component/security/SetPasswordFields.tsx | 27 ------ .../component/security/SignUp/SignUpForm.tsx | 11 ++- .../component/security/SignUp/SignUpView.tsx | 3 - .../src/constants/GlobalValidationSchema.tsx | 33 +++---- .../accountSecurity/ChangePassword.tsx | 18 ++-- 29 files changed, 288 insertions(+), 216 deletions(-) create mode 100644 webapp/src/component/security/PasswordFieldWithValidation.tsx create mode 100644 webapp/src/component/security/SetPasswordField.tsx delete mode 100644 webapp/src/component/security/SetPasswordFields.tsx diff --git a/backend/api/src/main/kotlin/io/tolgee/security/third_party/GithubOAuthDelegate.kt b/backend/api/src/main/kotlin/io/tolgee/security/third_party/GithubOAuthDelegate.kt index c202548b16..37dba03450 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/third_party/GithubOAuthDelegate.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/third_party/GithubOAuthDelegate.kt @@ -4,11 +4,10 @@ import io.tolgee.configuration.tolgee.GithubAuthenticationProperties import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.constants.Message import io.tolgee.exceptions.AuthenticationException -import io.tolgee.model.Invitation import io.tolgee.model.UserAccount import io.tolgee.security.authentication.JwtService import io.tolgee.security.payload.JwtAuthenticationResponse -import io.tolgee.service.InvitationService +import io.tolgee.service.security.SignUpService import io.tolgee.service.security.UserAccountService import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders @@ -24,8 +23,8 @@ class GithubOAuthDelegate( private val jwtService: JwtService, private val userAccountService: UserAccountService, private val restTemplate: RestTemplate, - private val properties: TolgeeProperties, - private val invitationService: InvitationService + properties: TolgeeProperties, + private val signUpService: SignUpService ) { private val githubConfigurationProperties: GithubAuthenticationProperties = properties.authentication.github @@ -72,25 +71,15 @@ class GithubOAuthDelegate( throw AuthenticationException(Message.USERNAME_ALREADY_EXISTS) } - var invitation: Invitation? = null - if (invitationCode == null) { - if (!properties.authentication.registrationsAllowed) { - throw AuthenticationException(Message.REGISTRATIONS_NOT_ALLOWED) - } - } else { - invitation = invitationService.getInvitation(invitationCode) - } - val newUserAccount = UserAccount() newUserAccount.username = githubEmail newUserAccount.name = userResponse.name ?: userResponse.login newUserAccount.thirdPartyAuthId = userResponse.id newUserAccount.thirdPartyAuthType = "github" newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY - userAccountService.createUser(newUserAccount) - if (invitation != null) { - invitationService.accept(invitation.code, newUserAccount) - } + + signUpService.signUp(newUserAccount, invitationCode, null) + newUserAccount } val jwt = jwtService.emitToken(user.id) diff --git a/backend/api/src/main/kotlin/io/tolgee/security/third_party/GoogleOAuthDelegate.kt b/backend/api/src/main/kotlin/io/tolgee/security/third_party/GoogleOAuthDelegate.kt index b5aedcca0c..290d2cd381 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/third_party/GoogleOAuthDelegate.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/third_party/GoogleOAuthDelegate.kt @@ -4,11 +4,10 @@ import io.tolgee.configuration.tolgee.GoogleAuthenticationProperties import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.constants.Message import io.tolgee.exceptions.AuthenticationException -import io.tolgee.model.Invitation import io.tolgee.model.UserAccount import io.tolgee.security.authentication.JwtService import io.tolgee.security.payload.JwtAuthenticationResponse -import io.tolgee.service.InvitationService +import io.tolgee.service.security.SignUpService import io.tolgee.service.security.UserAccountService import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders @@ -23,8 +22,8 @@ class GoogleOAuthDelegate( private val jwtService: JwtService, private val userAccountService: UserAccountService, private val restTemplate: RestTemplate, - private val properties: TolgeeProperties, - private val invitationService: InvitationService + properties: TolgeeProperties, + private val signUpService: SignUpService ) { private val googleConfigurationProperties: GoogleAuthenticationProperties = properties.authentication.google @@ -78,15 +77,6 @@ class GoogleOAuthDelegate( throw AuthenticationException(Message.USERNAME_ALREADY_EXISTS) } - var invitation: Invitation? = null - if (invitationCode == null) { - if (!properties.authentication.registrationsAllowed) { - throw AuthenticationException(Message.REGISTRATIONS_NOT_ALLOWED) - } - } else { - invitation = invitationService.getInvitation(invitationCode) - } - val newUserAccount = UserAccount() newUserAccount.username = userResponse.email ?: throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL) @@ -94,10 +84,7 @@ class GoogleOAuthDelegate( newUserAccount.thirdPartyAuthId = userResponse.sub newUserAccount.thirdPartyAuthType = "google" newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY - userAccountService.createUser(newUserAccount) - if (invitation != null) { - invitationService.accept(invitation.code, newUserAccount) - } + signUpService.signUp(newUserAccount, invitationCode, null) newUserAccount } diff --git a/backend/api/src/main/kotlin/io/tolgee/security/third_party/OAuth2Delegate.kt b/backend/api/src/main/kotlin/io/tolgee/security/third_party/OAuth2Delegate.kt index 659c7544ca..0d352cf008 100644 --- a/backend/api/src/main/kotlin/io/tolgee/security/third_party/OAuth2Delegate.kt +++ b/backend/api/src/main/kotlin/io/tolgee/security/third_party/OAuth2Delegate.kt @@ -4,11 +4,10 @@ import io.tolgee.configuration.tolgee.OAuth2AuthenticationProperties import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.constants.Message import io.tolgee.exceptions.AuthenticationException -import io.tolgee.model.Invitation import io.tolgee.model.UserAccount import io.tolgee.security.authentication.JwtService import io.tolgee.security.payload.JwtAuthenticationResponse -import io.tolgee.service.InvitationService +import io.tolgee.service.security.SignUpService import io.tolgee.service.security.UserAccountService import org.slf4j.LoggerFactory import org.springframework.http.HttpEntity @@ -27,8 +26,8 @@ class OAuth2Delegate( private val jwtService: JwtService, private val userAccountService: UserAccountService, private val restTemplate: RestTemplate, - private val properties: TolgeeProperties, - private val invitationService: InvitationService + properties: TolgeeProperties, + private val signUpService: SignUpService ) { private val oauth2ConfigurationProperties: OAuth2AuthenticationProperties = properties.authentication.oauth2 private val logger = LoggerFactory.getLogger(this::class.java) @@ -96,15 +95,6 @@ class OAuth2Delegate( throw AuthenticationException(Message.USERNAME_ALREADY_EXISTS) } - var invitation: Invitation? = null - if (invitationCode == null) { - if (!properties.authentication.registrationsAllowed) { - throw AuthenticationException(Message.REGISTRATIONS_NOT_ALLOWED) - } - } else { - invitation = invitationService.getInvitation(invitationCode) - } - val newUserAccount = UserAccount() newUserAccount.username = userResponse.email ?: throw AuthenticationException(Message.THIRD_PARTY_AUTH_NO_EMAIL) @@ -120,10 +110,8 @@ class OAuth2Delegate( newUserAccount.thirdPartyAuthId = userResponse.sub newUserAccount.thirdPartyAuthType = "oauth2" newUserAccount.accountType = UserAccount.AccountType.THIRD_PARTY - userAccountService.createUser(newUserAccount) - if (invitation != null) { - invitationService.accept(invitation.code, newUserAccount) - } + signUpService.signUp(newUserAccount, invitationCode, null) + newUserAccount } val jwt = jwtService.emitToken(user.id) diff --git a/backend/app/src/main/kotlin/io/tolgee/commandLineRunners/InitialUserCreatorCommandLineRunner.kt b/backend/app/src/main/kotlin/io/tolgee/commandLineRunners/InitialUserCreatorCommandLineRunner.kt index 3aa2f0979e..9954786e42 100644 --- a/backend/app/src/main/kotlin/io/tolgee/commandLineRunners/InitialUserCreatorCommandLineRunner.kt +++ b/backend/app/src/main/kotlin/io/tolgee/commandLineRunners/InitialUserCreatorCommandLineRunner.kt @@ -2,7 +2,6 @@ package io.tolgee.commandLineRunners import io.tolgee.configuration.tolgee.InternalProperties import io.tolgee.configuration.tolgee.TolgeeProperties -import io.tolgee.dtos.request.auth.SignUpDto import io.tolgee.dtos.request.organization.OrganizationDto import io.tolgee.model.UserAccount import io.tolgee.security.InitialPasswordManager @@ -45,10 +44,29 @@ class InitialUserCreatorCommandLineRunner( fun createInitialUser() { logger.info("Creating initial user...") val initialUsername = properties.authentication.initialUsername + + // Check if the account already exists. + // This can only be the case on Tolgee 3.x series and should be removed on Tolgee 4. + val candidate = userAccountService.findActive(initialUsername) + if (candidate != null) { + candidate.isInitialUser = true + userAccountService.save(candidate) + return + } + val initialPassword = initialPasswordManager.initialPassword - val user = userAccountService.createInitialUser( - SignUpDto(email = initialUsername, password = initialPassword, name = initialUsername), - ) + val user = UserAccount( + username = initialUsername, + password = passwordEncoder.encode(initialPassword), + name = initialUsername, + role = UserAccount.Role.ADMIN, + ).apply { + passwordChanged = false + isInitialUser = true + } + + userAccountService.createUser(userAccount = user) + userAccountService.transferLegacyNoAuthUser() // If the user was already existing, it may already have assigned orgs. // To avoid conflicts, we only create the org if the user doesn't have any. diff --git a/backend/data/src/main/kotlin/io/tolgee/component/demoProject/DemoProjectCreator.kt b/backend/data/src/main/kotlin/io/tolgee/component/demoProject/DemoProjectCreator.kt index 2dccc95466..7669522f80 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/demoProject/DemoProjectCreator.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/demoProject/DemoProjectCreator.kt @@ -45,7 +45,7 @@ class DemoProjectCreator( val project = Project().apply { name = "Demo project" this@apply.organizationOwner = organization - this.description = "This is a demo project of an packing list app" + this.description = "This is a demo project of a packing list app" } projectService.save(project) setAvatar(project) diff --git a/backend/data/src/main/kotlin/io/tolgee/development/DbPopulatorReal.kt b/backend/data/src/main/kotlin/io/tolgee/development/DbPopulatorReal.kt index 86ce178bca..63898666c8 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/DbPopulatorReal.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/DbPopulatorReal.kt @@ -2,7 +2,6 @@ package io.tolgee.development import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.dtos.request.LanguageDto -import io.tolgee.dtos.request.auth.SignUpDto import io.tolgee.dtos.request.organization.OrganizationDto import io.tolgee.model.ApiKey import io.tolgee.model.Language @@ -25,6 +24,7 @@ import io.tolgee.service.security.UserAccountService import io.tolgee.util.SlugGenerator import io.tolgee.util.executeInNewTransaction import jakarta.persistence.EntityManager +import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.annotation.Transactional @@ -45,6 +45,7 @@ class DbPopulatorReal( private val apiKeyService: ApiKeyService, private val languageStatsService: LanguageStatsService, private val platformTransactionManager: PlatformTransactionManager, + private val passwordEncoder: PasswordEncoder ) { private lateinit var de: Language private lateinit var en: Language @@ -59,12 +60,17 @@ class DbPopulatorReal( fun createUserIfNotExists(username: String, password: String? = null, name: String? = null): UserAccount { return userAccountService.findActive(username) ?: let { - val signUpDto = SignUpDto( - name = name ?: username, email = username, - password = password - ?: initialPasswordManager.initialPassword + + val rawPassword = password + ?: initialPasswordManager.initialPassword + + userAccountService.createUser( + UserAccount( + name = name ?: username, + username = username, + password = passwordEncoder.encode(rawPassword) + ) ) - userAccountService.createUser(signUpDto) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/InvitationService.kt b/backend/data/src/main/kotlin/io/tolgee/service/InvitationService.kt index 5c578fe869..3a55ba423f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/InvitationService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/InvitationService.kt @@ -21,7 +21,6 @@ import io.tolgee.service.organization.OrganizationRoleService import io.tolgee.service.security.PermissionService import io.tolgee.util.Logging import org.apache.commons.lang3.RandomStringUtils -import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.Duration @@ -29,13 +28,13 @@ import java.time.Instant import java.util.* @Service -class InvitationService @Autowired constructor( +class InvitationService( private val invitationRepository: InvitationRepository, private val authenticationFacade: AuthenticationFacade, private val organizationRoleService: OrganizationRoleService, private val permissionService: PermissionService, private val invitationEmailSender: InvitationEmailSender, - private val businessEventPublisher: BusinessEventPublisher + private val businessEventPublisher: BusinessEventPublisher, ) : Logging { @Transactional fun create(params: CreateProjectInvitationParams): Invitation { 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 b7be965a51..d5181d8d06 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 @@ -3,14 +3,17 @@ package io.tolgee.service.security import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.constants.Message import io.tolgee.dtos.request.auth.SignUpDto +import io.tolgee.exceptions.AuthenticationException import io.tolgee.exceptions.BadRequestException import io.tolgee.model.Invitation +import io.tolgee.model.UserAccount import io.tolgee.security.authentication.JwtService import io.tolgee.security.payload.JwtAuthenticationResponse import io.tolgee.service.EmailVerificationService import io.tolgee.service.InvitationService import io.tolgee.service.QuickStartService import io.tolgee.service.organization.OrganizationService +import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -22,39 +25,56 @@ class SignUpService( private val jwtService: JwtService, private val emailVerificationService: EmailVerificationService, private val organizationService: OrganizationService, - private val quickStartService: QuickStartService + private val quickStartService: QuickStartService, + private val passwordEncoder: PasswordEncoder ) { @Transactional fun signUp(dto: SignUpDto): JwtAuthenticationResponse? { - var invitation: Invitation? = null - if (dto.invitationCode == null) { - tolgeeProperties.authentication.checkAllowedRegistrations() - } else { - invitation = invitationService.getInvitation(dto.invitationCode) // it throws an exception - } - userAccountService.findActive(dto.email)?.let { throw BadRequestException(Message.USERNAME_ALREADY_EXISTS) } - val user = userAccountService.createUser(dto) + val user = dtoToEntity(dto) + signUp(user, dto.invitationCode, dto.organizationName) + + if (!tolgeeProperties.authentication.needsEmailVerification) { + return JwtAuthenticationResponse(jwtService.emitToken(user.id, true)) + } + + emailVerificationService.createForUser(user, dto.callbackUrl) + + return null + } + + fun signUp(entity: UserAccount, invitationCode: String?, organizationName: String?): UserAccount { + val invitation = findAndCheckInvitationOnRegistration(invitationCode) + val user = userAccountService.createUser(entity) if (invitation != null) { invitationService.accept(invitation.code, user) } val canCreateOrganization = tolgeeProperties.authentication.userCanCreateOrganizations - if (canCreateOrganization && (invitation == null || !dto.organizationName.isNullOrBlank())) { - val name = if (dto.organizationName.isNullOrBlank()) user.name else dto.organizationName!! + if (canCreateOrganization && (invitation == null || !organizationName.isNullOrBlank())) { + val name = if (organizationName.isNullOrBlank()) user.name else organizationName val organization = organizationService.createPreferred(user, name) quickStartService.create(user, organization) } + return user + } - if (!tolgeeProperties.authentication.needsEmailVerification) { - return JwtAuthenticationResponse(jwtService.emitToken(user.id, true)) - } - - emailVerificationService.createForUser(user, dto.callbackUrl) + fun dtoToEntity(request: SignUpDto): UserAccount { + val encodedPassword = passwordEncoder.encode(request.password!!) + return UserAccount(name = request.name, username = request.email, password = encodedPassword) + } - return null + @Transactional + fun findAndCheckInvitationOnRegistration(invitationCode: String?): Invitation? { + if (invitationCode == null) { + if (!tolgeeProperties.authentication.registrationsAllowed) { + throw AuthenticationException(Message.REGISTRATIONS_NOT_ALLOWED) + } + return null + } + return invitationService.getInvitation(invitationCode) } } 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 b6a5c25f61..09e55cf3bc 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 @@ -7,7 +7,6 @@ import io.tolgee.constants.Message import io.tolgee.dtos.cacheable.UserAccountDto import io.tolgee.dtos.request.UserUpdatePasswordRequestDto import io.tolgee.dtos.request.UserUpdateRequestDto -import io.tolgee.dtos.request.auth.SignUpDto import io.tolgee.dtos.request.validators.exceptions.ValidationException import io.tolgee.events.OnUserCountChanged import io.tolgee.events.user.OnUserCreated @@ -91,7 +90,7 @@ class UserAccountService( } } - @CacheEvict(cacheNames = [Caches.USER_ACCOUNTS], key = "#result.id") + @Transactional fun createUser(userAccount: UserAccount): UserAccount { userAccountRepository.saveAndFlush(userAccount) applicationEventPublisher.publishEvent(OnUserCreated(this, userAccount)) @@ -100,34 +99,12 @@ class UserAccountService( } @Transactional - fun createUser(request: SignUpDto, role: UserAccount.Role = UserAccount.Role.USER): UserAccount { - dtoToEntity(request).let { - it.role = role - this.createUser(it) - return it - } - } - - @Transactional - fun createInitialUser(request: SignUpDto): UserAccount { - // Check if the account already exists. - // This can only be the case on Tolgee 3.x series and should be removed on Tolgee 4. - val candidate = findActive(request.email) - if (candidate != null) { - candidate.isInitialUser = true - return save(candidate) - } - - dtoToEntity(request).let { - it.role = UserAccount.Role.ADMIN - it.isInitialUser = true - it.passwordChanged = false - this.createUser(it) - - // TODO: remove this on Tolgee 4 release - transferLegacyNoAuthUser() - return it - } + fun createUser(userAccount: UserAccount, rawPassword: String): UserAccount { + userAccountRepository.saveAndFlush(userAccount) + userAccount.password = passwordEncoder.encode(rawPassword) + applicationEventPublisher.publishEvent(OnUserCreated(this, userAccount)) + applicationEventPublisher.publishEvent(OnUserCountChanged(this)) + return userAccount } @CacheEvict(Caches.USER_ACCOUNTS, key = "#id") @@ -187,11 +164,6 @@ class UserAccountService( } } - fun dtoToEntity(request: SignUpDto): UserAccount { - val encodedPassword = passwordEncoder.encode(request.password!!) - return UserAccount(name = request.name, username = request.email, password = encodedPassword) - } - fun findByThirdParty(type: String, id: String): Optional { return userAccountRepository.findThirdByThirdParty(id, type) } @@ -351,7 +323,7 @@ class UserAccountService( } val matches = passwordEncoder.matches(dto.currentPassword, userAccount.password) - if (!matches) throw PermissionException() + if (!matches) throw PermissionException(Message.WRONG_CURRENT_PASSWORD) userAccount.tokensValidNotBefore = DateUtils.truncate(Date(), Calendar.SECOND) userAccount.password = passwordEncoder.encode(dto.password) @@ -433,10 +405,7 @@ class UserAccountService( this.applicationEventPublisher.publishEvent(OnUserCountChanged(this)) } - val isAnyUserAccount: Boolean - get() = userAccountRepository.count() > 0 - - private fun transferLegacyNoAuthUser() { + fun transferLegacyNoAuthUser() { val legacyImplicitUser = findActive("___implicit_user") ?: return diff --git a/backend/data/src/main/resources/demoProject/demoAvatar.png b/backend/data/src/main/resources/demoProject/demoAvatar.png index bf2afe2e5597692813a1ea4803862b4a6e19ab65..b6aa542c91c2ea3abd196a5f79d6261271927535 100644 GIT binary patch literal 6740 zcmcgxi$Bx*{~w*Ba!+%qB;+z#Zpn47xeHObw#a2Hw}?%-Cm{`yORh1uQp8-tq}vg|pD;DhyApDJag^Yn*L_F+7JK7j{nE$lrR3DXK5_>cn{gX)Z@<4Zq4MO$4VptNB*ZDfsY?!`{iH4 zN8aFDA{M(VG8_C~`T0b?uCLzx^u1ssdG^~lhB^FW+Ph(DxzficGJJ7zhBQY$@XRLp z8YN7Ez3XUZ{kRDKgYmN1NzR0-09kf3Fm{c>qHs%gKF1DWuWWfSEP3<#+{Qr{7gvR3 zkFua={+hk>!)Q0#RJ;i2uHW_8(-?hL1>OM6)CWu+nG_v1W^Dqx>$64tQUw)6U0Ro6 zKdeiDI#b^~<{k=90$3www;SEhBf)%8>YLd0Hs+M}HAsOWaG3<%qm1vEj#r^`B>|yC z{N{9p^NZ>b;bcI{&FZB;+{t&#Z&lk+j3q0&aIrSq0}wjrIQ(@>PorGU6}ULuX<1SC z{sJ$qJ?Jqu1(ZAKRrclJ+&3AXq7;zC&C$|fTY#zLz3-a}j#|>q3~Q69*t)Cd2yK(9e*KSpY=++WyKa?m(BYKw}G%en9t?Xit?>M&sIQ9=OKp+(m6#1wES3k>s_>y~0<;CN%e>4)J;{!7w z60g4r$3sX)V$pvBggzzq!%)ycakCt2lV>*8u!Y)GIVCv>SwWSyl==%UF2MC2_bnpb zSa#lN+|I=18LZ*j%t>=61zm1~C)(2q=EMFun75#>NXhWNb{~Ty(PM$4MQbq44cj5r zs$wp|EbRLOM;iR6PudovDYxN&+Kh*CngKhB&+o&pVsE)Bu!EbIgzDED^G4jh;`AZZf{b!&eD zNOK7H+TRjLexxz*;=mD)YFtBL!2DgRYSd-n#o8JRJNI2`GKtBO`&I@1*sZBCTz2ua zZuEg8hS~)ofj6jDQTKuWW|XE(I{&xm+W_|JilN9j7H)N3>%Z0@qA40-ep_|JvD~M{ zd5WUm28d|MDg}Ufg&32WRS~8uyie8G58Y4ibt2pOlG6X|p+W@lcZ2uJsh&G=`b58cjNhPqdmTAm3O%px3%&k@Pb=vlZDw0t+aPSaB z^b!zQq~=sf`Q9!%a)r?(XP9@M{m@@Po;l1Gmz4Wl60rgEZo&Ykpn;JxKXPV=Df*hp zkJ!6B8#3vUj3pvd-UwIX+)^0z_G=UN+$+FOqhs=hn1(@aKbar3j~;gAp)=@_o5U%G z69N8VjPb-;R|wxYHsRWIG4)Jlk(v4#VQVmn;9~1=eH@=dHxvSQr}iM92g>{q+uF=r z6i5pAC%@>%LY#uV(;+XeKg^cEI&^DR?M^TYbZ(z#_)vS&PUKXTh4ZoLt=KO>`)ocJ zALCChXj!$VtJh_9u-g9nJ`cytoF^64r!7>R&XMDMYt*e#J!15o z&1cS<=GwisO6FC%dV?^I=fMZ!m)Ah}I@|AM6d^J7FHlMzTBAPt}cC=*-xxEznCh;$qqs9)$)$4J}fz-3?tR@+M|CR9?Go?;UNHC;y|M z;Ir-oo$lF9Y^I0~qy=5{_n2H*tB41cHI=SdKJ=q&K)>LP_r7`_!h`KO*<_1d-IC2c zW4*1$xPtX3=Jug0-)F7K8kzL-WAPv@)o-ptAfNfEBr;@6kbYA47~7=*?U<_^=TO8chF3nYpMEfIXnszYy* zAq-Ak3l7R1AI-%_og6VbdOb#3N7(KA+Rk%In*&CX&&Zp+qiu zWj)a{*zV@-mJxWx#3hSpq;>yEM$x2;9xm~iJ6ewC(itfPhB7}7A#0w=@6D{&ain}@ z-lfXl;3cw+b_@5(<*Q$N7&uzg+ObrAr(eU;tj|b$*MjLdJOHhXQBDXo7Ame4BWO;! z2E1M;* zd|P<}7g1EZLiFl`VJOl#jVYZ`Ni3Ke^K*SC2ET2`!q>&A^fBW~15f$L;wO$$C;b+Kwt7K!!E zu5~kwyi8hf%e~XD-*wF2EqVU*!C^8-Rlvg#HU3>&uUfz@$Q%--*>-5eVdgxC=~2~A zBgMp%t^tCfuoA`aS6tYrLH}5zVF*$>HKO!Eo}IL01Z{abfvx#+x`Y zec}bW*Zy|`dhF?4(fM%K4brmT^m2IRZ>rD<^9SY#ue#QjG~!wH7uhlO)-{%=TYJ%M zTI(pHo`m0m`8eYmHtJHdKGyfiOOna54dc(X0KYKYu-n+FMc@(T7O1JDkR(C(L@~4Ch zn9`ZE$9`A*=NqBhK9U}@C(w2>a_3ZR)u(`)@UAy2$y&{OZo)tJUP^z&7P~QKuc5Yeeitf|vD~`( z`5KzNbHmb^G)U?lwVQ&7ey;o%0Dfr^uF#BkkEPf@v>+tv`t|Rkqb65=(a$54d3XLw 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 literal 36315 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

RV)?zB_D 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 users.forEach { - createdUsers[it.email] = userAccountService.dtoToEntity( + createdUsers[it.email] = signUpService.dtoToEntity( SignUpDto( name = it.name, email = it.email, password = "admin" ) diff --git a/e2e/cypress/common/login.ts b/e2e/cypress/common/login.ts index bd951cb09c..555bdb0e7c 100644 --- a/e2e/cypress/common/login.ts +++ b/e2e/cypress/common/login.ts @@ -72,8 +72,7 @@ export const fillAndSubmitSignUpForm = ( if (withOrganization) { cy.xpath(getInput('organizationName')).type('organization'); } - cy.xpath(getInput('password')).type('password'); - cy.xpath(getInput('passwordRepeat')).type('password'); + cy.xpath(getInput('password')).type('very.strong.password'); gcy('sign-up-submit-button').click(); }; diff --git a/e2e/cypress/e2e/security/login.cy.ts b/e2e/cypress/e2e/security/login.cy.ts index 46e61a3879..abbc71e36e 100644 --- a/e2e/cypress/e2e/security/login.cy.ts +++ b/e2e/cypress/e2e/security/login.cy.ts @@ -86,9 +86,8 @@ context('Login', () => { getParsedResetPasswordEmail().then((r) => { cy.visit(r.resetLink); }); - const newPassword = 'new_password'; + const newPassword = 'new_very.strong.password'; cy.xpath("//*[@name='password']").type(newPassword); - cy.xpath("//*[@name='passwordRepeat']").type(newPassword); cy.contains('Save new password').click(); assertMessage('Password successfully reset'); login(username, newPassword); diff --git a/e2e/cypress/e2e/userSettings/accountSecurity.cy.ts b/e2e/cypress/e2e/userSettings/accountSecurity.cy.ts index 447a2621ef..d6e6a500e9 100644 --- a/e2e/cypress/e2e/userSettings/accountSecurity.cy.ts +++ b/e2e/cypress/e2e/userSettings/accountSecurity.cy.ts @@ -26,16 +26,14 @@ describe('Account security', () => { }); it('changes password', () => { - const superNewPassword = 'super_new_password'; + const superNewPassword = 'super_new_password!1'; cy.xpath("//*[@name='currentPassword']").clear().type(INITIAL_PASSWORD); cy.xpath("//*[@name='password']").clear().type(superNewPassword); - cy.xpath("//*[@name='passwordRepeat']").clear().type(superNewPassword); cy.contains('Save').click(); assertMessage('updated'); cy.xpath("//*[@name='currentPassword']").should('not.have.value'); cy.xpath("//*[@name='password']").should('not.have.value'); - cy.xpath("//*[@name='passwordRepeat']").should('not.have.value'); // Ensure we're still logged in cy.reload(); diff --git a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/UsageReportingTest.kt b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/UsageReportingTest.kt index 3bfaed17bf..a1fe57443b 100644 --- a/ee/backend/tests/src/test/kotlin/io/tolgee/ee/UsageReportingTest.kt +++ b/ee/backend/tests/src/test/kotlin/io/tolgee/ee/UsageReportingTest.kt @@ -3,10 +3,11 @@ package io.tolgee.ee import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import io.tolgee.AbstractSpringTest import io.tolgee.constants.Feature -import io.tolgee.dtos.request.auth.SignUpDto import io.tolgee.ee.data.SubscriptionStatus import io.tolgee.ee.model.EeSubscription import io.tolgee.ee.repository.EeSubscriptionRepository +import io.tolgee.model.UserAccount +import io.tolgee.service.security.SignUpService import io.tolgee.testing.assert import org.junit.jupiter.api.Test import org.mockito.kotlin.KArgumentCaptor @@ -34,6 +35,9 @@ class UsageReportingTest : AbstractSpringTest() { @Autowired private lateinit var eeLicenseMockRequestUtil: EeLicensingMockRequestUtil + @Autowired + private lateinit var signUpService: SignUpService + @Test fun `it checks for subscription changes`() { eeSubscriptionRepository.save( @@ -59,21 +63,19 @@ class UsageReportingTest : AbstractSpringTest() { verify { val user1 = userAccountService.createUser( - SignUpDto( - "Test", - email = "aa@a.a", - organizationName = "Ho", - password = "12345678" - ) + UserAccount( + name = "Test", + username = "aa@a.a", + ), + rawPassword = "12345678" ) captor.assertSeats(1) val user2 = userAccountService.createUser( - SignUpDto( - "Test", - email = "ab@a.a", - organizationName = "Ho", - password = "12345678" - ) + UserAccount( + name = "Test", + username = "ab@a.a", + ), + rawPassword = "12345678" ) captor.assertSeats(2) userAccountService.delete(user1.id) diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 6371b84776..fa07a79551 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -64,7 +64,8 @@ "uuid": "9.0.0", "web-vitals": "^2.1.0", "yup": "^0.32.9", - "zustand": "4.1.2" + "zustand": "4.1.2", + "zxcvbn": "^4.4.2" }, "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", @@ -87,6 +88,7 @@ "@types/react-redux": "^7.1.18", "@types/react-router-dom": "^5.1.8", "@types/yup": "^0.29.13", + "@types/zxcvbn": "^4.4.4", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "babel-loader": "8.1.0", @@ -4932,6 +4934,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/zxcvbn": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@types/zxcvbn/-/zxcvbn-4.4.4.tgz", + "integrity": "sha512-Tuk4q7q0DnpzyJDI4aMeghGuFu2iS1QAdKpabn8JfbtfGmVDUgvZv1I7mEjP61Bvnp3ljKCC8BE6YYSTNxmvRQ==", + "dev": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", @@ -26956,6 +26964,11 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "node_modules/zxcvbn": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", + "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==" } }, "dependencies": { @@ -30243,6 +30256,12 @@ "version": "0.29.13", "dev": true }, + "@types/zxcvbn": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@types/zxcvbn/-/zxcvbn-4.4.4.tgz", + "integrity": "sha512-Tuk4q7q0DnpzyJDI4aMeghGuFu2iS1QAdKpabn8JfbtfGmVDUgvZv1I7mEjP61Bvnp3ljKCC8BE6YYSTNxmvRQ==", + "dev": true + }, "@typescript-eslint/eslint-plugin": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", @@ -45471,6 +45490,11 @@ "zwitch": { "version": "1.0.5", "dev": true + }, + "zxcvbn": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", + "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==" } } } diff --git a/webapp/package.json b/webapp/package.json index fbc66df843..f7f8846a75 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -59,7 +59,8 @@ "uuid": "9.0.0", "web-vitals": "^2.1.0", "yup": "^0.32.9", - "zustand": "4.1.2" + "zustand": "4.1.2", + "zxcvbn": "^4.4.2" }, "scripts": { "start": "NODE_OPTIONS=--openssl-legacy-provider craco start", @@ -116,6 +117,7 @@ "@types/react-redux": "^7.1.18", "@types/react-router-dom": "^5.1.8", "@types/yup": "^0.29.13", + "@types/zxcvbn": "^4.4.4", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "babel-loader": "8.1.0", diff --git a/webapp/src/component/common/avatar/AvatarImg.tsx b/webapp/src/component/common/avatar/AvatarImg.tsx index 71bc027d37..ec34a8f560 100644 --- a/webapp/src/component/common/avatar/AvatarImg.tsx +++ b/webapp/src/component/common/avatar/AvatarImg.tsx @@ -1,6 +1,6 @@ import { AutoAvatar } from './AutoAvatar'; import { AvatarOwner } from './ProfileAvatar'; -import { styled, useTheme } from '@mui/material'; +import { styled } from '@mui/material'; const StyledContainer = styled('div')` overflow: hidden; @@ -8,9 +8,7 @@ const StyledContainer = styled('div')` `; export const AvatarImg = (props: { size: number; owner: AvatarOwner }) => { - const theme = useTheme(); - const background = - theme.palette.mode === 'dark' ? 'rgb(239, 239, 239)' : 'rgb(200, 200, 200)'; + const background = 'rgb(242, 242, 242)'; const avatarPath = props.size <= 50 ? props.owner.avatar?.thumbnail diff --git a/webapp/src/component/layout/CompactView.tsx b/webapp/src/component/layout/CompactView.tsx index a7cd40aeb5..f273569785 100644 --- a/webapp/src/component/layout/CompactView.tsx +++ b/webapp/src/component/layout/CompactView.tsx @@ -17,6 +17,7 @@ const StyledContainer = styled('div')` align-items: space-between; justify-items: stretch; grid-template-rows: 1fr auto; + padding: 0 32px 0 32px; `; const StyledInner = styled('div')` diff --git a/webapp/src/component/layout/QuickStartGuide/QuickStartGuide.tsx b/webapp/src/component/layout/QuickStartGuide/QuickStartGuide.tsx index 4b8257fdff..8c30774fb9 100644 --- a/webapp/src/component/layout/QuickStartGuide/QuickStartGuide.tsx +++ b/webapp/src/component/layout/QuickStartGuide/QuickStartGuide.tsx @@ -16,7 +16,6 @@ const StyledContainer = styled(Box)` grid-template-rows: auto 1fr auto; height: 100%; position: relative; - border-top: 1px solid ${({ theme }) => theme.palette.quickStart.topBorder}; `; const StyledContent = styled(Box)` diff --git a/webapp/src/component/security/PasswordFieldWithValidation.tsx b/webapp/src/component/security/PasswordFieldWithValidation.tsx new file mode 100644 index 0000000000..622b75d7c2 --- /dev/null +++ b/webapp/src/component/security/PasswordFieldWithValidation.tsx @@ -0,0 +1,84 @@ +import { ReactNode } from 'react'; +import { useTranslate } from '@tolgee/react'; +import { useField } from 'formik'; + +import { TextField } from '../common/form/fields/TextField'; +import { Box, TextFieldProps, useTheme } from '@mui/material'; +import zxcvbn from 'zxcvbn'; + +type SetPasswordFieldsProps = { + label: ReactNode; +}; + +type Props = SetPasswordFieldsProps & TextFieldProps; + +export const PasswordFieldWithValidation: React.FC = (props) => { + const { t } = useTranslate(); + + function getScoreTranslation(strength: number) { + switch (strength) { + case 0: + return t('password-strength-very-weak'); + case 1: + return t('password-strength-weak'); + case 2: + return t('password-strength-medium'); + case 3: + return t('password-strength-strong'); + default: + return t('password-strength-very-strong'); + } + } + + const theme = useTheme(); + + function getScoreColor(strength: number) { + switch (strength) { + case 0: + case 1: + return theme.palette.error.main; + case 2: + return theme.palette.text.primary; + case 3: + case 4: + return theme.palette.success.main; + default: + return theme.palette.text.primary; + } + } + + function getPasswordCheck(value: string) { + if (value.length >= 8) { + const passwordCheck = zxcvbn(value); + return ( + + {getScoreTranslation(passwordCheck.score)} + + ); + } + } + + const [field] = useField({ + name: 'password', + validate: (value: string) => { + const score = zxcvbn(value).score; + return score <= 1 + ? (getPasswordCheck(value) as unknown as string) + : undefined; + }, + }); + + return ( + <> + + + ); +}; + +export default PasswordFieldWithValidation; diff --git a/webapp/src/component/security/ResetPasswordSetView.tsx b/webapp/src/component/security/ResetPasswordSetView.tsx index 99658c9e5c..b7165e2f5e 100644 --- a/webapp/src/component/security/ResetPasswordSetView.tsx +++ b/webapp/src/component/security/ResetPasswordSetView.tsx @@ -1,4 +1,4 @@ -import { FunctionComponent, useEffect } from 'react'; +import React, { FunctionComponent, useEffect } from 'react'; import { useTranslate } from '@tolgee/react'; import Box from '@mui/material/Box'; import { useSelector } from 'react-redux'; @@ -13,17 +13,20 @@ import { AppState } from 'tg.store/index'; import { CompactView } from 'tg.component/layout/CompactView'; import LoadingButton from 'tg.component/common/form/LoadingButton'; +import { NewPasswordLabel } from './SetPasswordField'; import { Alert } from '../common/Alert'; import { StandardForm } from '../common/form/StandardForm'; import { DashboardPage } from '../layout/DashboardPage'; -import { SetPasswordFields } from './SetPasswordFields'; import { useLogout } from 'tg.hooks/useLogout'; +const PasswordFieldWithValidation = React.lazy( + () => import('tg.component/security/PasswordFieldWithValidation') +); + const globalActions = container.resolve(GlobalActions); type ValueType = { password: string; - passwordRepeat: string; }; const PasswordResetSetView: FunctionComponent = () => { @@ -81,10 +84,11 @@ const PasswordResetSetView: FunctionComponent = () => { } windowTitle={t('reset_password_set_title')} title={t('reset_password_set_title')} + maxWidth={650} content={ @@ -111,7 +115,7 @@ const PasswordResetSetView: FunctionComponent = () => { ); }} > - + } /> } /> diff --git a/webapp/src/component/security/ResetPasswordView.tsx b/webapp/src/component/security/ResetPasswordView.tsx index bba2649c31..a7462949cb 100644 --- a/webapp/src/component/security/ResetPasswordView.tsx +++ b/webapp/src/component/security/ResetPasswordView.tsx @@ -61,6 +61,7 @@ const PasswordResetView: FunctionComponent = () => { windowTitle={t('reset_password_title')} title={t('reset_password_title')} backLink={LINKS.LOGIN.build()} + maxWidth={650} content={ loadable.loaded ? ( diff --git a/webapp/src/component/security/SetPasswordField.tsx b/webapp/src/component/security/SetPasswordField.tsx new file mode 100644 index 0000000000..54a5bf1602 --- /dev/null +++ b/webapp/src/component/security/SetPasswordField.tsx @@ -0,0 +1,9 @@ +import { T } from '@tolgee/react'; + +export const PasswordLabel = () => { + return ; +}; + +export const NewPasswordLabel = () => { + return ; +}; diff --git a/webapp/src/component/security/SetPasswordFields.tsx b/webapp/src/component/security/SetPasswordFields.tsx deleted file mode 100644 index ce75c6e5b7..0000000000 --- a/webapp/src/component/security/SetPasswordFields.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { default as React, FunctionComponent } from 'react'; -import { T } from '@tolgee/react'; - -import { TextField } from '../common/form/fields/TextField'; - -interface SetPasswordFieldsProps {} - -export const SetPasswordFields: FunctionComponent = ( - props -) => { - return ( - <> - } - variant="standard" - /> - } - variant="standard" - /> - - ); -}; diff --git a/webapp/src/component/security/SignUp/SignUpForm.tsx b/webapp/src/component/security/SignUp/SignUpForm.tsx index 3b45b8ee56..0115aea5f3 100644 --- a/webapp/src/component/security/SignUp/SignUpForm.tsx +++ b/webapp/src/component/security/SignUp/SignUpForm.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { Box, Link, Typography } from '@mui/material'; import { LoadingButton } from '@mui/lab'; import { T, useTranslate } from '@tolgee/react'; @@ -9,17 +10,20 @@ import { import { TextField } from 'tg.component/common/form/fields/TextField'; import { InvitationCodeService } from 'tg.service/InvitationCodeService'; import { Validation } from 'tg.constants/GlobalValidationSchema'; -import { SetPasswordFields } from '../SetPasswordFields'; +import { PasswordLabel } from '../SetPasswordField'; import { useConfig } from 'tg.globalContext/helpers'; import { ResourceErrorComponent } from '../../common/form/ResourceErrorComponent'; import { Alert } from '../../common/Alert'; import { SpendingLimitExceededDescription } from '../../billing/SpendingLimitExceeded'; +const PasswordFieldWithValidation = React.lazy( + () => import('tg.component/security/PasswordFieldWithValidation') +); + export type SignUpType = { name: string; email: string; password: string; - passwordRepeat?: string; organizationName: string; invitationCode?: string; }; @@ -64,7 +68,6 @@ export const SignUpForm = (props: Props) => { initialValues={ { password: '', - passwordRepeat: '', name: '', email: '', organizationName: orgRequired ? '' : undefined, @@ -104,7 +107,7 @@ export const SignUpForm = (props: Props) => { variant="standard" /> )} - + } /> { recaptchaToken: await getRecaptchaToken(), } as SignUpType; - delete request.passwordRepeat; - signUpMutation.mutate( { content: { 'application/json': request } }, { diff --git a/webapp/src/constants/GlobalValidationSchema.tsx b/webapp/src/constants/GlobalValidationSchema.tsx index 1818fba754..8b865a5557 100644 --- a/webapp/src/constants/GlobalValidationSchema.tsx +++ b/webapp/src/constants/GlobalValidationSchema.tsx @@ -41,18 +41,8 @@ Yup.setLocale({ }); export class Validation { - static readonly USER_PASSWORD = Yup.string().min(8).max(50).required(); - - static readonly USER_PASSWORD_WITH_REPEAT_NAKED = { - password: Validation.USER_PASSWORD, - passwordRepeat: Yup.string() - .oneOf([Yup.ref('password'), null], 'Passwords must match') - .required(), - }; - - static readonly USER_PASSWORD_WITH_REPEAT = Yup.object().shape( - Validation.USER_PASSWORD_WITH_REPEAT_NAKED - ); + static readonly USER_PASSWORD = (t: TFunType) => + Yup.string().min(8).max(50).required(); static readonly RESET_PASSWORD_REQUEST = Yup.object().shape({ email: Yup.string().email().required(), @@ -72,7 +62,7 @@ export class Validation { static readonly SIGN_UP = (t: TFunType, orgRequired: boolean) => Yup.object().shape({ - ...Validation.USER_PASSWORD_WITH_REPEAT_NAKED, + password: Validation.USER_PASSWORD(t), name: Yup.string().required(), email: Yup.string().email().required().test( 'checkEmailUnique', @@ -103,13 +93,16 @@ export class Validation { : Yup.string().email().required(), }); - static readonly USER_PASSWORD_CHANGE = Yup.object().shape({ - currentPassword: Yup.string().max(50).required(), - password: Validation.USER_PASSWORD, - passwordRepeat: Yup.string() - .notRequired() - .oneOf([Yup.ref('password'), null], 'Passwords must match'), - }); + static readonly USER_PASSWORD_CHANGE = (t: TFunType) => + Yup.object().shape({ + currentPassword: Yup.string().max(50).required(), + password: Validation.USER_PASSWORD(t), + }); + + static readonly PASSWORD_RESET = (t: TFunType) => + Yup.object().shape({ + password: Validation.USER_PASSWORD(t), + }); static readonly USER_MFA_ENABLE = Yup.object().shape({ password: Yup.string().max(50).required(), diff --git a/webapp/src/views/userSettings/accountSecurity/ChangePassword.tsx b/webapp/src/views/userSettings/accountSecurity/ChangePassword.tsx index ed95b40f5b..7f7dfbc980 100644 --- a/webapp/src/views/userSettings/accountSecurity/ChangePassword.tsx +++ b/webapp/src/views/userSettings/accountSecurity/ChangePassword.tsx @@ -1,6 +1,6 @@ import React, { FunctionComponent } from 'react'; import { Box, Typography } from '@mui/material'; -import { T } from '@tolgee/react'; +import { T, useTranslate } from '@tolgee/react'; import { container } from 'tsyringe'; import { MessageService } from 'tg.service/MessageService'; @@ -9,19 +9,28 @@ import { useApiMutation } from 'tg.service/http/useQueryApi'; import { UserUpdatePasswordDTO } from 'tg.service/request.types'; import { StandardForm } from 'tg.component/common/form/StandardForm'; import { TextField } from 'tg.component/common/form/fields/TextField'; -import { SetPasswordFields } from 'tg.component/security/SetPasswordFields'; +import { NewPasswordLabel } from 'tg.component/security/SetPasswordField'; import { useUser } from 'tg.globalContext/helpers'; import { Validation } from 'tg.constants/GlobalValidationSchema'; +const PasswordFieldWithValidation = React.lazy( + () => import('tg.component/security/PasswordFieldWithValidation') +); + const messagesService = container.resolve(MessageService); const securityService = container.resolve(SecurityService); export const ChangePassword: FunctionComponent = () => { const user = useUser(); + const { t } = useTranslate(); + const updatePassword = useApiMutation({ url: '/v2/user/password', method: 'put', + fetchOptions: { + disableAutoErrorHandle: true, + }, }); const handleSubmit = (v: UserUpdatePasswordDTO) => { @@ -49,10 +58,9 @@ export const ChangePassword: FunctionComponent = () => { { currentPassword: '', password: '', - passwordRepeat: '', } as UserUpdatePasswordDTO } - validationSchema={Validation.USER_PASSWORD_CHANGE} + validationSchema={Validation.USER_PASSWORD_CHANGE(t)} onSubmit={handleSubmit} > { label={} variant="standard" /> - + } /> );