BIT-620: Reset password screen (#871)

This commit is contained in:
Shannon Draeker 2024-01-30 13:19:37 -07:00 committed by Álison Fernandes
parent 94f532b9d2
commit 5fffd4e3e2
42 changed files with 1795 additions and 55 deletions

View file

@ -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.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson 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.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.Body
import retrofit2.http.POST import retrofit2.http.POST
@ -26,6 +26,6 @@ interface AccountsApi {
@POST("/two-factor/send-email-login") @POST("/two-factor/send-email-login")
suspend fun resendVerificationCodeEmail( suspend fun resendVerificationCodeEmail(
@Body body: ResendEmailJsonRequest, @Body body: ResendEmailRequestJson,
): Result<Unit> ): Result<Unit>
} }

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.datasource.network.api 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.DeleteAccountRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.HTTP import retrofit2.http.HTTP
@ -8,10 +9,15 @@ import retrofit2.http.HTTP
* Defines raw calls under the /accounts API with authentication applied. * Defines raw calls under the /accounts API with authentication applied.
*/ */
interface AuthenticatedAccountsApi { interface AuthenticatedAccountsApi {
/** /**
* Deletes the current account. * Deletes the current account.
*/ */
@HTTP(method = "DELETE", path = "/accounts", hasBody = true) @HTTP(method = "DELETE", path = "/accounts", hasBody = true)
suspend fun deleteAccount(@Body body: DeleteAccountRequestJson): Result<Unit> suspend fun deleteAccount(@Body body: DeleteAccountRequestJson): Result<Unit>
/**
* Resets the password.
*/
@HTTP(method = "POST", path = "/accounts/password", hasBody = true)
suspend fun resetPassword(@Body body: ResetPasswordRequestJson): Result<Unit>
} }

View file

@ -14,7 +14,7 @@ import kotlinx.serialization.Serializable
* @property ssoToken The sso token, if the user is logging in via single sign on. * @property ssoToken The sso token, if the user is logging in via single sign on.
*/ */
@Serializable @Serializable
data class ResendEmailJsonRequest( data class ResendEmailRequestJson(
@SerialName("DeviceIdentifier") @SerialName("DeviceIdentifier")
val deviceIdentifier: String, val deviceIdentifier: String,

View file

@ -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,
)

View file

@ -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.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson 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.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. * Provides an API for querying accounts endpoints.
@ -34,5 +35,10 @@ interface AccountsService {
/** /**
* Resend the email with the two-factor verification code. * Resend the email with the two-factor verification code.
*/ */
suspend fun resendVerificationCodeEmail(body: ResendEmailJsonRequest): Result<Unit> suspend fun resendVerificationCodeEmail(body: ResendEmailRequestJson): Result<Unit>
/**
* Reset the password.
*/
suspend fun resetPassword(body: ResetPasswordRequestJson): Result<Unit>
} }

View file

@ -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.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson 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.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.model.toBitwardenError
import com.x8bit.bitwarden.data.platform.datasource.network.util.parseErrorBodyOrNull import com.x8bit.bitwarden.data.platform.datasource.network.util.parseErrorBodyOrNull
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -60,6 +61,9 @@ class AccountsServiceImpl constructor(
?: throw throwable ?: throw throwable
} }
override suspend fun resendVerificationCodeEmail(body: ResendEmailJsonRequest): Result<Unit> = override suspend fun resendVerificationCodeEmail(body: ResendEmailRequestJson): Result<Unit> =
accountsApi.resendVerificationCodeEmail(body = body) accountsApi.resendVerificationCodeEmail(body = body)
override suspend fun resetPassword(body: ResetPasswordRequestJson): Result<Unit> =
authenticatedAccountsApi.resetPassword(body = body)
} }

View file

@ -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.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult 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.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.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
@ -78,6 +79,11 @@ interface AuthRepository : AuthenticatorProvider {
*/ */
var hasPendingAccountAddition: Boolean var hasPendingAccountAddition: Boolean
/**
* Return the cached password policies for the current user.
*/
val passwordPolicies: List<PolicyInformation.MasterPassword>
/** /**
* Clears the pending deletion state that occurs when the an account is successfully deleted. * Clears the pending deletion state that occurs when the an account is successfully deleted.
*/ */
@ -159,6 +165,16 @@ interface AuthRepository : AuthenticatorProvider {
email: String, email: String,
): PasswordHintResult ): 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]. * Set the value of [captchaTokenResultFlow].
*/ */
@ -230,10 +246,8 @@ interface AuthRepository : AuthenticatorProvider {
suspend fun validatePassword(password: String): ValidatePasswordResult 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( suspend fun validatePasswordAgainstPolicies(password: String): Boolean
password: String,
policy: PolicyInformation.MasterPassword,
): Boolean
} }

View file

@ -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.RefreshTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson 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.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.TwoFactorAuthMethod
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService 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.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult 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.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.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState 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. * 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 * The password that needs to be checked against any organization policies before
@ -218,6 +220,15 @@ class AuthRepositoryImpl(
override var hasPendingAccountAddition: Boolean override var hasPendingAccountAddition: Boolean
by mutableHasPendingAccountAdditionStateFlow::value by mutableHasPendingAccountAdditionStateFlow::value
override val passwordPolicies: List<PolicyInformation.MasterPassword>
get() = activeUserId?.let { userId ->
authDiskSource
.getPolicies(userId)
?.filter { it.type == PolicyTypeJson.MASTER_PASSWORD && it.isEnabled }
?.mapNotNull { it.policyInformation as? PolicyInformation.MasterPassword }
.orEmpty()
} ?: emptyList()
init { init {
pushManager pushManager
.syncOrgKeysFlow .syncOrgKeysFlow
@ -239,6 +250,10 @@ class AuthRepositoryImpl(
val userId = activeUserId ?: return@onEach val userId = activeUserId ?: return@onEach
if (passwordPassesPolicies(policies)) { if (passwordPassesPolicies(policies)) {
vaultRepository.completeUnlock(userId = userId) vaultRepository.completeUnlock(userId = userId)
storeUserResetPasswordReason(
userId = userId,
reason = null,
)
} else { } else {
storeUserResetPasswordReason( storeUserResetPasswordReason(
userId = userId, userId = userId,
@ -363,7 +378,7 @@ class AuthRepositoryImpl(
// Cache the data necessary for the remaining two-factor auth flow. // Cache the data necessary for the remaining two-factor auth flow.
identityTokenAuthModel = authModel identityTokenAuthModel = authModel
twoFactorResponse = loginResponse twoFactorResponse = loginResponse
resendEmailJsonRequest = ResendEmailJsonRequest( resendEmailRequestJson = ResendEmailRequestJson(
deviceIdentifier = authDiskSource.uniqueAppId, deviceIdentifier = authDiskSource.uniqueAppId,
email = email, email = email,
passwordHash = authModel.password, passwordHash = authModel.password,
@ -399,7 +414,7 @@ class AuthRepositoryImpl(
// Remove any cached data after successfully logging in. // Remove any cached data after successfully logging in.
identityTokenAuthModel = null identityTokenAuthModel = null
twoFactorResponse = null twoFactorResponse = null
resendEmailJsonRequest = null resendEmailRequestJson = null
// Attempt to unlock the vault if possible. // Attempt to unlock the vault if possible.
password?.let { password?.let {
@ -493,7 +508,7 @@ class AuthRepositoryImpl(
} }
override suspend fun resendVerificationCodeEmail(): ResendEmailResult = override suspend fun resendVerificationCodeEmail(): ResendEmailResult =
resendEmailJsonRequest?.let { jsonRequest -> resendEmailRequestJson?.let { jsonRequest ->
accountsService.resendVerificationCodeEmail(body = jsonRequest).fold( accountsService.resendVerificationCodeEmail(body = jsonRequest).fold(
onFailure = { ResendEmailResult.Error(message = it.message) }, onFailure = { ResendEmailResult.Error(message = it.message) },
onSuccess = { ResendEmailResult.Success }, 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) { override fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult) {
mutableCaptchaTokenFlow.tryEmit(tokenResult) mutableCaptchaTokenFlow.tryEmit(tokenResult)
} }
@ -854,7 +939,13 @@ class AuthRepositoryImpl(
} }
@Suppress("CyclomaticComplexMethod", "ReturnCount") @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, password: String,
policy: PolicyInformation.MasterPassword, policy: PolicyInformation.MasterPassword,
): Boolean { ): Boolean {
@ -908,10 +999,9 @@ class AuthRepositoryImpl(
.filter { it.enforceOnLogin == true } .filter { it.enforceOnLogin == true }
// Check the password against all the policies. // Check the password against all the policies.
val failingPolicies = passwordPolicies.filter { policy -> return passwordPolicies.all { policy ->
!validatePasswordAgainstPolicy(password, policy) validatePasswordAgainstPolicy(password, policy)
} }
return failingPolicies.isEmpty()
} }
private suspend fun getFingerprintPhrase( private suspend fun getFingerprintPhrase(

View file

@ -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()
}

View file

@ -42,6 +42,7 @@ data class UserState(
* @property isVaultUnlocked Whether or not the user's vault is currently unlocked. * @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 * @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. * 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 organizations List of [Organization]s the user is associated with, if any.
* @property isBiometricsEnabled Indicates that the biometrics mechanism for unlocking the * @property isBiometricsEnabled Indicates that the biometrics mechanism for unlocking the
* user's vault is enabled. * user's vault is enabled.
@ -57,6 +58,7 @@ data class UserState(
val isLoggedIn: Boolean, val isLoggedIn: Boolean,
val isVaultUnlocked: Boolean, val isVaultUnlocked: Boolean,
val isVaultPendingUnlock: Boolean, val isVaultPendingUnlock: Boolean,
val needsPasswordReset: Boolean,
val organizations: List<Organization>, val organizations: List<Organization>,
val isBiometricsEnabled: Boolean, val isBiometricsEnabled: Boolean,
val vaultUnlockType: VaultUnlockType = VaultUnlockType.MASTER_PASSWORD, val vaultUnlockType: VaultUnlockType = VaultUnlockType.MASTER_PASSWORD,

View file

@ -73,8 +73,8 @@ fun UserStateJson.toUserState(
isVaultUnlocked = vaultState.statusFor(userId) == isVaultUnlocked = vaultState.statusFor(userId) ==
VaultUnlockData.Status.UNLOCKED, VaultUnlockData.Status.UNLOCKED,
isVaultPendingUnlock = vaultState.statusFor(userId) == isVaultPendingUnlock = vaultState.statusFor(userId) ==
VaultUnlockData.Status.PENDING || VaultUnlockData.Status.PENDING,
accountJson.profile.forcePasswordResetReason != null, needsPasswordReset = accountJson.profile.forcePasswordResetReason != null,
organizations = userOrganizationsList organizations = userOrganizationsList
.find { it.userId == userId } .find { it.userId == userId }
?.organizations ?.organizations

View file

@ -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)
}

View file

@ -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))
}
}

View file

@ -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<ResetPasswordState, ResetPasswordEvent, ResetPasswordAction>(
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<Text>,
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()
}
}

View file

@ -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<PolicyInformation.MasterPassword>.toDisplayLabels(): List<Text> {
val list = mutableListOf<Text>()
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
}

View file

@ -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.AUTH_GRAPH_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.auth.authGraph 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.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.VAULT_UNLOCK_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.navigateToVaultUnlock import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.navigateToVaultUnlock
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.vaultUnlockDestination 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.VAULT_UNLOCKED_GRAPH_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.navigateToVaultUnlockedGraph import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.navigateToVaultUnlockedGraph
import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.vaultUnlockedGraph 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.platform.theme.RootTransitionProviders
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.model.AddSendType import com.x8bit.bitwarden.ui.tools.feature.send.addsend.model.AddSendType
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.navigateToAddSend 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. * Controls root level [NavHost] for the app.
*/ */
@Suppress("LongMethod") @Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable @Composable
fun RootNavScreen( fun RootNavScreen(
viewModel: RootNavViewModel = hiltViewModel(), viewModel: RootNavViewModel = hiltViewModel(),
@ -62,19 +67,21 @@ fun RootNavScreen(
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = SPLASH_ROUTE, startDestination = SPLASH_ROUTE,
enterTransition = RootTransitionProviders.Enter.fadeIn, enterTransition = { this.targetState.destination.route.toEnterTransition()(this) },
exitTransition = RootTransitionProviders.Exit.fadeOut, exitTransition = { this.targetState.destination.route.toExitTransition()(this) },
popEnterTransition = RootTransitionProviders.Enter.fadeIn, popEnterTransition = { this.targetState.destination.route.toEnterTransition()(this) },
popExitTransition = RootTransitionProviders.Exit.fadeOut, popExitTransition = { this.targetState.destination.route.toExitTransition()(this) },
) { ) {
splashDestination() splashDestination()
authGraph(navController) authGraph(navController)
resetPasswordDestination()
vaultUnlockDestination() vaultUnlockDestination()
vaultUnlockedGraph(navController) vaultUnlockedGraph(navController)
} }
val targetRoute = when (state) { val targetRoute = when (state) {
RootNavState.Auth -> AUTH_GRAPH_ROUTE RootNavState.Auth -> AUTH_GRAPH_ROUTE
RootNavState.ResetPassword -> RESET_PASSWORD_ROUTE
RootNavState.Splash -> SPLASH_ROUTE RootNavState.Splash -> SPLASH_ROUTE
RootNavState.VaultLocked -> VAULT_UNLOCK_ROUTE RootNavState.VaultLocked -> VAULT_UNLOCK_ROUTE
is RootNavState.VaultUnlocked, is RootNavState.VaultUnlocked,
@ -107,6 +114,7 @@ fun RootNavScreen(
when (val currentState = state) { when (val currentState = state) {
RootNavState.Auth -> navController.navigateToAuthGraph(rootNavOptions) RootNavState.Auth -> navController.navigateToAuthGraph(rootNavOptions)
RootNavState.ResetPassword -> navController.navigateToResetPasswordGraph(rootNavOptions)
RootNavState.Splash -> navController.navigateToSplash(rootNavOptions) RootNavState.Splash -> navController.navigateToSplash(rootNavOptions)
RootNavState.VaultLocked -> navController.navigateToVaultUnlock(rootNavOptions) RootNavState.VaultLocked -> navController.navigateToVaultUnlock(rootNavOptions)
is RootNavState.VaultUnlocked -> navController.navigateToVaultUnlockedGraph(rootNavOptions) is RootNavState.VaultUnlocked -> navController.navigateToVaultUnlockedGraph(rootNavOptions)
@ -144,3 +152,19 @@ private fun NavDestination?.rootLevelRoute(): String? {
} }
return parent.rootLevelRoute() 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
}

View file

@ -59,6 +59,8 @@ class RootNavViewModel @Inject constructor(
val userState = action.userState val userState = action.userState
val specialCircumstance = action.specialCircumstance val specialCircumstance = action.specialCircumstance
val updatedRootNavState = when { val updatedRootNavState = when {
userState?.activeAccount?.needsPasswordReset == true -> RootNavState.ResetPassword
userState == null || userState == null ||
!userState.activeAccount.isLoggedIn || !userState.activeAccount.isLoggedIn ||
userState.activeAccount.isVaultPendingUnlock || userState.activeAccount.isVaultPendingUnlock ||
@ -99,6 +101,12 @@ sealed class RootNavState : Parcelable {
@Parcelize @Parcelize
data object Auth : RootNavState() data object Auth : RootNavState()
/**
* App should show reset password graph.
*/
@Parcelize
data object ResetPassword : RootNavState()
/** /**
* App should show splash nav graph. * App should show splash nav graph.
*/ */

View file

@ -21,7 +21,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -71,7 +70,7 @@ fun ExportVaultScreen(
} }
var shouldShowConfirmationDialog by remember { mutableStateOf(false) } var shouldShowConfirmationDialog by remember { mutableStateOf(false) }
var confirmExportVaultClicked = remember(viewModel) { val confirmExportVaultClicked = remember(viewModel) {
{ viewModel.trySendAction(ExportVaultAction.ConfirmExportVaultClicked) } { viewModel.trySendAction(ExportVaultAction.ConfirmExportVaultClicked) }
} }
if (shouldShowConfirmationDialog) { if (shouldShowConfirmationDialog) {
@ -154,7 +153,6 @@ fun ExportVaultScreen(
} }
} }
@OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
private fun ExportVaultScreenContent( private fun ExportVaultScreenContent(
state: ExportVaultState, state: ExportVaultState,

View file

@ -263,6 +263,7 @@ class MainViewModelTest : BaseViewModelTest() {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
), ),

View file

@ -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.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson 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.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 com.x8bit.bitwarden.data.platform.base.BaseServiceTest
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -217,7 +218,7 @@ class AccountsServiceTest : BaseServiceTest() {
val response = MockResponse().setBody("") val response = MockResponse().setBody("")
server.enqueue(response) server.enqueue(response)
val result = service.resendVerificationCodeEmail( val result = service.resendVerificationCodeEmail(
body = ResendEmailJsonRequest( body = ResendEmailRequestJson(
deviceIdentifier = "3", deviceIdentifier = "3",
email = "example@email.com", email = "example@email.com",
passwordHash = "37y4d8r379r4789nt387r39k3dr87nr93", passwordHash = "37y4d8r379r4789nt387r39k3dr87nr93",
@ -227,6 +228,21 @@ class AccountsServiceTest : BaseServiceTest() {
assertTrue(result.isSuccess) 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 { companion object {
private const val EMAIL = "email" private const val EMAIL = "email"
private val registerRequestBody = RegisterRequestJson( private val registerRequestBody = RegisterRequestJson(

View file

@ -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.RefreshTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson 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.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.TwoFactorAuthMethod
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService 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.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult 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.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.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations 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.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType 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.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
@ -1940,6 +1941,155 @@ class AuthRepositoryTest {
assertEquals(RegisterResult.Error(errorMessage = "message"), result) 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 @Test
fun `passwordHintRequest with valid email should return Success`() = runTest { fun `passwordHintRequest with valid email should return Success`() = runTest {
val email = "valid@example.com" val email = "valid@example.com"
@ -2129,7 +2279,7 @@ class AuthRepositoryTest {
// Resend the verification code email. // Resend the verification code email.
coEvery { coEvery {
accountsService.resendVerificationCodeEmail( accountsService.resendVerificationCodeEmail(
body = ResendEmailJsonRequest( body = ResendEmailRequestJson(
deviceIdentifier = UNIQUE_APP_ID, deviceIdentifier = UNIQUE_APP_ID,
email = EMAIL, email = EMAIL,
passwordHash = PASSWORD_HASH, passwordHash = PASSWORD_HASH,
@ -2141,7 +2291,7 @@ class AuthRepositoryTest {
assertEquals(ResendEmailResult.Success, resendEmailResult) assertEquals(ResendEmailResult.Success, resendEmailResult)
coVerify { coVerify {
accountsService.resendVerificationCodeEmail( accountsService.resendVerificationCodeEmail(
body = ResendEmailJsonRequest( body = ResendEmailRequestJson(
deviceIdentifier = UNIQUE_APP_ID, deviceIdentifier = UNIQUE_APP_ID,
email = EMAIL, email = EMAIL,
passwordHash = PASSWORD_HASH, passwordHash = PASSWORD_HASH,
@ -2944,34 +3094,61 @@ class AuthRepositoryTest {
@Test @Test
fun `validatePasswordAgainstPolicy validates password against policy requirements`() = runTest { fun `validatePasswordAgainstPolicy validates password against policy requirements`() = runTest {
var policy = createMockMasterPasswordPolicy(minLength = 10) fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
assertFalse(repository.validatePasswordAgainstPolicy(password = "123", policy = policy))
// 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" val password = "simple"
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
coEvery { coEvery {
authSdkSource.passwordStrength( authSdkSource.passwordStrength(
email = SINGLE_USER_STATE_1.activeAccount.profile.email, email = SINGLE_USER_STATE_1.activeAccount.profile.email,
password = password, password = password,
) )
} returns Result.success(LEVEL_0) } returns Result.success(LEVEL_0)
policy = createMockMasterPasswordPolicy(minComplexity = 1) setPolicy(minComplexity = 10)
assertFalse(repository.validatePasswordAgainstPolicy(password = password, policy = policy)) assertFalse(repository.validatePasswordAgainstPolicies(password = password))
policy = createMockMasterPasswordPolicy(requireUpper = true) setPolicy(requireUpper = true)
assertFalse(repository.validatePasswordAgainstPolicy(password = "lower", policy = policy)) assertFalse(repository.validatePasswordAgainstPolicies(password = "lower"))
policy = createMockMasterPasswordPolicy(requireLower = true) setPolicy(requireLower = true)
assertFalse(repository.validatePasswordAgainstPolicy(password = "UPPER", policy = policy)) assertFalse(repository.validatePasswordAgainstPolicies(password = "UPPER"))
policy = createMockMasterPasswordPolicy(requireNumbers = true) setPolicy(requireNumbers = true)
assertFalse(repository.validatePasswordAgainstPolicy(password = "letters", policy = policy)) assertFalse(repository.validatePasswordAgainstPolicies(password = "letters"))
policy = createMockMasterPasswordPolicy(requireSpecial = true) setPolicy(requireSpecial = true)
assertFalse(repository.validatePasswordAgainstPolicy(password = "letters", policy = policy)) assertFalse(repository.validatePasswordAgainstPolicies(password = "letters"))
policy = createMockMasterPasswordPolicy(minLength = 5)
assertTrue(repository.validatePasswordAgainstPolicy(password = "password", policy = policy))
} }
companion object { companion object {

View file

@ -108,6 +108,7 @@ class UserStateJsonExtensionsTest {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
organizations = listOf( organizations = listOf(
Organization( Organization(
id = "organizationId", id = "organizationId",
@ -183,6 +184,7 @@ class UserStateJsonExtensionsTest {
isLoggedIn = false, isLoggedIn = false,
isVaultUnlocked = false, isVaultUnlocked = false,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
organizations = listOf( organizations = listOf(
Organization( Organization(
id = "organizationId", id = "organizationId",

View file

@ -72,6 +72,7 @@ class LandingViewModelTest : BaseViewModelTest() {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
), ),
@ -204,6 +205,7 @@ class LandingViewModelTest : BaseViewModelTest() {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
) )
@ -255,6 +257,7 @@ class LandingViewModelTest : BaseViewModelTest() {
isLoggedIn = false, isLoggedIn = false,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
) )

View file

@ -128,6 +128,7 @@ class LoginViewModelTest : BaseViewModelTest() {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
), ),

View file

@ -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<ResetPasswordEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
val viewModel = mockk<ResetPasswordViewModel>(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 = "",
)

View file

@ -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 = "",
)

View file

@ -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(),
)
}
}

View file

@ -117,6 +117,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
), ),
@ -151,6 +152,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = false, isVaultUnlocked = false,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = true, isBiometricsEnabled = true,
organizations = emptyList(), organizations = emptyList(),
), ),
@ -728,6 +730,7 @@ private val DEFAULT_ACCOUNT = UserState.Account(
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
) )

View file

@ -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: // Make sure navigating to vault unlocked works as expected:
rootNavStateFlow.value = RootNavState.VaultUnlocked(activeUserId = "userId") rootNavStateFlow.value = RootNavState.VaultUnlocked(activeUserId = "userId")
composeTestRule.runOnIdle { composeTestRule.runOnIdle {

View file

@ -31,7 +31,6 @@ class RootNavViewModelTest : BaseViewModelTest() {
assertEquals(RootNavState.Auth, viewModel.stateFlow.value) assertEquals(RootNavState.Auth, viewModel.stateFlow.value)
} }
@Suppress("MaxLineLength")
@Test @Test
fun `when the active user is not logged in the nav state should be Auth`() { fun `when the active user is not logged in the nav state should be Auth`() {
mutableUserStateFlow.tryEmit( mutableUserStateFlow.tryEmit(
@ -48,6 +47,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
isLoggedIn = false, isLoggedIn = false,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
), ),
@ -58,6 +58,33 @@ class RootNavViewModelTest : BaseViewModelTest() {
assertEquals(RootNavState.Auth, viewModel.stateFlow.value) 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") @Suppress("MaxLineLength")
@Test @Test
fun `when the active user but there are pending account additions the nav state should be Auth`() { 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, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
), ),
@ -102,6 +130,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = false, isVaultUnlocked = false,
isVaultPendingUnlock = true, isVaultPendingUnlock = true,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
), ),
@ -131,6 +160,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
), ),
@ -166,6 +196,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
), ),
@ -205,6 +236,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
), ),
@ -237,6 +269,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = false, isVaultUnlocked = false,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
), ),

View file

@ -975,6 +975,7 @@ private val DEFAULT_USER_STATE = UserState(
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
), ),

View file

@ -193,6 +193,7 @@ private val DEFAULT_USER_STATE = UserState(
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
organizations = emptyList(), organizations = emptyList(),
), ),
), ),

View file

@ -1787,6 +1787,7 @@ private val DEFAULT_USER_STATE = UserState(
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
), ),

View file

@ -1003,6 +1003,7 @@ class AddSendViewModelTest : BaseViewModelTest() {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
) )

View file

@ -2140,6 +2140,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
isLoggedIn = false, isLoggedIn = false,
isVaultUnlocked = false, isVaultUnlocked = false,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
organizations = listOf( organizations = listOf(
Organization( Organization(
id = "organizationId", id = "organizationId",

View file

@ -554,6 +554,7 @@ private val DEFAULT_USER_STATE = UserState(
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
), ),

View file

@ -1498,6 +1498,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
), ),

View file

@ -1373,6 +1373,7 @@ private val DEFAULT_ACCOUNT = UserState.Account(
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
) )

View file

@ -423,6 +423,7 @@ private val DEFAULT_USER_STATE = UserState(
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = listOf( organizations = listOf(
Organization( Organization(

View file

@ -95,6 +95,7 @@ private fun createMockUserState(hasOrganizations: Boolean = true): UserState =
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = if (hasOrganizations) { organizations = if (hasOrganizations) {
listOf( listOf(

View file

@ -163,6 +163,7 @@ class VaultViewModelTest : BaseViewModelTest() {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = listOf( organizations = listOf(
Organization( Organization(
@ -1281,6 +1282,7 @@ private val DEFAULT_USER_STATE = UserState(
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
), ),
@ -1294,6 +1296,7 @@ private val DEFAULT_USER_STATE = UserState(
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = false, isVaultUnlocked = false,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
), ),

View file

@ -70,6 +70,7 @@ class UserStateExtensionsTest {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = listOf( organizations = listOf(
Organization( Organization(
@ -88,6 +89,7 @@ class UserStateExtensionsTest {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = false, isVaultUnlocked = false,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = listOf( organizations = listOf(
Organization( Organization(
@ -110,6 +112,7 @@ class UserStateExtensionsTest {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = listOf( organizations = listOf(
Organization( Organization(
@ -132,6 +135,7 @@ class UserStateExtensionsTest {
isLoggedIn = false, isLoggedIn = false,
isVaultUnlocked = false, isVaultUnlocked = false,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = listOf( organizations = listOf(
Organization( Organization(
@ -169,6 +173,7 @@ class UserStateExtensionsTest {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = listOf( organizations = listOf(
Organization( Organization(
@ -204,6 +209,7 @@ class UserStateExtensionsTest {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = false, isVaultUnlocked = false,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = listOf( organizations = listOf(
Organization( Organization(
@ -243,6 +249,7 @@ class UserStateExtensionsTest {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = listOf( organizations = listOf(
Organization( Organization(
@ -270,6 +277,7 @@ class UserStateExtensionsTest {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = emptyList(), organizations = emptyList(),
) )
@ -306,6 +314,7 @@ class UserStateExtensionsTest {
isLoggedIn = true, isLoggedIn = true,
isVaultUnlocked = true, isVaultUnlocked = true,
isVaultPendingUnlock = false, isVaultPendingUnlock = false,
needsPasswordReset = false,
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = listOf( organizations = listOf(
Organization( Organization(