mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
BIT-620: Reset password screen (#871)
This commit is contained in:
parent
94f532b9d2
commit
5fffd4e3e2
42 changed files with 1795 additions and 55 deletions
|
@ -5,7 +5,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJso
|
|||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailJsonRequest
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
|
@ -26,6 +26,6 @@ interface AccountsApi {
|
|||
|
||||
@POST("/two-factor/send-email-login")
|
||||
suspend fun resendVerificationCodeEmail(
|
||||
@Body body: ResendEmailJsonRequest,
|
||||
@Body body: ResendEmailRequestJson,
|
||||
): Result<Unit>
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.x8bit.bitwarden.data.auth.datasource.network.api
|
||||
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.HTTP
|
||||
|
||||
|
@ -8,10 +9,15 @@ import retrofit2.http.HTTP
|
|||
* Defines raw calls under the /accounts API with authentication applied.
|
||||
*/
|
||||
interface AuthenticatedAccountsApi {
|
||||
|
||||
/**
|
||||
* Deletes the current account.
|
||||
*/
|
||||
@HTTP(method = "DELETE", path = "/accounts", hasBody = true)
|
||||
suspend fun deleteAccount(@Body body: DeleteAccountRequestJson): Result<Unit>
|
||||
|
||||
/**
|
||||
* Resets the password.
|
||||
*/
|
||||
@HTTP(method = "POST", path = "/accounts/password", hasBody = true)
|
||||
suspend fun resetPassword(@Body body: ResetPasswordRequestJson): Result<Unit>
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ import kotlinx.serialization.Serializable
|
|||
* @property ssoToken The sso token, if the user is logging in via single sign on.
|
||||
*/
|
||||
@Serializable
|
||||
data class ResendEmailJsonRequest(
|
||||
data class ResendEmailRequestJson(
|
||||
@SerialName("DeviceIdentifier")
|
||||
val deviceIdentifier: String,
|
||||
|
|
@ -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,
|
||||
)
|
|
@ -4,7 +4,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintRespon
|
|||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailJsonRequest
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson
|
||||
|
||||
/**
|
||||
* Provides an API for querying accounts endpoints.
|
||||
|
@ -34,5 +35,10 @@ interface AccountsService {
|
|||
/**
|
||||
* Resend the email with the two-factor verification code.
|
||||
*/
|
||||
suspend fun resendVerificationCodeEmail(body: ResendEmailJsonRequest): Result<Unit>
|
||||
suspend fun resendVerificationCodeEmail(body: ResendEmailRequestJson): Result<Unit>
|
||||
|
||||
/**
|
||||
* Reset the password.
|
||||
*/
|
||||
suspend fun resetPassword(body: ResetPasswordRequestJson): Result<Unit>
|
||||
}
|
||||
|
|
|
@ -9,7 +9,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJso
|
|||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailJsonRequest
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenError
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.util.parseErrorBodyOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
|
@ -60,6 +61,9 @@ class AccountsServiceImpl constructor(
|
|||
?: throw throwable
|
||||
}
|
||||
|
||||
override suspend fun resendVerificationCodeEmail(body: ResendEmailJsonRequest): Result<Unit> =
|
||||
override suspend fun resendVerificationCodeEmail(body: ResendEmailRequestJson): Result<Unit> =
|
||||
accountsApi.resendVerificationCodeEmail(body = body)
|
||||
|
||||
override suspend fun resetPassword(body: ResetPasswordRequestJson): Result<Unit> =
|
||||
authenticatedAccountsApi.resetPassword(body = body)
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
|
|||
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
|
@ -78,6 +79,11 @@ interface AuthRepository : AuthenticatorProvider {
|
|||
*/
|
||||
var hasPendingAccountAddition: Boolean
|
||||
|
||||
/**
|
||||
* Return the cached password policies for the current user.
|
||||
*/
|
||||
val passwordPolicies: List<PolicyInformation.MasterPassword>
|
||||
|
||||
/**
|
||||
* Clears the pending deletion state that occurs when the an account is successfully deleted.
|
||||
*/
|
||||
|
@ -159,6 +165,16 @@ interface AuthRepository : AuthenticatorProvider {
|
|||
email: String,
|
||||
): PasswordHintResult
|
||||
|
||||
/**
|
||||
* Resets the users password from the [currentPassword] to the [newPassword] and
|
||||
* optional [passwordHint].
|
||||
*/
|
||||
suspend fun resetPassword(
|
||||
currentPassword: String,
|
||||
newPassword: String,
|
||||
passwordHint: String?,
|
||||
): ResetPasswordResult
|
||||
|
||||
/**
|
||||
* Set the value of [captchaTokenResultFlow].
|
||||
*/
|
||||
|
@ -230,10 +246,8 @@ interface AuthRepository : AuthenticatorProvider {
|
|||
suspend fun validatePassword(password: String): ValidatePasswordResult
|
||||
|
||||
/**
|
||||
* Validates the given [password] against a MasterPassword [policy].
|
||||
* Validates the given [password] against the master password
|
||||
* policies for the current user.
|
||||
*/
|
||||
suspend fun validatePasswordAgainstPolicy(
|
||||
password: String,
|
||||
policy: PolicyInformation.MasterPassword,
|
||||
): Boolean
|
||||
suspend fun validatePasswordAgainstPolicies(password: String): Boolean
|
||||
}
|
||||
|
|
|
@ -14,7 +14,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintRespon
|
|||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailJsonRequest
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
|
||||
|
@ -43,6 +44,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
|
|||
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
|
@ -121,7 +123,7 @@ class AuthRepositoryImpl(
|
|||
/**
|
||||
* The information necessary to resend the verification code email for two-factor login.
|
||||
*/
|
||||
private var resendEmailJsonRequest: ResendEmailJsonRequest? = null
|
||||
private var resendEmailRequestJson: ResendEmailRequestJson? = null
|
||||
|
||||
/**
|
||||
* The password that needs to be checked against any organization policies before
|
||||
|
@ -218,6 +220,15 @@ class AuthRepositoryImpl(
|
|||
override var hasPendingAccountAddition: Boolean
|
||||
by mutableHasPendingAccountAdditionStateFlow::value
|
||||
|
||||
override val passwordPolicies: List<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 {
|
||||
pushManager
|
||||
.syncOrgKeysFlow
|
||||
|
@ -239,6 +250,10 @@ class AuthRepositoryImpl(
|
|||
val userId = activeUserId ?: return@onEach
|
||||
if (passwordPassesPolicies(policies)) {
|
||||
vaultRepository.completeUnlock(userId = userId)
|
||||
storeUserResetPasswordReason(
|
||||
userId = userId,
|
||||
reason = null,
|
||||
)
|
||||
} else {
|
||||
storeUserResetPasswordReason(
|
||||
userId = userId,
|
||||
|
@ -363,7 +378,7 @@ class AuthRepositoryImpl(
|
|||
// Cache the data necessary for the remaining two-factor auth flow.
|
||||
identityTokenAuthModel = authModel
|
||||
twoFactorResponse = loginResponse
|
||||
resendEmailJsonRequest = ResendEmailJsonRequest(
|
||||
resendEmailRequestJson = ResendEmailRequestJson(
|
||||
deviceIdentifier = authDiskSource.uniqueAppId,
|
||||
email = email,
|
||||
passwordHash = authModel.password,
|
||||
|
@ -399,7 +414,7 @@ class AuthRepositoryImpl(
|
|||
// Remove any cached data after successfully logging in.
|
||||
identityTokenAuthModel = null
|
||||
twoFactorResponse = null
|
||||
resendEmailJsonRequest = null
|
||||
resendEmailRequestJson = null
|
||||
|
||||
// Attempt to unlock the vault if possible.
|
||||
password?.let {
|
||||
|
@ -493,7 +508,7 @@ class AuthRepositoryImpl(
|
|||
}
|
||||
|
||||
override suspend fun resendVerificationCodeEmail(): ResendEmailResult =
|
||||
resendEmailJsonRequest?.let { jsonRequest ->
|
||||
resendEmailRequestJson?.let { jsonRequest ->
|
||||
accountsService.resendVerificationCodeEmail(body = jsonRequest).fold(
|
||||
onFailure = { ResendEmailResult.Error(message = it.message) },
|
||||
onSuccess = { ResendEmailResult.Success },
|
||||
|
@ -628,6 +643,76 @@ class AuthRepositoryImpl(
|
|||
)
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
override suspend fun resetPassword(
|
||||
currentPassword: String,
|
||||
newPassword: String,
|
||||
passwordHint: String?,
|
||||
): ResetPasswordResult {
|
||||
val activeAccount = authDiskSource
|
||||
.userState
|
||||
?.activeAccount
|
||||
?: return ResetPasswordResult.Error
|
||||
val currentPasswordHash = authSdkSource
|
||||
.hashPassword(
|
||||
email = activeAccount.profile.email,
|
||||
password = currentPassword,
|
||||
kdf = activeAccount.profile.toSdkParams(),
|
||||
purpose = HashPurpose.SERVER_AUTHORIZATION,
|
||||
)
|
||||
.fold(
|
||||
onFailure = { return ResetPasswordResult.Error },
|
||||
onSuccess = { it },
|
||||
)
|
||||
return authSdkSource
|
||||
.makeRegisterKeys(
|
||||
email = activeAccount.profile.email,
|
||||
password = newPassword,
|
||||
kdf = activeAccount.profile.toSdkParams(),
|
||||
)
|
||||
.flatMap { registerKeyResponse ->
|
||||
accountsService.resetPassword(
|
||||
body = ResetPasswordRequestJson(
|
||||
currentPasswordHash = currentPasswordHash,
|
||||
newPasswordHash = registerKeyResponse.masterPasswordHash,
|
||||
passwordHint = passwordHint,
|
||||
key = registerKeyResponse.encryptedUserKey,
|
||||
),
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
onSuccess = {
|
||||
// Clear the password reset reason, since it's no longer relevant.
|
||||
storeUserResetPasswordReason(
|
||||
userId = activeAccount.profile.userId,
|
||||
reason = null,
|
||||
)
|
||||
|
||||
// Update the saved master password hash.
|
||||
authSdkSource
|
||||
.hashPassword(
|
||||
email = activeAccount.profile.email,
|
||||
password = newPassword,
|
||||
kdf = activeAccount.profile.toSdkParams(),
|
||||
purpose = HashPurpose.LOCAL_AUTHORIZATION,
|
||||
)
|
||||
.onSuccess { passwordHash ->
|
||||
authDiskSource.storeMasterPasswordHash(
|
||||
userId = activeAccount.profile.userId,
|
||||
passwordHash = passwordHash,
|
||||
)
|
||||
}
|
||||
|
||||
// Complete the login flow.
|
||||
vaultRepository.completeUnlock(userId = activeAccount.profile.userId)
|
||||
|
||||
// Return the success.
|
||||
ResetPasswordResult.Success
|
||||
},
|
||||
onFailure = { ResetPasswordResult.Error },
|
||||
)
|
||||
}
|
||||
|
||||
override fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult) {
|
||||
mutableCaptchaTokenFlow.tryEmit(tokenResult)
|
||||
}
|
||||
|
@ -854,7 +939,13 @@ class AuthRepositoryImpl(
|
|||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "ReturnCount")
|
||||
override suspend fun validatePasswordAgainstPolicy(
|
||||
override suspend fun validatePasswordAgainstPolicies(
|
||||
password: String,
|
||||
): Boolean = passwordPolicies
|
||||
.all { validatePasswordAgainstPolicy(password, it) }
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "ReturnCount")
|
||||
private suspend fun validatePasswordAgainstPolicy(
|
||||
password: String,
|
||||
policy: PolicyInformation.MasterPassword,
|
||||
): Boolean {
|
||||
|
@ -908,10 +999,9 @@ class AuthRepositoryImpl(
|
|||
.filter { it.enforceOnLogin == true }
|
||||
|
||||
// Check the password against all the policies.
|
||||
val failingPolicies = passwordPolicies.filter { policy ->
|
||||
!validatePasswordAgainstPolicy(password, policy)
|
||||
return passwordPolicies.all { policy ->
|
||||
validatePasswordAgainstPolicy(password, policy)
|
||||
}
|
||||
return failingPolicies.isEmpty()
|
||||
}
|
||||
|
||||
private suspend fun getFingerprintPhrase(
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -42,6 +42,7 @@ data class UserState(
|
|||
* @property isVaultUnlocked Whether or not the user's vault is currently unlocked.
|
||||
* @property isVaultPendingUnlock Whether or not the user's vault is currently pending being
|
||||
* unlocked, such as when the password policy has not completed verification yet.
|
||||
* @property needsPasswordReset If the user needs to reset their password.
|
||||
* @property organizations List of [Organization]s the user is associated with, if any.
|
||||
* @property isBiometricsEnabled Indicates that the biometrics mechanism for unlocking the
|
||||
* user's vault is enabled.
|
||||
|
@ -57,6 +58,7 @@ data class UserState(
|
|||
val isLoggedIn: Boolean,
|
||||
val isVaultUnlocked: Boolean,
|
||||
val isVaultPendingUnlock: Boolean,
|
||||
val needsPasswordReset: Boolean,
|
||||
val organizations: List<Organization>,
|
||||
val isBiometricsEnabled: Boolean,
|
||||
val vaultUnlockType: VaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
|
||||
|
|
|
@ -73,8 +73,8 @@ fun UserStateJson.toUserState(
|
|||
isVaultUnlocked = vaultState.statusFor(userId) ==
|
||||
VaultUnlockData.Status.UNLOCKED,
|
||||
isVaultPendingUnlock = vaultState.statusFor(userId) ==
|
||||
VaultUnlockData.Status.PENDING ||
|
||||
accountJson.profile.forcePasswordResetReason != null,
|
||||
VaultUnlockData.Status.PENDING,
|
||||
needsPasswordReset = accountJson.profile.forcePasswordResetReason != null,
|
||||
organizations = userOrganizationsList
|
||||
.find { it.userId == userId }
|
||||
?.organizations
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -14,6 +14,9 @@ import androidx.navigation.navOptions
|
|||
import com.x8bit.bitwarden.ui.auth.feature.auth.AUTH_GRAPH_ROUTE
|
||||
import com.x8bit.bitwarden.ui.auth.feature.auth.authGraph
|
||||
import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuthGraph
|
||||
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.RESET_PASSWORD_ROUTE
|
||||
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.navigateToResetPasswordGraph
|
||||
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.resetPasswordDestination
|
||||
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.VAULT_UNLOCK_ROUTE
|
||||
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.navigateToVaultUnlock
|
||||
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.vaultUnlockDestination
|
||||
|
@ -24,6 +27,8 @@ import com.x8bit.bitwarden.ui.platform.feature.splash.splashDestination
|
|||
import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.VAULT_UNLOCKED_GRAPH_ROUTE
|
||||
import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.navigateToVaultUnlockedGraph
|
||||
import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.vaultUnlockedGraph
|
||||
import com.x8bit.bitwarden.ui.platform.theme.NonNullEnterTransitionProvider
|
||||
import com.x8bit.bitwarden.ui.platform.theme.NonNullExitTransitionProvider
|
||||
import com.x8bit.bitwarden.ui.platform.theme.RootTransitionProviders
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.model.AddSendType
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.navigateToAddSend
|
||||
|
@ -35,7 +40,7 @@ import java.util.concurrent.atomic.AtomicReference
|
|||
/**
|
||||
* Controls root level [NavHost] for the app.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun RootNavScreen(
|
||||
viewModel: RootNavViewModel = hiltViewModel(),
|
||||
|
@ -62,19 +67,21 @@ fun RootNavScreen(
|
|||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = SPLASH_ROUTE,
|
||||
enterTransition = RootTransitionProviders.Enter.fadeIn,
|
||||
exitTransition = RootTransitionProviders.Exit.fadeOut,
|
||||
popEnterTransition = RootTransitionProviders.Enter.fadeIn,
|
||||
popExitTransition = RootTransitionProviders.Exit.fadeOut,
|
||||
enterTransition = { this.targetState.destination.route.toEnterTransition()(this) },
|
||||
exitTransition = { this.targetState.destination.route.toExitTransition()(this) },
|
||||
popEnterTransition = { this.targetState.destination.route.toEnterTransition()(this) },
|
||||
popExitTransition = { this.targetState.destination.route.toExitTransition()(this) },
|
||||
) {
|
||||
splashDestination()
|
||||
authGraph(navController)
|
||||
resetPasswordDestination()
|
||||
vaultUnlockDestination()
|
||||
vaultUnlockedGraph(navController)
|
||||
}
|
||||
|
||||
val targetRoute = when (state) {
|
||||
RootNavState.Auth -> AUTH_GRAPH_ROUTE
|
||||
RootNavState.ResetPassword -> RESET_PASSWORD_ROUTE
|
||||
RootNavState.Splash -> SPLASH_ROUTE
|
||||
RootNavState.VaultLocked -> VAULT_UNLOCK_ROUTE
|
||||
is RootNavState.VaultUnlocked,
|
||||
|
@ -107,6 +114,7 @@ fun RootNavScreen(
|
|||
|
||||
when (val currentState = state) {
|
||||
RootNavState.Auth -> navController.navigateToAuthGraph(rootNavOptions)
|
||||
RootNavState.ResetPassword -> navController.navigateToResetPasswordGraph(rootNavOptions)
|
||||
RootNavState.Splash -> navController.navigateToSplash(rootNavOptions)
|
||||
RootNavState.VaultLocked -> navController.navigateToVaultUnlock(rootNavOptions)
|
||||
is RootNavState.VaultUnlocked -> navController.navigateToVaultUnlockedGraph(rootNavOptions)
|
||||
|
@ -144,3 +152,19 @@ private fun NavDestination?.rootLevelRoute(): String? {
|
|||
}
|
||||
return parent.rootLevelRoute()
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the enter transition for each route.
|
||||
*/
|
||||
private fun String?.toEnterTransition(): NonNullEnterTransitionProvider = when (this) {
|
||||
RESET_PASSWORD_ROUTE -> RootTransitionProviders.Enter.slideUp
|
||||
else -> RootTransitionProviders.Enter.fadeIn
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the exit transition for each route.
|
||||
*/
|
||||
private fun String?.toExitTransition(): NonNullExitTransitionProvider = when (this) {
|
||||
RESET_PASSWORD_ROUTE -> RootTransitionProviders.Exit.slideDown
|
||||
else -> RootTransitionProviders.Exit.fadeOut
|
||||
}
|
||||
|
|
|
@ -59,6 +59,8 @@ class RootNavViewModel @Inject constructor(
|
|||
val userState = action.userState
|
||||
val specialCircumstance = action.specialCircumstance
|
||||
val updatedRootNavState = when {
|
||||
userState?.activeAccount?.needsPasswordReset == true -> RootNavState.ResetPassword
|
||||
|
||||
userState == null ||
|
||||
!userState.activeAccount.isLoggedIn ||
|
||||
userState.activeAccount.isVaultPendingUnlock ||
|
||||
|
@ -99,6 +101,12 @@ sealed class RootNavState : Parcelable {
|
|||
@Parcelize
|
||||
data object Auth : RootNavState()
|
||||
|
||||
/**
|
||||
* App should show reset password graph.
|
||||
*/
|
||||
@Parcelize
|
||||
data object ResetPassword : RootNavState()
|
||||
|
||||
/**
|
||||
* App should show splash nav graph.
|
||||
*/
|
||||
|
|
|
@ -21,7 +21,6 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
@ -71,7 +70,7 @@ fun ExportVaultScreen(
|
|||
}
|
||||
|
||||
var shouldShowConfirmationDialog by remember { mutableStateOf(false) }
|
||||
var confirmExportVaultClicked = remember(viewModel) {
|
||||
val confirmExportVaultClicked = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExportVaultAction.ConfirmExportVaultClicked) }
|
||||
}
|
||||
if (shouldShowConfirmationDialog) {
|
||||
|
@ -154,7 +153,6 @@ fun ExportVaultScreen(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun ExportVaultScreenContent(
|
||||
state: ExportVaultState,
|
||||
|
|
|
@ -263,6 +263,7 @@ class MainViewModelTest : BaseViewModelTest() {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
|
|
@ -7,7 +7,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintRespon
|
|||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailJsonRequest
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson
|
||||
import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
|
@ -217,7 +218,7 @@ class AccountsServiceTest : BaseServiceTest() {
|
|||
val response = MockResponse().setBody("")
|
||||
server.enqueue(response)
|
||||
val result = service.resendVerificationCodeEmail(
|
||||
body = ResendEmailJsonRequest(
|
||||
body = ResendEmailRequestJson(
|
||||
deviceIdentifier = "3",
|
||||
email = "example@email.com",
|
||||
passwordHash = "37y4d8r379r4789nt387r39k3dr87nr93",
|
||||
|
@ -227,6 +228,21 @@ class AccountsServiceTest : BaseServiceTest() {
|
|||
assertTrue(result.isSuccess)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resetPassword with empty response is success`() = runTest {
|
||||
val response = MockResponse().setBody("")
|
||||
server.enqueue(response)
|
||||
val result = service.resetPassword(
|
||||
body = ResetPasswordRequestJson(
|
||||
currentPasswordHash = "",
|
||||
newPasswordHash = "",
|
||||
passwordHint = null,
|
||||
key = "",
|
||||
),
|
||||
)
|
||||
assertTrue(result.isSuccess)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EMAIL = "email"
|
||||
private val registerRequestBody = RegisterRequestJson(
|
||||
|
|
|
@ -22,7 +22,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResp
|
|||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailJsonRequest
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
|
||||
|
@ -53,11 +54,11 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
|
|||
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.createMockMasterPasswordPolicy
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
|
||||
|
@ -1940,6 +1941,155 @@ class AuthRepositoryTest {
|
|||
assertEquals(RegisterResult.Error(errorMessage = "message"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resetPassword Success should return Success`() = runTest {
|
||||
val currentPassword = "currentPassword"
|
||||
val currentPasswordHash = "hashedCurrentPassword"
|
||||
val password = "password"
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
coEvery {
|
||||
authSdkSource.hashPassword(
|
||||
email = ACCOUNT_1.profile.email,
|
||||
password = currentPassword,
|
||||
kdf = ACCOUNT_1.profile.toSdkParams(),
|
||||
purpose = HashPurpose.SERVER_AUTHORIZATION,
|
||||
)
|
||||
} returns currentPasswordHash.asSuccess()
|
||||
coEvery {
|
||||
authSdkSource.makeRegisterKeys(
|
||||
email = ACCOUNT_1.profile.email,
|
||||
password = password,
|
||||
kdf = ACCOUNT_1.profile.toSdkParams(),
|
||||
)
|
||||
} returns Result.success(
|
||||
RegisterKeyResponse(
|
||||
masterPasswordHash = PASSWORD_HASH,
|
||||
encryptedUserKey = ENCRYPTED_USER_KEY,
|
||||
keys = RsaKeyPair(
|
||||
public = PUBLIC_KEY,
|
||||
private = PRIVATE_KEY,
|
||||
),
|
||||
),
|
||||
)
|
||||
coEvery {
|
||||
accountsService.resetPassword(
|
||||
body = ResetPasswordRequestJson(
|
||||
currentPasswordHash = currentPasswordHash,
|
||||
newPasswordHash = PASSWORD_HASH,
|
||||
passwordHint = null,
|
||||
key = ENCRYPTED_USER_KEY,
|
||||
),
|
||||
)
|
||||
} returns Unit.asSuccess()
|
||||
|
||||
val result = repository.resetPassword(
|
||||
currentPassword = currentPassword,
|
||||
newPassword = password,
|
||||
passwordHint = null,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
ResetPasswordResult.Success,
|
||||
result,
|
||||
)
|
||||
coVerify {
|
||||
authSdkSource.makeRegisterKeys(
|
||||
email = ACCOUNT_1.profile.email,
|
||||
password = password,
|
||||
kdf = ACCOUNT_1.profile.toSdkParams(),
|
||||
)
|
||||
accountsService.resetPassword(
|
||||
body = ResetPasswordRequestJson(
|
||||
currentPasswordHash = currentPasswordHash,
|
||||
newPasswordHash = PASSWORD_HASH,
|
||||
passwordHint = null,
|
||||
key = ENCRYPTED_USER_KEY,
|
||||
),
|
||||
)
|
||||
}
|
||||
verify {
|
||||
vaultRepository.completeUnlock(userId = USER_ID_1)
|
||||
}
|
||||
fakeAuthDiskSource.assertMasterPasswordHash(
|
||||
userId = USER_ID_1,
|
||||
passwordHash = PASSWORD_HASH,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resetPassword Failure should return Error`() = runTest {
|
||||
val currentPassword = "currentPassword"
|
||||
val currentPasswordHash = "hashedCurrentPassword"
|
||||
val password = "password"
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
coEvery {
|
||||
authSdkSource.hashPassword(
|
||||
email = ACCOUNT_1.profile.email,
|
||||
password = currentPassword,
|
||||
kdf = ACCOUNT_1.profile.toSdkParams(),
|
||||
purpose = HashPurpose.SERVER_AUTHORIZATION,
|
||||
)
|
||||
} returns currentPasswordHash.asSuccess()
|
||||
coEvery {
|
||||
authSdkSource.makeRegisterKeys(
|
||||
email = ACCOUNT_1.profile.email,
|
||||
password = password,
|
||||
kdf = ACCOUNT_1.profile.toSdkParams(),
|
||||
)
|
||||
} returns Result.success(
|
||||
RegisterKeyResponse(
|
||||
masterPasswordHash = PASSWORD_HASH,
|
||||
encryptedUserKey = ENCRYPTED_USER_KEY,
|
||||
keys = RsaKeyPair(
|
||||
public = PUBLIC_KEY,
|
||||
private = PRIVATE_KEY,
|
||||
),
|
||||
),
|
||||
)
|
||||
coEvery {
|
||||
accountsService.resetPassword(
|
||||
body = ResetPasswordRequestJson(
|
||||
currentPasswordHash = currentPasswordHash,
|
||||
newPasswordHash = PASSWORD_HASH,
|
||||
passwordHint = null,
|
||||
key = ENCRYPTED_USER_KEY,
|
||||
),
|
||||
)
|
||||
} returns Throwable("Fail").asFailure()
|
||||
|
||||
val result = repository.resetPassword(
|
||||
currentPassword = currentPassword,
|
||||
newPassword = password,
|
||||
passwordHint = null,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
ResetPasswordResult.Error,
|
||||
result,
|
||||
)
|
||||
coVerify {
|
||||
authSdkSource.hashPassword(
|
||||
email = ACCOUNT_1.profile.email,
|
||||
password = currentPassword,
|
||||
kdf = ACCOUNT_1.profile.toSdkParams(),
|
||||
purpose = HashPurpose.SERVER_AUTHORIZATION,
|
||||
)
|
||||
authSdkSource.makeRegisterKeys(
|
||||
email = ACCOUNT_1.profile.email,
|
||||
password = password,
|
||||
kdf = ACCOUNT_1.profile.toSdkParams(),
|
||||
)
|
||||
accountsService.resetPassword(
|
||||
body = ResetPasswordRequestJson(
|
||||
currentPasswordHash = currentPasswordHash,
|
||||
newPasswordHash = PASSWORD_HASH,
|
||||
passwordHint = null,
|
||||
key = ENCRYPTED_USER_KEY,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `passwordHintRequest with valid email should return Success`() = runTest {
|
||||
val email = "valid@example.com"
|
||||
|
@ -2129,7 +2279,7 @@ class AuthRepositoryTest {
|
|||
// Resend the verification code email.
|
||||
coEvery {
|
||||
accountsService.resendVerificationCodeEmail(
|
||||
body = ResendEmailJsonRequest(
|
||||
body = ResendEmailRequestJson(
|
||||
deviceIdentifier = UNIQUE_APP_ID,
|
||||
email = EMAIL,
|
||||
passwordHash = PASSWORD_HASH,
|
||||
|
@ -2141,7 +2291,7 @@ class AuthRepositoryTest {
|
|||
assertEquals(ResendEmailResult.Success, resendEmailResult)
|
||||
coVerify {
|
||||
accountsService.resendVerificationCodeEmail(
|
||||
body = ResendEmailJsonRequest(
|
||||
body = ResendEmailRequestJson(
|
||||
deviceIdentifier = UNIQUE_APP_ID,
|
||||
email = EMAIL,
|
||||
passwordHash = PASSWORD_HASH,
|
||||
|
@ -2944,34 +3094,61 @@ class AuthRepositoryTest {
|
|||
|
||||
@Test
|
||||
fun `validatePasswordAgainstPolicy validates password against policy requirements`() = runTest {
|
||||
var policy = createMockMasterPasswordPolicy(minLength = 10)
|
||||
assertFalse(repository.validatePasswordAgainstPolicy(password = "123", policy = policy))
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
|
||||
// A helper method to set a policy in the store with the given parameters.
|
||||
fun setPolicy(
|
||||
minLength: Int = 0,
|
||||
minComplexity: Int? = null,
|
||||
requireUpper: Boolean = false,
|
||||
requireLower: Boolean = false,
|
||||
requireNumbers: Boolean = false,
|
||||
requireSpecial: Boolean = false,
|
||||
) {
|
||||
fakeAuthDiskSource.storePolicies(
|
||||
userId = USER_ID_1,
|
||||
policies = listOf(
|
||||
createMockPolicy(
|
||||
type = PolicyTypeJson.MASTER_PASSWORD,
|
||||
isEnabled = true,
|
||||
data = buildJsonObject {
|
||||
put(key = "minLength", value = minLength)
|
||||
put(key = "minComplexity", value = minComplexity)
|
||||
put(key = "requireUpper", value = requireUpper)
|
||||
put(key = "requireLower", value = requireLower)
|
||||
put(key = "requireNumbers", value = requireNumbers)
|
||||
put(key = "requireSpecial", value = requireSpecial)
|
||||
put(key = "enforceOnLogin", value = true)
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
setPolicy(minLength = 10)
|
||||
assertFalse(repository.validatePasswordAgainstPolicies(password = "123"))
|
||||
|
||||
val password = "simple"
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
coEvery {
|
||||
authSdkSource.passwordStrength(
|
||||
email = SINGLE_USER_STATE_1.activeAccount.profile.email,
|
||||
password = password,
|
||||
)
|
||||
} returns Result.success(LEVEL_0)
|
||||
policy = createMockMasterPasswordPolicy(minComplexity = 1)
|
||||
assertFalse(repository.validatePasswordAgainstPolicy(password = password, policy = policy))
|
||||
setPolicy(minComplexity = 10)
|
||||
assertFalse(repository.validatePasswordAgainstPolicies(password = password))
|
||||
|
||||
policy = createMockMasterPasswordPolicy(requireUpper = true)
|
||||
assertFalse(repository.validatePasswordAgainstPolicy(password = "lower", policy = policy))
|
||||
setPolicy(requireUpper = true)
|
||||
assertFalse(repository.validatePasswordAgainstPolicies(password = "lower"))
|
||||
|
||||
policy = createMockMasterPasswordPolicy(requireLower = true)
|
||||
assertFalse(repository.validatePasswordAgainstPolicy(password = "UPPER", policy = policy))
|
||||
setPolicy(requireLower = true)
|
||||
assertFalse(repository.validatePasswordAgainstPolicies(password = "UPPER"))
|
||||
|
||||
policy = createMockMasterPasswordPolicy(requireNumbers = true)
|
||||
assertFalse(repository.validatePasswordAgainstPolicy(password = "letters", policy = policy))
|
||||
setPolicy(requireNumbers = true)
|
||||
assertFalse(repository.validatePasswordAgainstPolicies(password = "letters"))
|
||||
|
||||
policy = createMockMasterPasswordPolicy(requireSpecial = true)
|
||||
assertFalse(repository.validatePasswordAgainstPolicy(password = "letters", policy = policy))
|
||||
|
||||
policy = createMockMasterPasswordPolicy(minLength = 5)
|
||||
assertTrue(repository.validatePasswordAgainstPolicy(password = "password", policy = policy))
|
||||
setPolicy(requireSpecial = true)
|
||||
assertFalse(repository.validatePasswordAgainstPolicies(password = "letters"))
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -108,6 +108,7 @@ class UserStateJsonExtensionsTest {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
id = "organizationId",
|
||||
|
@ -183,6 +184,7 @@ class UserStateJsonExtensionsTest {
|
|||
isLoggedIn = false,
|
||||
isVaultUnlocked = false,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
id = "organizationId",
|
||||
|
|
|
@ -72,6 +72,7 @@ class LandingViewModelTest : BaseViewModelTest() {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
@ -204,6 +205,7 @@ class LandingViewModelTest : BaseViewModelTest() {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
)
|
||||
|
@ -255,6 +257,7 @@ class LandingViewModelTest : BaseViewModelTest() {
|
|||
isLoggedIn = false,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
)
|
||||
|
|
|
@ -128,6 +128,7 @@ class LoginViewModelTest : BaseViewModelTest() {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
|
|
@ -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 = "",
|
||||
)
|
|
@ -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 = "",
|
||||
)
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -117,6 +117,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
@ -151,6 +152,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = false,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = true,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
@ -728,6 +730,7 @@ private val DEFAULT_ACCOUNT = UserState.Account(
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
)
|
||||
|
|
|
@ -82,6 +82,15 @@ class RootNavScreenTest : BaseComposeTest() {
|
|||
)
|
||||
}
|
||||
|
||||
// Make sure navigating to reset password works as expected:
|
||||
rootNavStateFlow.value = RootNavState.ResetPassword
|
||||
composeTestRule.runOnIdle {
|
||||
fakeNavHostController.assertLastNavigation(
|
||||
route = "reset_password",
|
||||
navOptions = expectedNavOptions,
|
||||
)
|
||||
}
|
||||
|
||||
// Make sure navigating to vault unlocked works as expected:
|
||||
rootNavStateFlow.value = RootNavState.VaultUnlocked(activeUserId = "userId")
|
||||
composeTestRule.runOnIdle {
|
||||
|
|
|
@ -31,7 +31,6 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||
assertEquals(RootNavState.Auth, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `when the active user is not logged in the nav state should be Auth`() {
|
||||
mutableUserStateFlow.tryEmit(
|
||||
|
@ -48,6 +47,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||
isLoggedIn = false,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
@ -58,6 +58,33 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||
assertEquals(RootNavState.Auth, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the active user needs a password reset the nav state should be ResetPassword`() {
|
||||
mutableUserStateFlow.tryEmit(
|
||||
UserState(
|
||||
activeUserId = "activeUserId",
|
||||
accounts = listOf(
|
||||
UserState.Account(
|
||||
userId = "activeUserId",
|
||||
name = "name",
|
||||
email = "email",
|
||||
avatarColorHex = "avatarColorHex",
|
||||
environment = Environment.Us,
|
||||
isPremium = true,
|
||||
isLoggedIn = false,
|
||||
isVaultUnlocked = false,
|
||||
isVaultPendingUnlock = true,
|
||||
needsPasswordReset = true,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val viewModel = createViewModel()
|
||||
assertEquals(RootNavState.ResetPassword, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `when the active user but there are pending account additions the nav state should be Auth`() {
|
||||
|
@ -75,6 +102,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
@ -102,6 +130,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = false,
|
||||
isVaultPendingUnlock = true,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
@ -131,6 +160,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
@ -166,6 +196,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
@ -205,6 +236,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
@ -237,6 +269,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = false,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
|
|
@ -975,6 +975,7 @@ private val DEFAULT_USER_STATE = UserState(
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
|
|
@ -193,6 +193,7 @@ private val DEFAULT_USER_STATE = UserState(
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -1787,6 +1787,7 @@ private val DEFAULT_USER_STATE = UserState(
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
|
|
@ -1003,6 +1003,7 @@ class AddSendViewModelTest : BaseViewModelTest() {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
)
|
||||
|
|
|
@ -2140,6 +2140,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
|
|||
isLoggedIn = false,
|
||||
isVaultUnlocked = false,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
id = "organizationId",
|
||||
|
|
|
@ -554,6 +554,7 @@ private val DEFAULT_USER_STATE = UserState(
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
|
|
@ -1498,6 +1498,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
|
|
@ -1373,6 +1373,7 @@ private val DEFAULT_ACCOUNT = UserState.Account(
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
)
|
||||
|
|
|
@ -423,6 +423,7 @@ private val DEFAULT_USER_STATE = UserState(
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
|
|
@ -95,6 +95,7 @@ private fun createMockUserState(hasOrganizations: Boolean = true): UserState =
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = if (hasOrganizations) {
|
||||
listOf(
|
||||
|
|
|
@ -163,6 +163,7 @@ class VaultViewModelTest : BaseViewModelTest() {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
@ -1281,6 +1282,7 @@ private val DEFAULT_USER_STATE = UserState(
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
@ -1294,6 +1296,7 @@ private val DEFAULT_USER_STATE = UserState(
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = false,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
),
|
||||
|
|
|
@ -70,6 +70,7 @@ class UserStateExtensionsTest {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
@ -88,6 +89,7 @@ class UserStateExtensionsTest {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = false,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
@ -110,6 +112,7 @@ class UserStateExtensionsTest {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
@ -132,6 +135,7 @@ class UserStateExtensionsTest {
|
|||
isLoggedIn = false,
|
||||
isVaultUnlocked = false,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
@ -169,6 +173,7 @@ class UserStateExtensionsTest {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
@ -204,6 +209,7 @@ class UserStateExtensionsTest {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = false,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
@ -243,6 +249,7 @@ class UserStateExtensionsTest {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
@ -270,6 +277,7 @@ class UserStateExtensionsTest {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
)
|
||||
|
@ -306,6 +314,7 @@ class UserStateExtensionsTest {
|
|||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
isVaultPendingUnlock = false,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
|
|
Loading…
Reference in a new issue