From 5fffd4e3e2090bc7932f06377e1886c95d1587d9 Mon Sep 17 00:00:00 2001 From: Shannon Draeker <125921730+shannon-livefront@users.noreply.github.com> Date: Tue, 30 Jan 2024 13:19:37 -0700 Subject: [PATCH] BIT-620: Reset password screen (#871) --- .../datasource/network/api/AccountsApi.kt | 4 +- .../network/api/AuthenticatedAccountsApi.kt | 8 +- ...onRequest.kt => ResendEmailRequestJson.kt} | 2 +- .../network/model/ResetPasswordRequestJson.kt | 27 ++ .../network/service/AccountsService.kt | 10 +- .../network/service/AccountsServiceImpl.kt | 8 +- .../data/auth/repository/AuthRepository.kt | 24 +- .../auth/repository/AuthRepositoryImpl.kt | 108 ++++- .../repository/model/ResetPasswordResult.kt | 16 + .../data/auth/repository/model/UserState.kt | 2 + .../util/UserStateJsonExtensions.kt | 4 +- .../resetpassword/ResetPasswordNavigation.kt | 26 ++ .../resetpassword/ResetPasswordScreen.kt | 247 +++++++++++ .../resetpassword/ResetPasswordViewModel.kt | 409 ++++++++++++++++++ ...licyInformationMasterPasswordExtensions.kt | 40 ++ .../platform/feature/rootnav/RootNavScreen.kt | 34 +- .../feature/rootnav/RootNavViewModel.kt | 8 + .../settings/exportvault/ExportVaultScreen.kt | 4 +- .../com/x8bit/bitwarden/MainViewModelTest.kt | 1 + .../network/service/AccountsServiceTest.kt | 20 +- .../auth/repository/AuthRepositoryTest.kt | 217 +++++++++- .../util/UserStateJsonExtensionsTest.kt | 2 + .../feature/landing/LandingViewModelTest.kt | 3 + .../auth/feature/login/LoginViewModelTest.kt | 1 + .../resetPassword/ResetPasswordScreenTest.kt | 180 ++++++++ .../ResetPasswordViewModelTest.kt | 321 ++++++++++++++ ...InformationMasterPasswordExtensionsTest.kt | 55 +++ .../vaultunlock/VaultUnlockViewModelTest.kt | 3 + .../feature/rootnav/RootNavScreenTest.kt | 9 + .../feature/rootnav/RootNavViewModelTest.kt | 35 +- .../feature/search/SearchViewModelTest.kt | 1 + .../LoginApprovalViewModelTest.kt | 1 + .../generator/GeneratorViewModelTest.kt | 1 + .../send/addsend/AddSendViewModelTest.kt | 1 + .../addedit/VaultAddEditViewModelTest.kt | 1 + .../attachments/AttachmentsViewModelTest.kt | 1 + .../feature/item/VaultItemViewModelTest.kt | 1 + .../VaultItemListingViewModelTest.kt | 1 + .../VaultMoveToOrganizationViewModelTest.kt | 1 + .../VaultMoveToOrganizationExtensionsTest.kt | 1 + .../vault/feature/vault/VaultViewModelTest.kt | 3 + .../vault/util/UserStateExtensionsTest.kt | 9 + 42 files changed, 1795 insertions(+), 55 deletions(-) rename app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/{ResendEmailJsonRequest.kt => ResendEmailRequestJson.kt} (95%) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/ResetPasswordRequestJson.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/ResetPasswordResult.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordNavigation.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordScreen.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordViewModel.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/resetpassword/util/PolicyInformationMasterPasswordExtensions.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/resetPassword/ResetPasswordScreenTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/resetPassword/ResetPasswordViewModelTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/resetPassword/util/PolicyInformationMasterPasswordExtensionsTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AccountsApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AccountsApi.kt index f41e7a20c..af4d31e8b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AccountsApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AccountsApi.kt @@ -5,7 +5,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJso import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson -import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailJsonRequest +import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson import retrofit2.http.Body import retrofit2.http.POST @@ -26,6 +26,6 @@ interface AccountsApi { @POST("/two-factor/send-email-login") suspend fun resendVerificationCodeEmail( - @Body body: ResendEmailJsonRequest, + @Body body: ResendEmailRequestJson, ): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthenticatedAccountsApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthenticatedAccountsApi.kt index 0d51f932a..4965f7ccf 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthenticatedAccountsApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthenticatedAccountsApi.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.auth.datasource.network.api import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson import retrofit2.http.Body import retrofit2.http.HTTP @@ -8,10 +9,15 @@ import retrofit2.http.HTTP * Defines raw calls under the /accounts API with authentication applied. */ interface AuthenticatedAccountsApi { - /** * Deletes the current account. */ @HTTP(method = "DELETE", path = "/accounts", hasBody = true) suspend fun deleteAccount(@Body body: DeleteAccountRequestJson): Result + + /** + * Resets the password. + */ + @HTTP(method = "POST", path = "/accounts/password", hasBody = true) + suspend fun resetPassword(@Body body: ResetPasswordRequestJson): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/ResendEmailJsonRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/ResendEmailRequestJson.kt similarity index 95% rename from app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/ResendEmailJsonRequest.kt rename to app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/ResendEmailRequestJson.kt index 3dcc0bb79..ddc76d0da 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/ResendEmailJsonRequest.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/ResendEmailRequestJson.kt @@ -14,7 +14,7 @@ import kotlinx.serialization.Serializable * @property ssoToken The sso token, if the user is logging in via single sign on. */ @Serializable -data class ResendEmailJsonRequest( +data class ResendEmailRequestJson( @SerialName("DeviceIdentifier") val deviceIdentifier: String, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/ResetPasswordRequestJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/ResetPasswordRequestJson.kt new file mode 100644 index 000000000..334479a72 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/ResetPasswordRequestJson.kt @@ -0,0 +1,27 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Request body for resetting the password. + * + * @param currentPasswordHash The hash of the user's current password. + * @param newPasswordHash The hash of the user's new password. + * @param passwordHint The hint for the master password (nullable). + * @param key The user key for the request (encrypted). + */ +@Serializable +data class ResetPasswordRequestJson( + @SerialName("masterPasswordHash") + val currentPasswordHash: String, + + @SerialName("newMasterPasswordHash") + val newPasswordHash: String, + + @SerialName("masterPasswordHint") + val passwordHint: String?, + + @SerialName("Key") + val key: String, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt index 77097820d..43b6847c8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt @@ -4,7 +4,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintRespon import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson -import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailJsonRequest +import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson /** * Provides an API for querying accounts endpoints. @@ -34,5 +35,10 @@ interface AccountsService { /** * Resend the email with the two-factor verification code. */ - suspend fun resendVerificationCodeEmail(body: ResendEmailJsonRequest): Result + suspend fun resendVerificationCodeEmail(body: ResendEmailRequestJson): Result + + /** + * Reset the password. + */ + suspend fun resetPassword(body: ResetPasswordRequestJson): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt index 9bf1d9b35..3726de4b0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt @@ -9,7 +9,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJso import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson -import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailJsonRequest +import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenError import com.x8bit.bitwarden.data.platform.datasource.network.util.parseErrorBodyOrNull import kotlinx.serialization.json.Json @@ -60,6 +61,9 @@ class AccountsServiceImpl constructor( ?: throw throwable } - override suspend fun resendVerificationCodeEmail(body: ResendEmailJsonRequest): Result = + override suspend fun resendVerificationCodeEmail(body: ResendEmailRequestJson): Result = accountsApi.resendVerificationCodeEmail(body = body) + + override suspend fun resetPassword(body: ResetPasswordRequestJson): Result = + authenticatedAccountsApi.resetPassword(body = body) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index 87cf623e7..0ba34a0a1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -17,6 +17,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult +import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult @@ -78,6 +79,11 @@ interface AuthRepository : AuthenticatorProvider { */ var hasPendingAccountAddition: Boolean + /** + * Return the cached password policies for the current user. + */ + val passwordPolicies: List + /** * Clears the pending deletion state that occurs when the an account is successfully deleted. */ @@ -159,6 +165,16 @@ interface AuthRepository : AuthenticatorProvider { email: String, ): PasswordHintResult + /** + * Resets the users password from the [currentPassword] to the [newPassword] and + * optional [passwordHint]. + */ + suspend fun resetPassword( + currentPassword: String, + newPassword: String, + passwordHint: String?, + ): ResetPasswordResult + /** * Set the value of [captchaTokenResultFlow]. */ @@ -230,10 +246,8 @@ interface AuthRepository : AuthenticatorProvider { suspend fun validatePassword(password: String): ValidatePasswordResult /** - * Validates the given [password] against a MasterPassword [policy]. + * Validates the given [password] against the master password + * policies for the current user. */ - suspend fun validatePasswordAgainstPolicy( - password: String, - policy: PolicyInformation.MasterPassword, - ): Boolean + suspend fun validatePasswordAgainstPolicies(password: String): Boolean } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index d6f7c012c..85e5aa49e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -14,7 +14,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintRespon import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson -import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailJsonRequest +import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService @@ -43,6 +44,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult +import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult import com.x8bit.bitwarden.data.auth.repository.model.UserState @@ -121,7 +123,7 @@ class AuthRepositoryImpl( /** * The information necessary to resend the verification code email for two-factor login. */ - private var resendEmailJsonRequest: ResendEmailJsonRequest? = null + private var resendEmailRequestJson: ResendEmailRequestJson? = null /** * The password that needs to be checked against any organization policies before @@ -218,6 +220,15 @@ class AuthRepositoryImpl( override var hasPendingAccountAddition: Boolean by mutableHasPendingAccountAdditionStateFlow::value + override val passwordPolicies: List + get() = activeUserId?.let { userId -> + authDiskSource + .getPolicies(userId) + ?.filter { it.type == PolicyTypeJson.MASTER_PASSWORD && it.isEnabled } + ?.mapNotNull { it.policyInformation as? PolicyInformation.MasterPassword } + .orEmpty() + } ?: emptyList() + init { pushManager .syncOrgKeysFlow @@ -239,6 +250,10 @@ class AuthRepositoryImpl( val userId = activeUserId ?: return@onEach if (passwordPassesPolicies(policies)) { vaultRepository.completeUnlock(userId = userId) + storeUserResetPasswordReason( + userId = userId, + reason = null, + ) } else { storeUserResetPasswordReason( userId = userId, @@ -363,7 +378,7 @@ class AuthRepositoryImpl( // Cache the data necessary for the remaining two-factor auth flow. identityTokenAuthModel = authModel twoFactorResponse = loginResponse - resendEmailJsonRequest = ResendEmailJsonRequest( + resendEmailRequestJson = ResendEmailRequestJson( deviceIdentifier = authDiskSource.uniqueAppId, email = email, passwordHash = authModel.password, @@ -399,7 +414,7 @@ class AuthRepositoryImpl( // Remove any cached data after successfully logging in. identityTokenAuthModel = null twoFactorResponse = null - resendEmailJsonRequest = null + resendEmailRequestJson = null // Attempt to unlock the vault if possible. password?.let { @@ -493,7 +508,7 @@ class AuthRepositoryImpl( } override suspend fun resendVerificationCodeEmail(): ResendEmailResult = - resendEmailJsonRequest?.let { jsonRequest -> + resendEmailRequestJson?.let { jsonRequest -> accountsService.resendVerificationCodeEmail(body = jsonRequest).fold( onFailure = { ResendEmailResult.Error(message = it.message) }, onSuccess = { ResendEmailResult.Success }, @@ -628,6 +643,76 @@ class AuthRepositoryImpl( ) } + @Suppress("ReturnCount") + override suspend fun resetPassword( + currentPassword: String, + newPassword: String, + passwordHint: String?, + ): ResetPasswordResult { + val activeAccount = authDiskSource + .userState + ?.activeAccount + ?: return ResetPasswordResult.Error + val currentPasswordHash = authSdkSource + .hashPassword( + email = activeAccount.profile.email, + password = currentPassword, + kdf = activeAccount.profile.toSdkParams(), + purpose = HashPurpose.SERVER_AUTHORIZATION, + ) + .fold( + onFailure = { return ResetPasswordResult.Error }, + onSuccess = { it }, + ) + return authSdkSource + .makeRegisterKeys( + email = activeAccount.profile.email, + password = newPassword, + kdf = activeAccount.profile.toSdkParams(), + ) + .flatMap { registerKeyResponse -> + accountsService.resetPassword( + body = ResetPasswordRequestJson( + currentPasswordHash = currentPasswordHash, + newPasswordHash = registerKeyResponse.masterPasswordHash, + passwordHint = passwordHint, + key = registerKeyResponse.encryptedUserKey, + ), + ) + } + .fold( + onSuccess = { + // Clear the password reset reason, since it's no longer relevant. + storeUserResetPasswordReason( + userId = activeAccount.profile.userId, + reason = null, + ) + + // Update the saved master password hash. + authSdkSource + .hashPassword( + email = activeAccount.profile.email, + password = newPassword, + kdf = activeAccount.profile.toSdkParams(), + purpose = HashPurpose.LOCAL_AUTHORIZATION, + ) + .onSuccess { passwordHash -> + authDiskSource.storeMasterPasswordHash( + userId = activeAccount.profile.userId, + passwordHash = passwordHash, + ) + } + + // Complete the login flow. + vaultRepository.completeUnlock(userId = activeAccount.profile.userId) + + // Return the success. + ResetPasswordResult.Success + }, + onFailure = { ResetPasswordResult.Error }, + ) + } + override fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult) { mutableCaptchaTokenFlow.tryEmit(tokenResult) } @@ -854,7 +939,13 @@ class AuthRepositoryImpl( } @Suppress("CyclomaticComplexMethod", "ReturnCount") - override suspend fun validatePasswordAgainstPolicy( + override suspend fun validatePasswordAgainstPolicies( + password: String, + ): Boolean = passwordPolicies + .all { validatePasswordAgainstPolicy(password, it) } + + @Suppress("CyclomaticComplexMethod", "ReturnCount") + private suspend fun validatePasswordAgainstPolicy( password: String, policy: PolicyInformation.MasterPassword, ): Boolean { @@ -908,10 +999,9 @@ class AuthRepositoryImpl( .filter { it.enforceOnLogin == true } // Check the password against all the policies. - val failingPolicies = passwordPolicies.filter { policy -> - !validatePasswordAgainstPolicy(password, policy) + return passwordPolicies.all { policy -> + validatePasswordAgainstPolicy(password, policy) } - return failingPolicies.isEmpty() } private suspend fun getFingerprintPhrase( diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/ResetPasswordResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/ResetPasswordResult.kt new file mode 100644 index 000000000..376929d4d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/ResetPasswordResult.kt @@ -0,0 +1,16 @@ +package com.x8bit.bitwarden.data.auth.repository.model + +/** + * Models result of resetting a user's password. + */ +sealed class ResetPasswordResult { + /** + * The password was reset successfully. + */ + data object Success : ResetPasswordResult() + + /** + * There was an error resetting the password. + */ + data object Error : ResetPasswordResult() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/UserState.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/UserState.kt index b263c94e3..0173ab1bd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/UserState.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/UserState.kt @@ -42,6 +42,7 @@ data class UserState( * @property isVaultUnlocked Whether or not the user's vault is currently unlocked. * @property isVaultPendingUnlock Whether or not the user's vault is currently pending being * unlocked, such as when the password policy has not completed verification yet. + * @property needsPasswordReset If the user needs to reset their password. * @property organizations List of [Organization]s the user is associated with, if any. * @property isBiometricsEnabled Indicates that the biometrics mechanism for unlocking the * user's vault is enabled. @@ -57,6 +58,7 @@ data class UserState( val isLoggedIn: Boolean, val isVaultUnlocked: Boolean, val isVaultPendingUnlock: Boolean, + val needsPasswordReset: Boolean, val organizations: List, val isBiometricsEnabled: Boolean, val vaultUnlockType: VaultUnlockType = VaultUnlockType.MASTER_PASSWORD, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt index 182236176..6003172aa 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt @@ -73,8 +73,8 @@ fun UserStateJson.toUserState( isVaultUnlocked = vaultState.statusFor(userId) == VaultUnlockData.Status.UNLOCKED, isVaultPendingUnlock = vaultState.statusFor(userId) == - VaultUnlockData.Status.PENDING || - accountJson.profile.forcePasswordResetReason != null, + VaultUnlockData.Status.PENDING, + needsPasswordReset = accountJson.profile.forcePasswordResetReason != null, organizations = userOrganizationsList .find { it.userId == userId } ?.organizations diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordNavigation.kt new file mode 100644 index 000000000..0080ce493 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordNavigation.kt @@ -0,0 +1,26 @@ +package com.x8bit.bitwarden.ui.auth.feature.resetpassword + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions + +const val RESET_PASSWORD_ROUTE: String = "reset_password" + +/** + * Add the Reset Password screen to the nav graph. + */ +fun NavGraphBuilder.resetPasswordDestination() { + composableWithSlideTransitions( + route = RESET_PASSWORD_ROUTE, + ) { + ResetPasswordScreen() + } +} + +/** + * Navigate to the Reset Password screen. + */ +fun NavController.navigateToResetPasswordGraph(navOptions: NavOptions? = null) { + this.navigate(RESET_PASSWORD_ROUTE, navOptions) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordScreen.kt new file mode 100644 index 000000000..9b23636b0 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordScreen.kt @@ -0,0 +1,247 @@ +package com.x8bit.bitwarden.ui.auth.feature.resetpassword + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.BasicDialogState +import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog +import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog +import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar +import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField +import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton +import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField +import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog +import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState + +/** + * The top level composable for the Reset Password screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +@Suppress("LongMethod") +fun ResetPasswordScreen( + viewModel: ResetPasswordViewModel = hiltViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + when (val dialog = state.dialogState) { + is ResetPasswordState.DialogState.Error -> { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = dialog.title ?: R.string.an_error_has_occurred.asText(), + message = dialog.message, + ), + onDismissRequest = remember(viewModel) { + { viewModel.trySendAction(ResetPasswordAction.DialogDismiss) } + }, + ) + } + + is ResetPasswordState.DialogState.Loading -> { + BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown( + text = dialog.message, + ), + ) + } + + null -> Unit + } + + var shouldShowLogoutConfirmationDialog by remember { mutableStateOf(false) } + val onLogoutClicked = remember(viewModel) { + { viewModel.trySendAction(ResetPasswordAction.ConfirmLogoutClick) } + } + if (shouldShowLogoutConfirmationDialog) { + BitwardenTwoButtonDialog( + title = stringResource(id = R.string.log_out), + message = stringResource(id = R.string.logout_confirmation), + confirmButtonText = stringResource(id = R.string.yes), + dismissButtonText = stringResource(id = R.string.cancel), + onConfirmClick = { + shouldShowLogoutConfirmationDialog = false + onLogoutClicked() + }, + onDismissClick = { shouldShowLogoutConfirmationDialog = false }, + onDismissRequest = { shouldShowLogoutConfirmationDialog = false }, + ) + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenMediumTopAppBar( + title = stringResource(id = R.string.update_master_password), + scrollBehavior = scrollBehavior, + actions = { + BitwardenTextButton( + label = stringResource(id = R.string.log_out), + onClick = { shouldShowLogoutConfirmationDialog = true }, + ) + BitwardenTextButton( + label = stringResource(id = R.string.submit), + onClick = remember(viewModel) { + { viewModel.trySendAction(ResetPasswordAction.SubmitClick) } + }, + ) + }, + ) + }, + ) { innerPadding -> + ResetPasswordScreeContent( + state = state, + onCurrentPasswordInputChanged = remember(viewModel) { + { viewModel.trySendAction(ResetPasswordAction.CurrentPasswordInputChanged(it)) } + }, + onPasswordInputChanged = remember(viewModel) { + { viewModel.trySendAction(ResetPasswordAction.PasswordInputChanged(it)) } + }, + onRetypePasswordInputChanged = remember(viewModel) { + { viewModel.trySendAction(ResetPasswordAction.RetypePasswordInputChanged(it)) } + }, + onPasswordHintInputChanged = remember(viewModel) { + { viewModel.trySendAction(ResetPasswordAction.PasswordHintInputChanged(it)) } + }, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + } +} + +@Composable +@Suppress("LongMethod") +private fun ResetPasswordScreeContent( + state: ResetPasswordState, + onCurrentPasswordInputChanged: (String) -> Unit, + onPasswordInputChanged: (String) -> Unit, + onRetypePasswordInputChanged: (String) -> Unit, + onPasswordHintInputChanged: (String) -> Unit, + modifier: Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .imePadding() + .verticalScroll(rememberScrollState()), + ) { + + Text( + text = stringResource(id = R.string.update_weak_master_password_warning), + textAlign = TextAlign.Start, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(4.dp), + ) + .padding(16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + val passwordPolicyContent = listOf( + stringResource(id = R.string.master_password_policy_in_effect), + ) + .plus(state.policies.map { it() }) + .joinToString("\n • ") + Text( + text = passwordPolicyContent, + textAlign = TextAlign.Start, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(4.dp), + ) + .padding(16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + BitwardenPasswordField( + label = stringResource(id = R.string.current_master_password), + value = state.currentPasswordInput, + onValueChange = onCurrentPasswordInputChanged, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + BitwardenPasswordField( + label = stringResource(id = R.string.master_password), + value = state.passwordInput, + onValueChange = onPasswordInputChanged, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + BitwardenPasswordField( + label = stringResource(id = R.string.retype_master_password), + value = state.retypePasswordInput, + onValueChange = onRetypePasswordInputChanged, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + BitwardenTextField( + label = stringResource(id = R.string.master_password_hint), + value = state.passwordHintInput, + onValueChange = onPasswordHintInputChanged, + hint = stringResource(id = R.string.master_password_hint_description), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordViewModel.kt new file mode 100644 index 000000000..ba75cf03f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/resetpassword/ResetPasswordViewModel.kt @@ -0,0 +1,409 @@ +package com.x8bit.bitwarden.ui.auth.feature.resetpassword + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult +import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult +import com.x8bit.bitwarden.ui.auth.feature.resetpassword.util.toDisplayLabels +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * Manages application state for the Reset Password screen. + */ +@HiltViewModel +@Suppress("TooManyFunctions") +class ResetPasswordViewModel @Inject constructor( + private val authRepository: AuthRepository, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] + ?: ResetPasswordState( + policies = authRepository.passwordPolicies.toDisplayLabels(), + dialogState = null, + currentPasswordInput = "", + passwordInput = "", + retypePasswordInput = "", + passwordHintInput = "", + ), +) { + init { + // As state updates, write to saved state handle. + stateFlow + .onEach { savedStateHandle[KEY_STATE] = it } + .launchIn(viewModelScope) + } + + override fun handleAction(action: ResetPasswordAction) { + when (action) { + ResetPasswordAction.ConfirmLogoutClick -> handleConfirmLogoutClick() + ResetPasswordAction.SubmitClick -> handleSubmitClicked() + ResetPasswordAction.DialogDismiss -> handleDialogDismiss() + + is ResetPasswordAction.CurrentPasswordInputChanged -> { + handleCurrentPasswordInputChanged(action) + } + + is ResetPasswordAction.PasswordInputChanged -> handlePasswordInputChanged(action) + + is ResetPasswordAction.RetypePasswordInputChanged -> { + handleRetypePasswordInputChanged(action) + } + + is ResetPasswordAction.PasswordHintInputChanged -> { + handlePasswordHintInputChanged(action) + } + + is ResetPasswordAction.Internal.ReceiveResetPasswordResult -> { + handleReceiveResetPasswordResult(action) + } + + is ResetPasswordAction.Internal.ReceiveValidatePasswordAgainstPoliciesResult -> { + handleReceiveValidatePasswordAgainstPoliciesResult(action) + } + + is ResetPasswordAction.Internal.ReceiveValidatePasswordResult -> { + handleReceiveValidatePasswordResult(action) + } + } + } + + /** + * Dismiss the view if the user confirms logging out. + */ + private fun handleConfirmLogoutClick() { + authRepository.logout() + } + + /** + * Validate the user's current password when they submit. + */ + private fun handleSubmitClicked() { + // Display an error dialog if the new password field is blank. + val password = state.passwordInput + if (password.isBlank()) { + mutableStateFlow.update { + it.copy( + dialogState = ResetPasswordState.DialogState.Error( + title = null, + message = R.string.validation_field_required + .asText(R.string.master_password.asText()), + ), + ) + } + return + } + + // Check if the new password meets the policy requirements. + viewModelScope.launch { + val result = authRepository.validatePasswordAgainstPolicies(password) + sendAction( + ResetPasswordAction.Internal.ReceiveValidatePasswordAgainstPoliciesResult(result), + ) + } + } + + /** + * Dismiss the dialog state. + */ + private fun handleDialogDismiss() { + mutableStateFlow.update { + it.copy( + dialogState = null, + ) + } + } + + /** + * Update the state with the current password input. + */ + private fun handleCurrentPasswordInputChanged( + action: ResetPasswordAction.CurrentPasswordInputChanged, + ) { + mutableStateFlow.update { + it.copy( + currentPasswordInput = action.input, + ) + } + } + + /** + * Update the state with the new password input. + */ + private fun handlePasswordInputChanged(action: ResetPasswordAction.PasswordInputChanged) { + mutableStateFlow.update { + it.copy( + passwordInput = action.input, + ) + } + } + + /** + * Update the state with the re-typed password input. + */ + private fun handleRetypePasswordInputChanged( + action: ResetPasswordAction.RetypePasswordInputChanged, + ) { + mutableStateFlow.update { + it.copy( + retypePasswordInput = action.input, + ) + } + } + + /** + * Update the state with the password hint input. + */ + private fun handlePasswordHintInputChanged( + action: ResetPasswordAction.PasswordHintInputChanged, + ) { + mutableStateFlow.update { + it.copy( + passwordHintInput = action.input, + ) + } + } + + /** + * Show an alert if the reset password attempt failed. + */ + private fun handleReceiveResetPasswordResult( + action: ResetPasswordAction.Internal.ReceiveResetPasswordResult, + ) { + // End the loading state. + mutableStateFlow.update { it.copy(dialogState = null) } + + when (action.result) { + // Display an alert if there was an error. + ResetPasswordResult.Error -> { + mutableStateFlow.update { + it.copy( + dialogState = ResetPasswordState.DialogState.Error( + title = null, + message = R.string.generic_error_message.asText(), + ), + ) + } + } + + // NO-OP: The root nav view model will handle the completed auth flow. + ResetPasswordResult.Success -> {} + } + } + + /** + * Display an error if the current password is valid or if there was an error, and + * otherwise, reset the master password. + */ + private fun handleReceiveValidatePasswordResult( + action: ResetPasswordAction.Internal.ReceiveValidatePasswordResult, + ) { + when (action.result) { + // Display an alert if there was an error. + ValidatePasswordResult.Error -> { + mutableStateFlow.update { + it.copy( + dialogState = ResetPasswordState.DialogState.Error( + title = null, + message = R.string.generic_error_message.asText(), + ), + ) + } + } + + is ValidatePasswordResult.Success -> { + // Display an error dialog if the password is invalid. + if (!action.result.isValid) { + mutableStateFlow.update { + it.copy( + dialogState = ResetPasswordState.DialogState.Error( + title = null, + message = R.string.invalid_master_password.asText(), + ), + ) + } + } else { + // Show the loading dialog. + mutableStateFlow.update { + it.copy( + dialogState = ResetPasswordState.DialogState.Loading( + message = R.string.updating_password.asText(), + ), + ) + } + viewModelScope.launch { + val result = authRepository.resetPassword( + currentPassword = state.currentPasswordInput, + newPassword = state.passwordInput, + passwordHint = state.passwordHintInput, + ) + trySendAction( + ResetPasswordAction.Internal.ReceiveResetPasswordResult(result), + ) + } + } + } + } + } + + /** + * Display an alert if the password doesn't meet the policy requirements, then check that + * the new password matches the retyped password and that the current password is valid. + */ + private fun handleReceiveValidatePasswordAgainstPoliciesResult( + action: ResetPasswordAction.Internal.ReceiveValidatePasswordAgainstPoliciesResult, + ) { + // Display an error alert if the new password doesn't meet the policy requirements. + if (!action.meetsRequirements) { + mutableStateFlow.update { + it.copy( + dialogState = ResetPasswordState.DialogState.Error( + title = R.string.master_password_policy_validation_title.asText(), + message = R.string.master_password_policy_validation_message.asText(), + ), + ) + } + return + } + + // Display an error alert if the re-typed password doesn't match the new password. + if (state.passwordInput != state.retypePasswordInput) { + mutableStateFlow.update { + it.copy( + dialogState = ResetPasswordState.DialogState.Error( + title = null, + message = R.string.master_password_confirmation_val_message.asText(), + ), + ) + } + return + } + + // Check that the entered current password is correct. + viewModelScope.launch { + val currentPassword = state.currentPasswordInput + val result = authRepository.validatePassword(currentPassword) + trySendAction(ResetPasswordAction.Internal.ReceiveValidatePasswordResult(result)) + } + } +} + +/** + * Models state of the Reset Password screen. + */ +@Parcelize +data class ResetPasswordState( + val policies: List, + val dialogState: DialogState?, + val currentPasswordInput: String, + val passwordInput: String, + val retypePasswordInput: String, + val passwordHintInput: String, +) : Parcelable { + /** + * Represents the current state of any dialogs on the screen. + */ + sealed class DialogState : Parcelable { + /** + * Represents an error dialog with the given [message] and optional [title]. If no title + * is specified a default will be provided. + */ + @Parcelize + data class Error( + val title: Text? = null, + val message: Text, + ) : DialogState() + + /** + * Represents a loading dialog with the given [message]. + */ + @Parcelize + data class Loading( + val message: Text, + ) : DialogState() + } +} + +/** + * Models events for the Reset Password screen. + */ +sealed class ResetPasswordEvent + +/** + * Models actions for the Reset Password screen. + */ +sealed class ResetPasswordAction { + /** + * Indicates that the user has confirmed logging out. + */ + data object ConfirmLogoutClick : ResetPasswordAction() + + /** + * Indicates that the user has clicked the submit button. + */ + data object SubmitClick : ResetPasswordAction() + + /** + * Indicates that the dialog has been dismissed. + */ + data object DialogDismiss : ResetPasswordAction() + + /** + * Indicates that the current password input has changed. + */ + data class CurrentPasswordInputChanged(val input: String) : ResetPasswordAction() + + /** + * Indicates that the new password input has changed. + */ + data class PasswordInputChanged(val input: String) : ResetPasswordAction() + + /** + * Indicates that the re-type password input has changed. + */ + data class RetypePasswordInputChanged(val input: String) : ResetPasswordAction() + + /** + * Indicates that the password hint input has changed. + */ + data class PasswordHintInputChanged(val input: String) : ResetPasswordAction() + + /** + * Models actions that the [ResetPasswordViewModel] might send itself. + */ + sealed class Internal : ResetPasswordAction() { + /** + * Indicates that a reset password result has been received. + */ + data class ReceiveResetPasswordResult( + val result: ResetPasswordResult, + ) : Internal() + + /** + * Indicates that a validate password result has been received. + */ + data class ReceiveValidatePasswordResult( + val result: ValidatePasswordResult, + ) : Internal() + + /** + * Indicates that a validate password against policies result has been received. + */ + data class ReceiveValidatePasswordAgainstPoliciesResult( + val meetsRequirements: Boolean, + ) : Internal() + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/resetpassword/util/PolicyInformationMasterPasswordExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/resetpassword/util/PolicyInformationMasterPasswordExtensions.kt new file mode 100644 index 000000000..a3f38c43b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/resetpassword/util/PolicyInformationMasterPasswordExtensions.kt @@ -0,0 +1,40 @@ +package com.x8bit.bitwarden.ui.auth.feature.resetpassword.util + +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText + +/** + * Convert a list of master password policies into a list of text instructions + * for the user about what requirements the password must meet. + */ +fun List.toDisplayLabels(): List { + val list = mutableListOf() + + mapNotNull { it.minLength }.maxOrNull()?.let { + list.add(R.string.policy_in_effect_min_length.asText(it)) + } + + mapNotNull { it.minComplexity }.maxOrNull()?.let { + list.add(R.string.policy_in_effect_min_complexity.asText(it)) + } + + if (mapNotNull { it.requireUpper }.any { it }) { + list.add(R.string.policy_in_effect_uppercase.asText()) + } + + if (mapNotNull { it.requireLower }.any { it }) { + list.add(R.string.policy_in_effect_lowercase.asText()) + } + + if (mapNotNull { it.requireNumbers }.any { it }) { + list.add(R.string.policy_in_effect_numbers.asText()) + } + + if (mapNotNull { it.requireSpecial }.any { it }) { + list.add(R.string.policy_in_effect_special.asText()) + } + + return list +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index 4709a4be5..da5ca4af1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -14,6 +14,9 @@ import androidx.navigation.navOptions import com.x8bit.bitwarden.ui.auth.feature.auth.AUTH_GRAPH_ROUTE import com.x8bit.bitwarden.ui.auth.feature.auth.authGraph import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuthGraph +import com.x8bit.bitwarden.ui.auth.feature.resetpassword.RESET_PASSWORD_ROUTE +import com.x8bit.bitwarden.ui.auth.feature.resetpassword.navigateToResetPasswordGraph +import com.x8bit.bitwarden.ui.auth.feature.resetpassword.resetPasswordDestination import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.VAULT_UNLOCK_ROUTE import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.navigateToVaultUnlock import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.vaultUnlockDestination @@ -24,6 +27,8 @@ import com.x8bit.bitwarden.ui.platform.feature.splash.splashDestination import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.VAULT_UNLOCKED_GRAPH_ROUTE import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.navigateToVaultUnlockedGraph import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.vaultUnlockedGraph +import com.x8bit.bitwarden.ui.platform.theme.NonNullEnterTransitionProvider +import com.x8bit.bitwarden.ui.platform.theme.NonNullExitTransitionProvider import com.x8bit.bitwarden.ui.platform.theme.RootTransitionProviders import com.x8bit.bitwarden.ui.tools.feature.send.addsend.model.AddSendType import com.x8bit.bitwarden.ui.tools.feature.send.addsend.navigateToAddSend @@ -35,7 +40,7 @@ import java.util.concurrent.atomic.AtomicReference /** * Controls root level [NavHost] for the app. */ -@Suppress("LongMethod") +@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun RootNavScreen( viewModel: RootNavViewModel = hiltViewModel(), @@ -62,19 +67,21 @@ fun RootNavScreen( NavHost( navController = navController, startDestination = SPLASH_ROUTE, - enterTransition = RootTransitionProviders.Enter.fadeIn, - exitTransition = RootTransitionProviders.Exit.fadeOut, - popEnterTransition = RootTransitionProviders.Enter.fadeIn, - popExitTransition = RootTransitionProviders.Exit.fadeOut, + enterTransition = { this.targetState.destination.route.toEnterTransition()(this) }, + exitTransition = { this.targetState.destination.route.toExitTransition()(this) }, + popEnterTransition = { this.targetState.destination.route.toEnterTransition()(this) }, + popExitTransition = { this.targetState.destination.route.toExitTransition()(this) }, ) { splashDestination() authGraph(navController) + resetPasswordDestination() vaultUnlockDestination() vaultUnlockedGraph(navController) } val targetRoute = when (state) { RootNavState.Auth -> AUTH_GRAPH_ROUTE + RootNavState.ResetPassword -> RESET_PASSWORD_ROUTE RootNavState.Splash -> SPLASH_ROUTE RootNavState.VaultLocked -> VAULT_UNLOCK_ROUTE is RootNavState.VaultUnlocked, @@ -107,6 +114,7 @@ fun RootNavScreen( when (val currentState = state) { RootNavState.Auth -> navController.navigateToAuthGraph(rootNavOptions) + RootNavState.ResetPassword -> navController.navigateToResetPasswordGraph(rootNavOptions) RootNavState.Splash -> navController.navigateToSplash(rootNavOptions) RootNavState.VaultLocked -> navController.navigateToVaultUnlock(rootNavOptions) is RootNavState.VaultUnlocked -> navController.navigateToVaultUnlockedGraph(rootNavOptions) @@ -144,3 +152,19 @@ private fun NavDestination?.rootLevelRoute(): String? { } return parent.rootLevelRoute() } + +/** + * Define the enter transition for each route. + */ +private fun String?.toEnterTransition(): NonNullEnterTransitionProvider = when (this) { + RESET_PASSWORD_ROUTE -> RootTransitionProviders.Enter.slideUp + else -> RootTransitionProviders.Enter.fadeIn +} + +/** + * Define the exit transition for each route. + */ +private fun String?.toExitTransition(): NonNullExitTransitionProvider = when (this) { + RESET_PASSWORD_ROUTE -> RootTransitionProviders.Exit.slideDown + else -> RootTransitionProviders.Exit.fadeOut +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index 29b410a4d..18c7625d1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -59,6 +59,8 @@ class RootNavViewModel @Inject constructor( val userState = action.userState val specialCircumstance = action.specialCircumstance val updatedRootNavState = when { + userState?.activeAccount?.needsPasswordReset == true -> RootNavState.ResetPassword + userState == null || !userState.activeAccount.isLoggedIn || userState.activeAccount.isVaultPendingUnlock || @@ -99,6 +101,12 @@ sealed class RootNavState : Parcelable { @Parcelize data object Auth : RootNavState() + /** + * App should show reset password graph. + */ + @Parcelize + data object ResetPassword : RootNavState() + /** * App should show splash nav graph. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt index 75069d851..dfdc290f4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt @@ -21,7 +21,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext @@ -71,7 +70,7 @@ fun ExportVaultScreen( } var shouldShowConfirmationDialog by remember { mutableStateOf(false) } - var confirmExportVaultClicked = remember(viewModel) { + val confirmExportVaultClicked = remember(viewModel) { { viewModel.trySendAction(ExportVaultAction.ConfirmExportVaultClicked) } } if (shouldShowConfirmationDialog) { @@ -154,7 +153,6 @@ fun ExportVaultScreen( } } -@OptIn(ExperimentalComposeUiApi::class) @Composable private fun ExportVaultScreenContent( state: ExportVaultState, diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt index d3b36595e..75992f1db 100644 --- a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt @@ -263,6 +263,7 @@ class MainViewModelTest : BaseViewModelTest() { isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt index 19691da86..45d697775 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt @@ -7,7 +7,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintRespon import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson -import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailJsonRequest +import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson import com.x8bit.bitwarden.data.platform.base.BaseServiceTest import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json @@ -217,7 +218,7 @@ class AccountsServiceTest : BaseServiceTest() { val response = MockResponse().setBody("") server.enqueue(response) val result = service.resendVerificationCodeEmail( - body = ResendEmailJsonRequest( + body = ResendEmailRequestJson( deviceIdentifier = "3", email = "example@email.com", passwordHash = "37y4d8r379r4789nt387r39k3dr87nr93", @@ -227,6 +228,21 @@ class AccountsServiceTest : BaseServiceTest() { assertTrue(result.isSuccess) } + @Test + fun `resetPassword with empty response is success`() = runTest { + val response = MockResponse().setBody("") + server.enqueue(response) + val result = service.resetPassword( + body = ResetPasswordRequestJson( + currentPasswordHash = "", + newPasswordHash = "", + passwordHint = null, + key = "", + ), + ) + assertTrue(result.isSuccess) + } + companion object { private const val EMAIL = "email" private val registerRequestBody = RegisterRequestJson( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 4f7117e20..00639ae7c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -22,7 +22,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResp import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson -import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailJsonRequest +import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService @@ -53,11 +54,11 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult +import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType -import com.x8bit.bitwarden.data.auth.repository.model.createMockMasterPasswordPolicy import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations @@ -1940,6 +1941,155 @@ class AuthRepositoryTest { assertEquals(RegisterResult.Error(errorMessage = "message"), result) } + @Test + fun `resetPassword Success should return Success`() = runTest { + val currentPassword = "currentPassword" + val currentPasswordHash = "hashedCurrentPassword" + val password = "password" + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + coEvery { + authSdkSource.hashPassword( + email = ACCOUNT_1.profile.email, + password = currentPassword, + kdf = ACCOUNT_1.profile.toSdkParams(), + purpose = HashPurpose.SERVER_AUTHORIZATION, + ) + } returns currentPasswordHash.asSuccess() + coEvery { + authSdkSource.makeRegisterKeys( + email = ACCOUNT_1.profile.email, + password = password, + kdf = ACCOUNT_1.profile.toSdkParams(), + ) + } returns Result.success( + RegisterKeyResponse( + masterPasswordHash = PASSWORD_HASH, + encryptedUserKey = ENCRYPTED_USER_KEY, + keys = RsaKeyPair( + public = PUBLIC_KEY, + private = PRIVATE_KEY, + ), + ), + ) + coEvery { + accountsService.resetPassword( + body = ResetPasswordRequestJson( + currentPasswordHash = currentPasswordHash, + newPasswordHash = PASSWORD_HASH, + passwordHint = null, + key = ENCRYPTED_USER_KEY, + ), + ) + } returns Unit.asSuccess() + + val result = repository.resetPassword( + currentPassword = currentPassword, + newPassword = password, + passwordHint = null, + ) + + assertEquals( + ResetPasswordResult.Success, + result, + ) + coVerify { + authSdkSource.makeRegisterKeys( + email = ACCOUNT_1.profile.email, + password = password, + kdf = ACCOUNT_1.profile.toSdkParams(), + ) + accountsService.resetPassword( + body = ResetPasswordRequestJson( + currentPasswordHash = currentPasswordHash, + newPasswordHash = PASSWORD_HASH, + passwordHint = null, + key = ENCRYPTED_USER_KEY, + ), + ) + } + verify { + vaultRepository.completeUnlock(userId = USER_ID_1) + } + fakeAuthDiskSource.assertMasterPasswordHash( + userId = USER_ID_1, + passwordHash = PASSWORD_HASH, + ) + } + + @Test + fun `resetPassword Failure should return Error`() = runTest { + val currentPassword = "currentPassword" + val currentPasswordHash = "hashedCurrentPassword" + val password = "password" + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + coEvery { + authSdkSource.hashPassword( + email = ACCOUNT_1.profile.email, + password = currentPassword, + kdf = ACCOUNT_1.profile.toSdkParams(), + purpose = HashPurpose.SERVER_AUTHORIZATION, + ) + } returns currentPasswordHash.asSuccess() + coEvery { + authSdkSource.makeRegisterKeys( + email = ACCOUNT_1.profile.email, + password = password, + kdf = ACCOUNT_1.profile.toSdkParams(), + ) + } returns Result.success( + RegisterKeyResponse( + masterPasswordHash = PASSWORD_HASH, + encryptedUserKey = ENCRYPTED_USER_KEY, + keys = RsaKeyPair( + public = PUBLIC_KEY, + private = PRIVATE_KEY, + ), + ), + ) + coEvery { + accountsService.resetPassword( + body = ResetPasswordRequestJson( + currentPasswordHash = currentPasswordHash, + newPasswordHash = PASSWORD_HASH, + passwordHint = null, + key = ENCRYPTED_USER_KEY, + ), + ) + } returns Throwable("Fail").asFailure() + + val result = repository.resetPassword( + currentPassword = currentPassword, + newPassword = password, + passwordHint = null, + ) + + assertEquals( + ResetPasswordResult.Error, + result, + ) + coVerify { + authSdkSource.hashPassword( + email = ACCOUNT_1.profile.email, + password = currentPassword, + kdf = ACCOUNT_1.profile.toSdkParams(), + purpose = HashPurpose.SERVER_AUTHORIZATION, + ) + authSdkSource.makeRegisterKeys( + email = ACCOUNT_1.profile.email, + password = password, + kdf = ACCOUNT_1.profile.toSdkParams(), + ) + accountsService.resetPassword( + body = ResetPasswordRequestJson( + currentPasswordHash = currentPasswordHash, + newPasswordHash = PASSWORD_HASH, + passwordHint = null, + key = ENCRYPTED_USER_KEY, + ), + ) + } + } + @Test fun `passwordHintRequest with valid email should return Success`() = runTest { val email = "valid@example.com" @@ -2129,7 +2279,7 @@ class AuthRepositoryTest { // Resend the verification code email. coEvery { accountsService.resendVerificationCodeEmail( - body = ResendEmailJsonRequest( + body = ResendEmailRequestJson( deviceIdentifier = UNIQUE_APP_ID, email = EMAIL, passwordHash = PASSWORD_HASH, @@ -2141,7 +2291,7 @@ class AuthRepositoryTest { assertEquals(ResendEmailResult.Success, resendEmailResult) coVerify { accountsService.resendVerificationCodeEmail( - body = ResendEmailJsonRequest( + body = ResendEmailRequestJson( deviceIdentifier = UNIQUE_APP_ID, email = EMAIL, passwordHash = PASSWORD_HASH, @@ -2944,34 +3094,61 @@ class AuthRepositoryTest { @Test fun `validatePasswordAgainstPolicy validates password against policy requirements`() = runTest { - var policy = createMockMasterPasswordPolicy(minLength = 10) - assertFalse(repository.validatePasswordAgainstPolicy(password = "123", policy = policy)) + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + + // A helper method to set a policy in the store with the given parameters. + fun setPolicy( + minLength: Int = 0, + minComplexity: Int? = null, + requireUpper: Boolean = false, + requireLower: Boolean = false, + requireNumbers: Boolean = false, + requireSpecial: Boolean = false, + ) { + fakeAuthDiskSource.storePolicies( + userId = USER_ID_1, + policies = listOf( + createMockPolicy( + type = PolicyTypeJson.MASTER_PASSWORD, + isEnabled = true, + data = buildJsonObject { + put(key = "minLength", value = minLength) + put(key = "minComplexity", value = minComplexity) + put(key = "requireUpper", value = requireUpper) + put(key = "requireLower", value = requireLower) + put(key = "requireNumbers", value = requireNumbers) + put(key = "requireSpecial", value = requireSpecial) + put(key = "enforceOnLogin", value = true) + }, + ), + ), + ) + } + + setPolicy(minLength = 10) + assertFalse(repository.validatePasswordAgainstPolicies(password = "123")) val password = "simple" - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 coEvery { authSdkSource.passwordStrength( email = SINGLE_USER_STATE_1.activeAccount.profile.email, password = password, ) } returns Result.success(LEVEL_0) - policy = createMockMasterPasswordPolicy(minComplexity = 1) - assertFalse(repository.validatePasswordAgainstPolicy(password = password, policy = policy)) + setPolicy(minComplexity = 10) + assertFalse(repository.validatePasswordAgainstPolicies(password = password)) - policy = createMockMasterPasswordPolicy(requireUpper = true) - assertFalse(repository.validatePasswordAgainstPolicy(password = "lower", policy = policy)) + setPolicy(requireUpper = true) + assertFalse(repository.validatePasswordAgainstPolicies(password = "lower")) - policy = createMockMasterPasswordPolicy(requireLower = true) - assertFalse(repository.validatePasswordAgainstPolicy(password = "UPPER", policy = policy)) + setPolicy(requireLower = true) + assertFalse(repository.validatePasswordAgainstPolicies(password = "UPPER")) - policy = createMockMasterPasswordPolicy(requireNumbers = true) - assertFalse(repository.validatePasswordAgainstPolicy(password = "letters", policy = policy)) + setPolicy(requireNumbers = true) + assertFalse(repository.validatePasswordAgainstPolicies(password = "letters")) - policy = createMockMasterPasswordPolicy(requireSpecial = true) - assertFalse(repository.validatePasswordAgainstPolicy(password = "letters", policy = policy)) - - policy = createMockMasterPasswordPolicy(minLength = 5) - assertTrue(repository.validatePasswordAgainstPolicy(password = "password", policy = policy)) + setPolicy(requireSpecial = true) + assertFalse(repository.validatePasswordAgainstPolicies(password = "letters")) } companion object { diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt index 99e777282..5a7ef5aed 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt @@ -108,6 +108,7 @@ class UserStateJsonExtensionsTest { isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, organizations = listOf( Organization( id = "organizationId", @@ -183,6 +184,7 @@ class UserStateJsonExtensionsTest { isLoggedIn = false, isVaultUnlocked = false, isVaultPendingUnlock = false, + needsPasswordReset = false, organizations = listOf( Organization( id = "organizationId", diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt index 980028a5e..9be433dd2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt @@ -72,6 +72,7 @@ class LandingViewModelTest : BaseViewModelTest() { isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ), @@ -204,6 +205,7 @@ class LandingViewModelTest : BaseViewModelTest() { isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ) @@ -255,6 +257,7 @@ class LandingViewModelTest : BaseViewModelTest() { isLoggedIn = false, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt index 215b72d17..f4a8abd0e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModelTest.kt @@ -128,6 +128,7 @@ class LoginViewModelTest : BaseViewModelTest() { isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/resetPassword/ResetPasswordScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/resetPassword/ResetPasswordScreenTest.kt new file mode 100644 index 000000000..028a01f7a --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/resetPassword/ResetPasswordScreenTest.kt @@ -0,0 +1,180 @@ +package com.x8bit.bitwarden.ui.auth.feature.resetPassword + +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.isDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.auth.feature.resetpassword.ResetPasswordAction +import com.x8bit.bitwarden.ui.auth.feature.resetpassword.ResetPasswordEvent +import com.x8bit.bitwarden.ui.auth.feature.resetpassword.ResetPasswordScreen +import com.x8bit.bitwarden.ui.auth.feature.resetpassword.ResetPasswordState +import com.x8bit.bitwarden.ui.auth.feature.resetpassword.ResetPasswordViewModel +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.util.assertNoDialogExists +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Before +import org.junit.Test + +class ResetPasswordScreenTest : BaseComposeTest() { + private val mutableEventFlow = bufferedMutableSharedFlow() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + val viewModel = mockk(relaxed = true) { + every { eventFlow } returns mutableEventFlow + every { stateFlow } returns mutableStateFlow + } + + @Before + fun setUp() { + composeTestRule.setContent { + ResetPasswordScreen( + viewModel = viewModel, + ) + } + } + + @Test + fun `basicDialog should update according to state`() { + composeTestRule + .onNodeWithText("Error message") + .assertDoesNotExist() + composeTestRule.assertNoDialogExists() + + mutableStateFlow.update { + it.copy( + dialogState = ResetPasswordState.DialogState.Error( + title = null, + message = "Error message".asText(), + ), + ) + } + + composeTestRule + .onNodeWithText("Error message") + .assert(hasAnyAncestor(isDialog())) + .isDisplayed() + } + + @Test + fun `loadingDialog should update according to state`() { + composeTestRule.onNodeWithText("Loading...").assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialogState = ResetPasswordState.DialogState.Loading( + message = "Loading...".asText(), + ), + ) + } + + composeTestRule.onNodeWithText("Loading...").isDisplayed() + } + + @Test + fun `logout button click should display confirmation dialog and emit ConfirmLogoutClick`() { + composeTestRule.onNodeWithText("Log out").performClick() + + composeTestRule + .onNodeWithText("Are you sure you want to log out?") + .assert(hasAnyAncestor(isDialog())) + .isDisplayed() + + composeTestRule + .onNodeWithText("Yes") + .assert(hasAnyAncestor(isDialog())) + .performClick() + + composeTestRule.assertNoDialogExists() + verify { + viewModel.trySendAction(ResetPasswordAction.ConfirmLogoutClick) + } + } + + @Test + fun `submit button click should emit SubmitClick`() { + composeTestRule.onNodeWithText("Submit").performClick() + + verify { + viewModel.trySendAction(ResetPasswordAction.SubmitClick) + } + } + + @Test + @Suppress("MaxLineLength") + fun `password instructions should update according to state`() { + val baseString = + "One or more organization policies require your master password to meet the following requirements:" + composeTestRule + .onNodeWithText(baseString) + .assertIsDisplayed() + + mutableStateFlow.update { + it.copy( + policies = listOf("Make password better".asText()), + ) + } + + val updatedString = listOf( + baseString, + "Make password better", + ) + .joinToString("\n • ") + composeTestRule + .onNodeWithText(updatedString) + .assertIsDisplayed() + } + + @Test + fun `current password input change should send CurrentPasswordInputChanged action`() { + val input = "Test123" + composeTestRule.onNodeWithText("Current master password").performTextInput(input) + verify { + viewModel.trySendAction(ResetPasswordAction.CurrentPasswordInputChanged("Test123")) + } + } + + @Test + fun `password input change should send PasswordInputChange action`() { + val input = "Test123" + composeTestRule.onNodeWithText("Master password").performTextInput(input) + verify { + viewModel.trySendAction(ResetPasswordAction.PasswordInputChanged("Test123")) + } + } + + @Test + fun `retype password input change should send RetypePasswordInputChanged action`() { + val input = "Test123" + composeTestRule.onNodeWithText("Re-type master password").performTextInput(input) + verify { + viewModel.trySendAction(ResetPasswordAction.RetypePasswordInputChanged("Test123")) + } + } + + @Test + fun `password hint input change should send PasswordHintInputChanged action`() { + val input = "Test123" + composeTestRule.onNodeWithText("Master password hint (optional)").performTextInput(input) + verify { + viewModel.trySendAction(ResetPasswordAction.PasswordHintInputChanged("Test123")) + } + } +} + +private val DEFAULT_STATE = ResetPasswordState( + policies = emptyList(), + dialogState = null, + currentPasswordInput = "", + passwordInput = "", + retypePasswordInput = "", + passwordHintInput = "", +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/resetPassword/ResetPasswordViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/resetPassword/ResetPasswordViewModelTest.kt new file mode 100644 index 000000000..52f786998 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/resetPassword/ResetPasswordViewModelTest.kt @@ -0,0 +1,321 @@ +package com.x8bit.bitwarden.ui.auth.feature.resetPassword + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult +import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult +import com.x8bit.bitwarden.ui.auth.feature.resetpassword.ResetPasswordAction +import com.x8bit.bitwarden.ui.auth.feature.resetpassword.ResetPasswordState +import com.x8bit.bitwarden.ui.auth.feature.resetpassword.ResetPasswordViewModel +import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ResetPasswordViewModelTest : BaseViewModelTest() { + private val authRepository: AuthRepository = mockk() { + every { passwordPolicies } returns emptyList() + } + + private val savedStateHandle = SavedStateHandle() + + @Test + fun `ConfirmLogoutClick logs out`() = runTest { + every { authRepository.logout() } just runs + + val viewModel = createViewModel() + viewModel.trySendAction(ResetPasswordAction.ConfirmLogoutClick) + + verify { authRepository.logout() } + } + + @Test + fun `CurrentPasswordInputChanged should update the current password input in the state`() = + runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(ResetPasswordAction.CurrentPasswordInputChanged("Test123")) + + assertEquals( + DEFAULT_STATE.copy( + currentPasswordInput = "Test123", + ), + viewModel.stateFlow.value, + ) + } + } + + @Test + fun `SubmitClicked with blank password shows error alert`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(ResetPasswordAction.SubmitClick) + + assertEquals( + DEFAULT_STATE.copy( + dialogState = ResetPasswordState.DialogState.Error( + title = null, + message = R.string.validation_field_required + .asText(R.string.master_password.asText()), + ), + ), + viewModel.stateFlow.value, + ) + + // Dismiss the alert. + viewModel.trySendAction(ResetPasswordAction.DialogDismiss) + assertEquals( + DEFAULT_STATE, + viewModel.stateFlow.value, + ) + } + } + + @Test + fun `SubmitClicked with invalid password shows error alert`() = runTest { + val password = "Test123" + coEvery { + authRepository.validatePasswordAgainstPolicies(password) + } returns false + + val viewModel = createViewModel() + viewModel.trySendAction(ResetPasswordAction.PasswordInputChanged(password)) + viewModel.eventFlow.test { + viewModel.trySendAction(ResetPasswordAction.SubmitClick) + + assertEquals( + DEFAULT_STATE.copy( + dialogState = ResetPasswordState.DialogState.Error( + title = R.string.master_password_policy_validation_title.asText(), + message = R.string.master_password_policy_validation_message.asText(), + ), + passwordInput = password, + ), + viewModel.stateFlow.value, + ) + } + } + + @Test + fun `SubmitClicked with non-matching retyped password shows error alert`() = runTest { + val password = "Test123" + coEvery { + authRepository.validatePasswordAgainstPolicies(password) + } returns true + + val viewModel = createViewModel() + viewModel.trySendAction(ResetPasswordAction.PasswordInputChanged(password)) + + viewModel.eventFlow.test { + viewModel.trySendAction(ResetPasswordAction.SubmitClick) + + assertEquals( + DEFAULT_STATE.copy( + dialogState = ResetPasswordState.DialogState.Error( + title = null, + message = R.string.master_password_confirmation_val_message.asText(), + ), + passwordInput = password, + ), + viewModel.stateFlow.value, + ) + } + } + + @Test + fun `SubmitClicked with error for validating current password shows error alert`() = runTest { + val currentPassword = "CurrentTest123" + val password = "Test123" + coEvery { + authRepository.validatePasswordAgainstPolicies(password) + } returns true + coEvery { + authRepository.validatePassword(currentPassword) + } returns ValidatePasswordResult.Error + + val viewModel = createViewModel() + viewModel.trySendAction(ResetPasswordAction.CurrentPasswordInputChanged(currentPassword)) + viewModel.trySendAction(ResetPasswordAction.PasswordInputChanged(password)) + viewModel.trySendAction(ResetPasswordAction.RetypePasswordInputChanged(password)) + + viewModel.eventFlow.test { + viewModel.trySendAction(ResetPasswordAction.SubmitClick) + + assertEquals( + DEFAULT_STATE.copy( + dialogState = ResetPasswordState.DialogState.Error( + title = null, + message = R.string.generic_error_message.asText(), + ), + currentPasswordInput = currentPassword, + passwordInput = password, + retypePasswordInput = password, + ), + viewModel.stateFlow.value, + ) + } + } + + @Test + fun `SubmitClicked with invalid current password shows alert`() = runTest { + val currentPassword = "CurrentTest123" + val password = "Test123" + coEvery { + authRepository.validatePasswordAgainstPolicies(password) + } returns true + coEvery { + authRepository.validatePassword(currentPassword) + } returns ValidatePasswordResult.Success(isValid = false) + + val viewModel = createViewModel() + viewModel.trySendAction(ResetPasswordAction.CurrentPasswordInputChanged(currentPassword)) + viewModel.trySendAction(ResetPasswordAction.PasswordInputChanged(password)) + viewModel.trySendAction(ResetPasswordAction.RetypePasswordInputChanged(password)) + + viewModel.eventFlow.test { + viewModel.trySendAction(ResetPasswordAction.SubmitClick) + + assertEquals( + DEFAULT_STATE.copy( + dialogState = ResetPasswordState.DialogState.Error( + title = null, + message = R.string.invalid_master_password.asText(), + ), + currentPasswordInput = currentPassword, + passwordInput = password, + retypePasswordInput = password, + ), + viewModel.stateFlow.value, + ) + } + } + + @Test + fun `SubmitClicked with all valid inputs resets password`() = runTest { + val currentPassword = "CurrentTest123" + val password = "Test123" + coEvery { + authRepository.validatePasswordAgainstPolicies(password) + } returns true + coEvery { + authRepository.validatePassword(currentPassword) + } returns ValidatePasswordResult.Success(isValid = true) + coEvery { + authRepository.resetPassword(any(), any(), any()) + } returns ResetPasswordResult.Success + + val viewModel = createViewModel() + viewModel.trySendAction(ResetPasswordAction.CurrentPasswordInputChanged(currentPassword)) + viewModel.trySendAction(ResetPasswordAction.PasswordInputChanged(password)) + viewModel.trySendAction(ResetPasswordAction.RetypePasswordInputChanged(password)) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE.copy( + dialogState = null, + currentPasswordInput = currentPassword, + passwordInput = password, + retypePasswordInput = password, + ), + awaitItem(), + ) + + viewModel.trySendAction(ResetPasswordAction.SubmitClick) + + assertEquals( + DEFAULT_STATE.copy( + dialogState = ResetPasswordState.DialogState.Loading( + message = R.string.updating_password.asText(), + ), + currentPasswordInput = currentPassword, + passwordInput = password, + retypePasswordInput = password, + ), + awaitItem(), + ) + + assertEquals( + DEFAULT_STATE.copy( + dialogState = null, + currentPasswordInput = currentPassword, + passwordInput = password, + retypePasswordInput = password, + ), + awaitItem(), + ) + + coVerify { authRepository.resetPassword(any(), any(), any()) } + } + } + + @Test + fun `PasswordInputChanged should update the password input in the state`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(ResetPasswordAction.PasswordInputChanged("Test123")) + + assertEquals( + DEFAULT_STATE.copy( + passwordInput = "Test123", + ), + viewModel.stateFlow.value, + ) + } + } + + @Test + fun `RetypePasswordInputChanged should update the retype password input in the state`() = + runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(ResetPasswordAction.RetypePasswordInputChanged("Test123")) + + assertEquals( + DEFAULT_STATE.copy( + retypePasswordInput = "Test123", + ), + viewModel.stateFlow.value, + ) + } + } + + @Test + fun `PasswordHintInputChanged should update the password hint input in the state`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(ResetPasswordAction.PasswordHintInputChanged("Test123")) + + assertEquals( + DEFAULT_STATE.copy( + passwordHintInput = "Test123", + ), + viewModel.stateFlow.value, + ) + } + } + + private fun createViewModel(): ResetPasswordViewModel = + ResetPasswordViewModel( + authRepository = authRepository, + savedStateHandle = savedStateHandle, + ) +} + +private val DEFAULT_STATE = ResetPasswordState( + policies = emptyList(), + dialogState = null, + currentPasswordInput = "", + passwordInput = "", + retypePasswordInput = "", + passwordHintInput = "", +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/resetPassword/util/PolicyInformationMasterPasswordExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/resetPassword/util/PolicyInformationMasterPasswordExtensionsTest.kt new file mode 100644 index 000000000..b7ffa602b --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/resetPassword/util/PolicyInformationMasterPasswordExtensionsTest.kt @@ -0,0 +1,55 @@ +package com.x8bit.bitwarden.ui.auth.feature.resetPassword.util + +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.model.createMockMasterPasswordPolicy +import com.x8bit.bitwarden.ui.auth.feature.resetpassword.util.toDisplayLabels +import com.x8bit.bitwarden.ui.platform.base.util.asText +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class PolicyInformationMasterPasswordExtensionsTest { + @Test + fun `toDisplayLabels with multiple minLength values should choose highest value`() { + val policyList = listOf( + createMockMasterPasswordPolicy(minLength = null), + createMockMasterPasswordPolicy(minLength = 10), + createMockMasterPasswordPolicy(minLength = 2), + ) + assertEquals( + listOf(R.string.policy_in_effect_min_length.asText(10)), + policyList.toDisplayLabels(), + ) + } + + @Test + fun `toDisplayLabels with multiple minComplexity values should choose highest value`() { + val policyList = listOf( + createMockMasterPasswordPolicy(minComplexity = null), + createMockMasterPasswordPolicy(minComplexity = 1), + createMockMasterPasswordPolicy(minComplexity = 2), + ) + assertEquals( + listOf(R.string.policy_in_effect_min_complexity.asText(2)), + policyList.toDisplayLabels(), + ) + } + + @Test + fun `toDisplayLabels lists any nonNull requirements`() { + val policyList = listOf( + createMockMasterPasswordPolicy(requireUpper = true), + createMockMasterPasswordPolicy(requireLower = true), + createMockMasterPasswordPolicy(requireNumbers = true), + createMockMasterPasswordPolicy(requireSpecial = true), + ) + assertEquals( + listOf( + R.string.policy_in_effect_uppercase.asText(), + R.string.policy_in_effect_lowercase.asText(), + R.string.policy_in_effect_numbers.asText(), + R.string.policy_in_effect_special.asText(), + ), + policyList.toDisplayLabels(), + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt index 80f8b0eb6..64e2fe399 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt @@ -117,6 +117,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ), @@ -151,6 +152,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { isLoggedIn = true, isVaultUnlocked = false, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = true, organizations = emptyList(), ), @@ -728,6 +730,7 @@ private val DEFAULT_ACCOUNT = UserState.Account( isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt index 0e49f87db..be2f6cbce 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -82,6 +82,15 @@ class RootNavScreenTest : BaseComposeTest() { ) } + // Make sure navigating to reset password works as expected: + rootNavStateFlow.value = RootNavState.ResetPassword + composeTestRule.runOnIdle { + fakeNavHostController.assertLastNavigation( + route = "reset_password", + navOptions = expectedNavOptions, + ) + } + // Make sure navigating to vault unlocked works as expected: rootNavStateFlow.value = RootNavState.VaultUnlocked(activeUserId = "userId") composeTestRule.runOnIdle { diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index 0ab3c4978..1e1c56945 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -31,7 +31,6 @@ class RootNavViewModelTest : BaseViewModelTest() { assertEquals(RootNavState.Auth, viewModel.stateFlow.value) } - @Suppress("MaxLineLength") @Test fun `when the active user is not logged in the nav state should be Auth`() { mutableUserStateFlow.tryEmit( @@ -48,6 +47,7 @@ class RootNavViewModelTest : BaseViewModelTest() { isLoggedIn = false, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ), @@ -58,6 +58,33 @@ class RootNavViewModelTest : BaseViewModelTest() { assertEquals(RootNavState.Auth, viewModel.stateFlow.value) } + @Test + fun `when the active user needs a password reset the nav state should be ResetPassword`() { + mutableUserStateFlow.tryEmit( + UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "name", + email = "email", + avatarColorHex = "avatarColorHex", + environment = Environment.Us, + isPremium = true, + isLoggedIn = false, + isVaultUnlocked = false, + isVaultPendingUnlock = true, + needsPasswordReset = true, + isBiometricsEnabled = false, + organizations = emptyList(), + ), + ), + ), + ) + val viewModel = createViewModel() + assertEquals(RootNavState.ResetPassword, viewModel.stateFlow.value) + } + @Suppress("MaxLineLength") @Test fun `when the active user but there are pending account additions the nav state should be Auth`() { @@ -75,6 +102,7 @@ class RootNavViewModelTest : BaseViewModelTest() { isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ), @@ -102,6 +130,7 @@ class RootNavViewModelTest : BaseViewModelTest() { isLoggedIn = true, isVaultUnlocked = false, isVaultPendingUnlock = true, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ), @@ -131,6 +160,7 @@ class RootNavViewModelTest : BaseViewModelTest() { isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ), @@ -166,6 +196,7 @@ class RootNavViewModelTest : BaseViewModelTest() { isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ), @@ -205,6 +236,7 @@ class RootNavViewModelTest : BaseViewModelTest() { isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ), @@ -237,6 +269,7 @@ class RootNavViewModelTest : BaseViewModelTest() { isLoggedIn = true, isVaultUnlocked = false, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt index 538ff5163..e62fabde4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt @@ -975,6 +975,7 @@ private val DEFAULT_USER_STATE = UserState( isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModelTest.kt index bd84ec6a0..2996d2430 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModelTest.kt @@ -193,6 +193,7 @@ private val DEFAULT_USER_STATE = UserState( isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, organizations = emptyList(), ), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt index 13515db2c..be1967f9d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt @@ -1787,6 +1787,7 @@ private val DEFAULT_USER_STATE = UserState( isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt index 13aa77e45..51f8f8934 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt @@ -1003,6 +1003,7 @@ class AddSendViewModelTest : BaseViewModelTest() { isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index 24c9eec6c..40b4d8f11 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -2140,6 +2140,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { isLoggedIn = false, isVaultUnlocked = false, isVaultPendingUnlock = false, + needsPasswordReset = false, organizations = listOf( Organization( id = "organizationId", diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt index 5fd86fbba..8d2734dca 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt @@ -554,6 +554,7 @@ private val DEFAULT_USER_STATE = UserState( isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index c62e99a5e..8ed4c20e0 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -1498,6 +1498,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index 946f47822..442aae499 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -1373,6 +1373,7 @@ private val DEFAULT_ACCOUNT = UserState.Account( isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt index f7d23971f..1fa579e0c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt @@ -423,6 +423,7 @@ private val DEFAULT_USER_STATE = UserState( isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = listOf( Organization( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationExtensionsTest.kt index 26e2ba85e..dc3920ba4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationExtensionsTest.kt @@ -95,6 +95,7 @@ private fun createMockUserState(hasOrganizations: Boolean = true): UserState = isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = if (hasOrganizations) { listOf( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index f10ade721..963ac9318 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -163,6 +163,7 @@ class VaultViewModelTest : BaseViewModelTest() { isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = listOf( Organization( @@ -1281,6 +1282,7 @@ private val DEFAULT_USER_STATE = UserState( isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ), @@ -1294,6 +1296,7 @@ private val DEFAULT_USER_STATE = UserState( isLoggedIn = true, isVaultUnlocked = false, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensionsTest.kt index 94e6baa1e..144becd95 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensionsTest.kt @@ -70,6 +70,7 @@ class UserStateExtensionsTest { isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = listOf( Organization( @@ -88,6 +89,7 @@ class UserStateExtensionsTest { isLoggedIn = true, isVaultUnlocked = false, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = listOf( Organization( @@ -110,6 +112,7 @@ class UserStateExtensionsTest { isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = listOf( Organization( @@ -132,6 +135,7 @@ class UserStateExtensionsTest { isLoggedIn = false, isVaultUnlocked = false, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = listOf( Organization( @@ -169,6 +173,7 @@ class UserStateExtensionsTest { isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = listOf( Organization( @@ -204,6 +209,7 @@ class UserStateExtensionsTest { isLoggedIn = true, isVaultUnlocked = false, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = listOf( Organization( @@ -243,6 +249,7 @@ class UserStateExtensionsTest { isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = listOf( Organization( @@ -270,6 +277,7 @@ class UserStateExtensionsTest { isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = emptyList(), ) @@ -306,6 +314,7 @@ class UserStateExtensionsTest { isLoggedIn = true, isVaultUnlocked = true, isVaultPendingUnlock = false, + needsPasswordReset = false, isBiometricsEnabled = false, organizations = listOf( Organization(