[PM-26810] Add OTP support to VerifyPasswordScreen (#6034)

This commit is contained in:
Patrick Honkonen 2025-10-16 17:02:52 -04:00 committed by GitHub
parent ae3470c598
commit 74aa0a78ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 915 additions and 439 deletions

View File

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@ -15,8 +16,10 @@ import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -25,14 +28,18 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.bitwarden.ui.platform.components.field.BitwardenPasswordField import com.bitwarden.ui.platform.components.field.BitwardenPasswordField
import com.bitwarden.ui.platform.components.model.CardStyle import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarHostState
import com.bitwarden.ui.platform.components.snackbar.model.rememberBitwardenSnackbarHostState
import com.bitwarden.ui.platform.components.util.rememberVectorPainter import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.ui.vault.feature.exportitems.component.AccountSummaryListItem import com.x8bit.bitwarden.ui.vault.feature.exportitems.component.AccountSummaryListItem
import com.x8bit.bitwarden.ui.vault.feature.exportitems.component.ExportItemsScaffold import com.x8bit.bitwarden.ui.vault.feature.exportitems.component.ExportItemsScaffold
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
@ -47,6 +54,7 @@ fun VerifyPasswordScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onPasswordVerified: (userId: String) -> Unit, onPasswordVerified: (userId: String) -> Unit,
viewModel: VerifyPasswordViewModel = hiltViewModel(), viewModel: VerifyPasswordViewModel = hiltViewModel(),
snackbarHostState: BitwardenSnackbarHostState = rememberBitwardenSnackbarHostState(),
) { ) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle() val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@ -59,6 +67,10 @@ fun VerifyPasswordScreen(
is VerifyPasswordEvent.PasswordVerified -> { is VerifyPasswordEvent.PasswordVerified -> {
onPasswordVerified(event.userId) onPasswordVerified(event.userId)
} }
is VerifyPasswordEvent.ShowSnackbar -> {
snackbarHostState.showSnackbar(event.data)
}
} }
} }
@ -79,10 +91,9 @@ fun VerifyPasswordScreen(
VerifyPasswordContent( VerifyPasswordContent(
state = state, state = state,
onInputChanged = handler.onInputChanged, onInputChanged = handler.onInputChanged,
onUnlockClick = handler.onUnlockClick, onContinueClick = handler.onContinueClick,
modifier = Modifier onResendCodeClick = handler.onSendCodeClick,
.fillMaxSize() modifier = Modifier.fillMaxSize(),
.standardHorizontalMargin(),
) )
} }
} }
@ -110,11 +121,13 @@ private fun VerifyPasswordDialogs(
} }
} }
@Suppress("LongMethod")
@Composable @Composable
private fun VerifyPasswordContent( private fun VerifyPasswordContent(
state: VerifyPasswordState, state: VerifyPasswordState,
onInputChanged: (String) -> Unit, onInputChanged: (String) -> Unit,
onUnlockClick: () -> Unit, onContinueClick: () -> Unit,
onResendCodeClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@ -125,23 +138,64 @@ private fun VerifyPasswordContent(
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Text( Text(
text = stringResource(BitwardenString.verify_your_master_password), text = state.title(),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = BitwardenTheme.typography.titleMedium, style = BitwardenTheme.typography.titleMedium,
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
) )
state.subtext?.let { subtext ->
Spacer(Modifier.height(8.dp))
Text(
text = subtext(),
textAlign = TextAlign.Center,
style = BitwardenTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
AccountSummaryListItem( AccountSummaryListItem(
item = state.accountSummaryListItem, item = state.accountSummaryListItem,
cardStyle = CardStyle.Full, cardStyle = CardStyle.Full,
clickable = false, clickable = false,
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
if (state.showResendCodeButton) {
BitwardenPasswordField(
label = stringResource(id = BitwardenString.verification_code),
value = state.input,
onValueChange = onInputChanged,
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
keyboardActions = KeyboardActions(
onDone = {
if (state.isContinueButtonEnabled) {
onContinueClick()
} else {
defaultKeyboardAction(ImeAction.Done)
}
},
),
autoFocus = true,
cardStyle = CardStyle.Full,
passwordFieldTestTag = "VerificationCodeEntry",
showPasswordTestTag = "VerificationCodeVisibilityToggle",
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
} else {
BitwardenPasswordField( BitwardenPasswordField(
label = stringResource(BitwardenString.master_password), label = stringResource(BitwardenString.master_password),
value = state.input, value = state.input,
@ -150,36 +204,55 @@ private fun VerifyPasswordContent(
imeAction = ImeAction.Done, imeAction = ImeAction.Done,
keyboardActions = KeyboardActions( keyboardActions = KeyboardActions(
onDone = { onDone = {
if (state.isUnlockButtonEnabled) { if (state.isContinueButtonEnabled) {
onUnlockClick() onContinueClick()
} else { } else {
defaultKeyboardAction(ImeAction.Done) defaultKeyboardAction(ImeAction.Done)
} }
}, },
), ),
autoFocus = true,
supportingText = stringResource(BitwardenString.vault_locked_master_password), supportingText = stringResource(BitwardenString.vault_locked_master_password),
passwordFieldTestTag = "MasterPasswordEntry", passwordFieldTestTag = "MasterPasswordEntry",
cardStyle = CardStyle.Full, cardStyle = CardStyle.Full,
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
) )
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
BitwardenFilledButton( BitwardenFilledButton(
label = stringResource(BitwardenString.unlock), label = stringResource(BitwardenString.continue_text),
onClick = onUnlockClick, onClick = onContinueClick,
isEnabled = state.isUnlockButtonEnabled, isEnabled = state.isContinueButtonEnabled,
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.testTag("ContinueImportButton")
.fillMaxWidth()
.standardHorizontalMargin(),
) )
if (state.showResendCodeButton) {
BitwardenOutlinedButton(
label = stringResource(BitwardenString.resend_code),
onClick = onResendCodeClick,
modifier = Modifier
.testTag("ResendTOTPCodeButton")
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Spacer(Modifier.navigationBarsPadding())
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
private fun VerifyPasswordContent_Preview() { private fun VerifyPasswordContent_MasterPassword_preview() {
val accountSummaryListItem = AccountSelectionListItem( val accountSummaryListItem = AccountSelectionListItem(
userId = "userId", userId = "userId",
isItemRestricted = false, isItemRestricted = false,
@ -188,14 +261,65 @@ private fun VerifyPasswordContent_Preview() {
email = "john.doe@example.com", email = "john.doe@example.com",
) )
val state = VerifyPasswordState( val state = VerifyPasswordState(
title = BitwardenString.verify_your_master_password.asText(),
subtext = null,
accountSummaryListItem = accountSummaryListItem, accountSummaryListItem = accountSummaryListItem,
) )
ExportItemsScaffold(
navIcon = rememberVectorPainter(
BitwardenDrawable.ic_back,
),
onNavigationIconClick = {},
navigationIconContentDescription = stringResource(BitwardenString.back),
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
modifier = Modifier.fillMaxSize(),
) {
VerifyPasswordContent( VerifyPasswordContent(
state = state, state = state,
onInputChanged = {}, onInputChanged = {},
onUnlockClick = {}, onContinueClick = {},
onResendCodeClick = {},
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize(),
.standardHorizontalMargin(),
) )
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(showBackground = true)
@Composable
private fun VerifyPasswordContent_Otp_preview() {
val accountSummaryListItem = AccountSelectionListItem(
userId = "userId",
isItemRestricted = false,
avatarColorHex = "#FF0000",
initials = "JD",
email = "john.doe@example.com",
)
val state = VerifyPasswordState(
title = BitwardenString.verify_your_account_email_address.asText(),
subtext = BitwardenString
.enter_the_6_digit_code_that_was_emailed_to_the_address_below
.asText(),
accountSummaryListItem = accountSummaryListItem,
showResendCodeButton = true,
)
ExportItemsScaffold(
navIcon = rememberVectorPainter(
BitwardenDrawable.ic_back,
),
onNavigationIconClick = {},
navigationIconContentDescription = stringResource(BitwardenString.back),
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
modifier = Modifier.fillMaxSize(),
) {
VerifyPasswordContent(
state = state,
onInputChanged = {},
onContinueClick = {},
onResendCodeClick = {},
modifier = Modifier
.fillMaxSize(),
)
}
} }

View File

@ -5,12 +5,15 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.bitwarden.network.model.PolicyTypeJson import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.ui.platform.base.BaseViewModel import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
@ -59,6 +62,15 @@ class VerifyPasswordViewModel @Inject constructor(
.map { it.organizationId } .map { it.organizationId }
VerifyPasswordState( VerifyPasswordState(
title = if (account.hasMasterPassword) {
BitwardenString.verify_your_master_password.asText()
} else {
BitwardenString.verify_your_account_email_address.asText()
},
subtext = BitwardenString
.enter_the_6_digit_code_that_was_emailed_to_the_address_below
.asText()
.takeUnless { account.hasMasterPassword },
accountSummaryListItem = AccountSelectionListItem( accountSummaryListItem = AccountSelectionListItem(
userId = args.userId, userId = args.userId,
avatarColorHex = account.avatarColorHex, avatarColorHex = account.avatarColorHex,
@ -68,6 +80,7 @@ class VerifyPasswordViewModel @Inject constructor(
.organizations .organizations
.any { it.id in restrictedItemPolicyOrgIds }, .any { it.id in restrictedItemPolicyOrgIds },
), ),
showResendCodeButton = !account.hasMasterPassword,
) )
}, },
) { ) {
@ -77,6 +90,16 @@ class VerifyPasswordViewModel @Inject constructor(
stateFlow stateFlow
.onEach { savedStateHandle[KEY_STATE] = it } .onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope) .launchIn(viewModelScope)
if (stateFlow.value.showResendCodeButton) {
viewModelScope.launch {
sendAction(
VerifyPasswordAction.Internal.SendOtpCodeResultReceive(
result = authRepository.requestOneTimePasscode(),
),
)
}
}
} }
override fun onCleared() { override fun onCleared() {
@ -92,8 +115,8 @@ class VerifyPasswordViewModel @Inject constructor(
handleNavigateBackClick() handleNavigateBackClick()
} }
VerifyPasswordAction.UnlockClick -> { VerifyPasswordAction.ContinueClick -> {
handleUnlockClick() handleContinueClick()
} }
is VerifyPasswordAction.PasswordInputChangeReceive -> { is VerifyPasswordAction.PasswordInputChangeReceive -> {
@ -104,6 +127,10 @@ class VerifyPasswordViewModel @Inject constructor(
handleDismissDialog() handleDismissDialog()
} }
VerifyPasswordAction.ResendCodeClick -> {
handleResendCodeClick()
}
is VerifyPasswordAction.Internal -> { is VerifyPasswordAction.Internal -> {
handleInternalAction(action) handleInternalAction(action)
} }
@ -114,7 +141,7 @@ class VerifyPasswordViewModel @Inject constructor(
sendEvent(VerifyPasswordEvent.NavigateBack) sendEvent(VerifyPasswordEvent.NavigateBack)
} }
private fun handleUnlockClick() { private fun handleContinueClick() {
if (state.input.isBlank()) { if (state.input.isBlank()) {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
@ -154,6 +181,23 @@ class VerifyPasswordViewModel @Inject constructor(
mutableStateFlow.update { it.copy(dialog = null) } mutableStateFlow.update { it.copy(dialog = null) }
} }
private fun handleResendCodeClick() {
mutableStateFlow.update {
it.copy(
dialog = VerifyPasswordState.DialogState.Loading(
message = BitwardenString.sending.asText(),
),
)
}
viewModelScope.launch {
sendAction(
VerifyPasswordAction.Internal.SendOtpCodeResultReceive(
result = authRepository.requestOneTimePasscode(),
),
)
}
}
private fun handleInternalAction(action: VerifyPasswordAction.Internal) { private fun handleInternalAction(action: VerifyPasswordAction.Internal) {
when (action) { when (action) {
is VerifyPasswordAction.Internal.ValidatePasswordResultReceive -> { is VerifyPasswordAction.Internal.ValidatePasswordResultReceive -> {
@ -163,6 +207,14 @@ class VerifyPasswordViewModel @Inject constructor(
is VerifyPasswordAction.Internal.UnlockVaultResultReceive -> { is VerifyPasswordAction.Internal.UnlockVaultResultReceive -> {
handleUnlockVaultResultReceive(action) handleUnlockVaultResultReceive(action)
} }
is VerifyPasswordAction.Internal.SendOtpCodeResultReceive -> {
handleSendOtpCodeResultReceive(action)
}
is VerifyPasswordAction.Internal.VerifyOtpResultReceive -> {
handleVerifyOtpResultReceive(action)
}
} }
} }
@ -221,6 +273,60 @@ class VerifyPasswordViewModel @Inject constructor(
} }
} }
private fun handleSendOtpCodeResultReceive(
action: VerifyPasswordAction.Internal.SendOtpCodeResultReceive,
) {
when (val result = action.result) {
is RequestOtpResult.Error -> {
val message = result.message?.asText()
?: BitwardenString.generic_error_message.asText()
mutableStateFlow.update {
it.copy(
dialog = VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = message,
),
)
}
}
RequestOtpResult.Success -> {
mutableStateFlow.update {
it.copy(dialog = null)
}
sendEvent(
VerifyPasswordEvent.ShowSnackbar(BitwardenString.code_sent.asText()),
)
}
}
}
private fun handleVerifyOtpResultReceive(
action: VerifyPasswordAction.Internal.VerifyOtpResultReceive,
) {
when (action.result) {
is VerifyOtpResult.Verified -> {
mutableStateFlow.update { it.copy(dialog = null) }
sendEvent(
VerifyPasswordEvent.PasswordVerified(
state.accountSummaryListItem.userId,
),
)
}
is VerifyOtpResult.NotVerified -> {
mutableStateFlow.update {
it.copy(
dialog = VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.invalid_verification_code.asText(),
),
)
}
}
}
}
private fun switchAccountAndVerifyPassword() { private fun switchAccountAndVerifyPassword() {
val switchAccountResult = authRepository val switchAccountResult = authRepository
.switchAccount(userId = state.accountSummaryListItem.userId) .switchAccount(userId = state.accountSummaryListItem.userId)
@ -244,7 +350,15 @@ class VerifyPasswordViewModel @Inject constructor(
val userId = state.accountSummaryListItem.userId val userId = state.accountSummaryListItem.userId
viewModelScope.launch { viewModelScope.launch {
if (vaultRepository.isVaultUnlocked(userId)) { if (state.showResendCodeButton) {
sendAction(
VerifyPasswordAction.Internal.VerifyOtpResultReceive(
result = authRepository.verifyOneTimePasscode(
oneTimePasscode = state.input,
),
),
)
} else if (vaultRepository.isVaultUnlocked(userId)) {
// If the vault is already unlocked, validate the password directly. // If the vault is already unlocked, validate the password directly.
sendAction( sendAction(
VerifyPasswordAction.Internal.ValidatePasswordResultReceive( VerifyPasswordAction.Internal.ValidatePasswordResultReceive(
@ -300,19 +414,23 @@ class VerifyPasswordViewModel @Inject constructor(
* @param accountSummaryListItem The account summary to display. * @param accountSummaryListItem The account summary to display.
* @param input The current password input. * @param input The current password input.
* @param dialog The current dialog state, or null if no dialog is shown. * @param dialog The current dialog state, or null if no dialog is shown.
* @param showResendCodeButton Whether to show the send code button.
*/ */
@Parcelize @Parcelize
data class VerifyPasswordState( data class VerifyPasswordState(
val accountSummaryListItem: AccountSelectionListItem, val accountSummaryListItem: AccountSelectionListItem,
val title: Text,
val subtext: Text?,
// We never want this saved since the input is sensitive data. // We never want this saved since the input is sensitive data.
@IgnoredOnParcel val input: String = "", @IgnoredOnParcel val input: String = "",
val dialog: DialogState? = null, val dialog: DialogState? = null,
val showResendCodeButton: Boolean = false,
) : Parcelable { ) : Parcelable {
/** /**
* Whether the unlock button should be enabled. * Whether the unlock button should be enabled.
*/ */
val isUnlockButtonEnabled: Boolean val isContinueButtonEnabled: Boolean
get() = input.isNotBlank() && dialog !is DialogState.Loading get() = input.isNotBlank() && dialog !is DialogState.Loading
/** /**
@ -356,6 +474,27 @@ sealed class VerifyPasswordEvent {
* @param userId The ID of the user whose password was verified. * @param userId The ID of the user whose password was verified.
*/ */
data class PasswordVerified(val userId: String) : VerifyPasswordEvent() data class PasswordVerified(val userId: String) : VerifyPasswordEvent()
/**
* Show a snackbar with the given data.
*/
data class ShowSnackbar(
val data: BitwardenSnackbarData,
) : VerifyPasswordEvent() {
constructor(
message: Text,
messageHeader: Text? = null,
actionLabel: Text? = null,
withDismissAction: Boolean = false,
) : this(
data = BitwardenSnackbarData(
message = message,
messageHeader = messageHeader,
actionLabel = actionLabel,
withDismissAction = withDismissAction,
),
)
}
} }
/** /**
@ -368,15 +507,20 @@ sealed class VerifyPasswordAction {
data object NavigateBackClick : VerifyPasswordAction() data object NavigateBackClick : VerifyPasswordAction()
/** /**
* Represents a click on the unlock button. * Represents a click on the Continue button.
*/ */
data object UnlockClick : VerifyPasswordAction() data object ContinueClick : VerifyPasswordAction()
/** /**
* Dismiss the current dialog. * Dismiss the current dialog.
*/ */
data object DismissDialog : VerifyPasswordAction() data object DismissDialog : VerifyPasswordAction()
/**
* Represents a click on the resend code button.
*/
data object ResendCodeClick : VerifyPasswordAction()
/** /**
* Represents a change in the password input. * Represents a change in the password input.
* @param input The new password input. * @param input The new password input.
@ -403,5 +547,17 @@ sealed class VerifyPasswordAction {
data class UnlockVaultResultReceive( data class UnlockVaultResultReceive(
val vaultUnlockResult: VaultUnlockResult, val vaultUnlockResult: VaultUnlockResult,
) : Internal() ) : Internal()
/**
* Represents a result of requesting an OTP code.
* @param result The result of requesting an OTP code.
*/
data class SendOtpCodeResultReceive(val result: RequestOtpResult) : Internal()
/**
* Represents a result of verifying an OTP code.
* @param result The result of verifying an OTP code.
*/
data class VerifyOtpResultReceive(val result: VerifyOtpResult) : Internal()
} }
} }

View File

@ -10,8 +10,9 @@ import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.VerifyPas
*/ */
data class VerifyPasswordHandlers( data class VerifyPasswordHandlers(
val onNavigateBackClick: () -> Unit, val onNavigateBackClick: () -> Unit,
val onUnlockClick: () -> Unit, val onContinueClick: () -> Unit,
val onInputChanged: (String) -> Unit, val onInputChanged: (String) -> Unit,
val onSendCodeClick: () -> Unit,
val onDismissDialog: () -> Unit, val onDismissDialog: () -> Unit,
) { ) {
@ -26,14 +27,17 @@ data class VerifyPasswordHandlers(
onNavigateBackClick = { onNavigateBackClick = {
viewModel.trySendAction(VerifyPasswordAction.NavigateBackClick) viewModel.trySendAction(VerifyPasswordAction.NavigateBackClick)
}, },
onUnlockClick = { onContinueClick = {
viewModel.trySendAction(VerifyPasswordAction.UnlockClick) viewModel.trySendAction(VerifyPasswordAction.ContinueClick)
}, },
onInputChanged = { onInputChanged = {
viewModel.trySendAction( viewModel.trySendAction(
VerifyPasswordAction.PasswordInputChangeReceive(it), VerifyPasswordAction.PasswordInputChangeReceive(it),
) )
}, },
onSendCodeClick = {
viewModel.trySendAction(VerifyPasswordAction.ResendCodeClick)
},
onDismissDialog = { onDismissDialog = {
viewModel.trySendAction(VerifyPasswordAction.DismissDialog) viewModel.trySendAction(VerifyPasswordAction.DismissDialog)
}, },

View File

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.filterToOne
@ -15,6 +16,7 @@ import androidx.compose.ui.test.performTextInput
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.repository.model.Environment import com.bitwarden.data.repository.model.Environment
import com.bitwarden.network.model.OrganizationType import com.bitwarden.network.model.OrganizationType
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.model.Organization import com.x8bit.bitwarden.data.auth.repository.model.Organization
@ -81,10 +83,34 @@ class VerifyPasswordScreenTest : BitwardenComposeTest() {
.onNodeWithText("You vault is locked. Verify your master password to continue.") .onNodeWithText("You vault is locked. Verify your master password to continue.")
composeTestRule composeTestRule
.onNodeWithText("Unlock") .onNodeWithText("Continue")
.assertIsNotEnabled() .assertIsNotEnabled()
} }
@Test
fun `otp state should be correct`() = runTest {
mockStateFlow.emit(
DEFAULT_STATE.copy(
title = BitwardenString.verify_your_account_email_address.asText(),
subtext = BitwardenString
.enter_the_6_digit_code_that_was_emailed_to_the_address_below
.asText(),
showResendCodeButton = true,
),
)
composeTestRule
.onNodeWithText("Verify your account email address")
composeTestRule
.onNodeWithText("Enter the 6-digit code that was emailed to the address below")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Resend code")
.assertIsDisplayed()
}
@Test @Test
fun `input should update based on state`() = runTest { fun `input should update based on state`() = runTest {
composeTestRule composeTestRule
@ -105,10 +131,6 @@ class VerifyPasswordScreenTest : BitwardenComposeTest() {
composeTestRule composeTestRule
.onNodeWithText("Master password") .onNodeWithText("Master password")
.performTextInput("abc123") .performTextInput("abc123")
composeTestRule
.onNodeWithTag("PasswordVisibilityToggle")
.performClick()
verify { verify {
viewModel.trySendAction( viewModel.trySendAction(
VerifyPasswordAction.PasswordInputChangeReceive("abc123"), VerifyPasswordAction.PasswordInputChangeReceive("abc123"),
@ -117,26 +139,37 @@ class VerifyPasswordScreenTest : BitwardenComposeTest() {
} }
@Test @Test
fun `Unlock button should should update based on input`() = runTest { fun `Continue button should should update based on input`() = runTest {
composeTestRule composeTestRule
.onNodeWithText("Unlock") .onNodeWithText("Continue")
.assertIsNotEnabled() .assertIsNotEnabled()
mockStateFlow.emit(DEFAULT_STATE.copy(input = "abc123")) mockStateFlow.emit(DEFAULT_STATE.copy(input = "abc123"))
composeTestRule composeTestRule
.onNodeWithText("Unlock") .onNodeWithText("Continue")
.assertIsEnabled() .assertIsEnabled()
} }
@Test @Test
fun `Unlock button should send UnlockClick action`() = runTest { fun `Continue button should send ContinueClick action`() = runTest {
mockStateFlow.emit(DEFAULT_STATE.copy(input = "abc123")) mockStateFlow.emit(DEFAULT_STATE.copy(input = "abc123"))
composeTestRule composeTestRule
.onNodeWithText("Unlock") .onNodeWithText("Continue")
.performClick() .performClick()
verify { verify {
viewModel.trySendAction(VerifyPasswordAction.UnlockClick) viewModel.trySendAction(VerifyPasswordAction.ContinueClick)
}
}
@Test
fun `Resend code button should send SendCodeClick action`() = runTest {
mockStateFlow.emit(DEFAULT_STATE.copy(showResendCodeButton = true))
composeTestRule
.onNodeWithText("Resend code")
.performClick()
verify {
viewModel.trySendAction(VerifyPasswordAction.ResendCodeClick)
} }
} }
@ -259,6 +292,8 @@ private val DEFAULT_ACCOUNT_SELECTION_LIST_ITEM = AccountSelectionListItem(
initials = DEFAULT_USER_STATE.activeAccount.initials, initials = DEFAULT_USER_STATE.activeAccount.initials,
) )
private val DEFAULT_STATE = VerifyPasswordState( private val DEFAULT_STATE = VerifyPasswordState(
title = BitwardenString.verify_your_master_password.asText(),
subtext = null,
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM, accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM,
input = "", input = "",
dialog = null, dialog = null,

View File

@ -12,9 +12,11 @@ import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.Organization import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository
@ -34,6 +36,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class VerifyPasswordViewModelTest : BaseViewModelTest() { class VerifyPasswordViewModelTest : BaseViewModelTest() {
@ -42,6 +45,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
private val authRepository = mockk<AuthRepository> { private val authRepository = mockk<AuthRepository> {
every { userStateFlow } returns mutableUserStateFlow every { userStateFlow } returns mutableUserStateFlow
every { activeUserId } returns DEFAULT_USER_ID every { activeUserId } returns DEFAULT_USER_ID
coEvery { requestOneTimePasscode() } returns RequestOtpResult.Success
} }
private val vaultRepository = mockk<VaultRepository> { private val vaultRepository = mockk<VaultRepository> {
every { isVaultUnlocked(any()) } returns true every { isVaultUnlocked(any()) } returns true
@ -73,13 +77,49 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
) )
} }
@Nested
inner class State {
@Test
fun `initial state should be correct when account has no master password`() = runTest {
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = DEFAULT_USER_STATE.accounts.map {
it.copy(hasMasterPassword = false)
},
)
coEvery { authRepository.requestOneTimePasscode() } returns RequestOtpResult.Success
createViewModel()
.also {
assertEquals(
VerifyPasswordState(
title = BitwardenString.verify_your_account_email_address.asText(),
subtext = BitwardenString
.enter_the_6_digit_code_that_was_emailed_to_the_address_below
.asText(),
accountSummaryListItem = AccountSelectionListItem(
userId = DEFAULT_USER_ID,
email = DEFAULT_USER_STATE.activeAccount.email,
avatarColorHex = DEFAULT_USER_STATE.activeAccount.avatarColorHex,
isItemRestricted = false,
initials = DEFAULT_USER_STATE.activeAccount.initials,
),
showResendCodeButton = true,
),
it.stateFlow.value,
)
coVerify { authRepository.requestOneTimePasscode() }
}
}
@Test @Test
fun `initial state should be correct when account is not restricted`() = runTest { fun `initial state should be correct when account is not restricted`() = runTest {
createViewModel() createViewModel()
.also { .also {
assertEquals( assertEquals(
VerifyPasswordState( VerifyPasswordState(
AccountSelectionListItem( title = BitwardenString.verify_your_master_password.asText(),
subtext = null,
accountSummaryListItem = AccountSelectionListItem(
userId = DEFAULT_USER_ID, userId = DEFAULT_USER_ID,
email = DEFAULT_USER_STATE.activeAccount.email, email = DEFAULT_USER_STATE.activeAccount.email,
avatarColorHex = DEFAULT_USER_STATE.activeAccount.avatarColorHex, avatarColorHex = DEFAULT_USER_STATE.activeAccount.avatarColorHex,
@ -108,6 +148,8 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
.also { .also {
assertEquals( assertEquals(
VerifyPasswordState( VerifyPasswordState(
title = BitwardenString.verify_your_master_password.asText(),
subtext = null,
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM
.copy(isItemRestricted = true), .copy(isItemRestricted = true),
), ),
@ -115,6 +157,120 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
) )
} }
} }
}
@Nested
inner class ViewActions {
@Test
fun `SendCodeClick should request otp code`() = runTest {
val initialState = DEFAULT_STATE.copy(
title = BitwardenString.verify_your_account_email_address.asText(),
subtext = BitwardenString
.enter_the_6_digit_code_that_was_emailed_to_the_address_below
.asText(),
showResendCodeButton = true,
)
coEvery { authRepository.requestOneTimePasscode() } returns RequestOtpResult.Success
createViewModel(state = initialState).also { viewModel ->
viewModel.trySendAction(VerifyPasswordAction.ResendCodeClick)
coVerify { authRepository.requestOneTimePasscode() }
}
}
@Test
fun `SendOtpCodeResultReceive success should show snackbar`() = runTest {
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.SendOtpCodeResultReceive(
RequestOtpResult.Success,
),
)
viewModel.eventFlow.test {
assertEquals(
VerifyPasswordEvent.ShowSnackbar(BitwardenString.code_sent.asText()),
awaitItem(),
)
}
}
}
@Test
fun `SendOtpCodeResultReceive error should show dialog`() = runTest {
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.SendOtpCodeResultReceive(
RequestOtpResult.Error(
message = "error",
error = IllegalStateException(),
),
),
)
assertEquals(
VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = "error".asText(),
),
viewModel.stateFlow.value.dialog,
)
}
}
@Test
fun `ContinueClick with otp should verify otp`() = runTest {
val initialState = DEFAULT_STATE.copy(showResendCodeButton = true, input = "123456")
coEvery {
authRepository.verifyOneTimePasscode("123456")
} returns VerifyOtpResult.Verified
createViewModel(state = initialState).also { viewModel ->
viewModel.trySendAction(VerifyPasswordAction.ContinueClick)
coVerify { authRepository.verifyOneTimePasscode("123456") }
}
}
@Test
fun `VerifyOtpResultReceive verified should send event`() = runTest {
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.VerifyOtpResultReceive(
VerifyOtpResult.Verified,
),
)
viewModel.eventFlow.test {
assertEquals(
VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID),
awaitItem(),
)
}
}
}
@Test
fun `VerifyOtpResultReceive not verified should show dialog`() = runTest {
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.VerifyOtpResultReceive(
VerifyOtpResult.NotVerified(
error = IllegalStateException(),
errorMessage = null,
),
),
)
assertEquals(
VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.invalid_verification_code.asText(),
),
viewModel.stateFlow.value.dialog,
)
}
}
@Test @Test
fun `NavigateBackClick should send NavigateBack event`() = runTest { fun `NavigateBackClick should send NavigateBack event`() = runTest {
@ -130,9 +286,9 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
} }
@Test @Test
fun `UnlockClick with empty input should show error dialog`() = runTest { fun `ContinueClick with empty input should show error dialog`() = runTest {
createViewModel().also { createViewModel().also {
it.trySendAction(VerifyPasswordAction.UnlockClick) it.trySendAction(VerifyPasswordAction.ContinueClick)
it.stateFlow.test { it.stateFlow.test {
assertEquals( assertEquals(
DEFAULT_STATE.copy( DEFAULT_STATE.copy(
@ -156,13 +312,13 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `UnlockClick with non-empty input should show loading dialog, validate password and send validates password`() = fun `ContinueClick with non-empty input should show loading dialog, validate password and send validates password`() =
runTest { runTest {
val initialState = DEFAULT_STATE.copy(input = "mockInput") val initialState = DEFAULT_STATE.copy(input = "mockInput")
coEvery { authRepository.validatePassword(password = "mockInput") } just awaits coEvery { authRepository.validatePassword(password = "mockInput") } just awaits
createViewModel(state = initialState).also { viewModel -> createViewModel(state = initialState).also { viewModel ->
viewModel.trySendAction(VerifyPasswordAction.UnlockClick) viewModel.trySendAction(VerifyPasswordAction.ContinueClick)
viewModel.stateFlow.test { viewModel.stateFlow.test {
assertEquals( assertEquals(
@ -187,7 +343,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `UnlockClick with non-empty input should show loading dialog, switch accounts, then validate password when selected account is not active and switch is successful`() = fun `ContinueClick with non-empty input should show loading dialog, switch accounts, then validate password when selected account is not active and switch is successful`() =
runTest { runTest {
val initialState = DEFAULT_STATE.copy( val initialState = DEFAULT_STATE.copy(
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM
@ -199,7 +355,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
} returns SwitchAccountResult.AccountSwitched } returns SwitchAccountResult.AccountSwitched
coEvery { authRepository.validatePassword(password = "mockInput") } just awaits coEvery { authRepository.validatePassword(password = "mockInput") } just awaits
createViewModel(state = initialState).also { viewModel -> createViewModel(state = initialState).also { viewModel ->
viewModel.trySendAction(VerifyPasswordAction.UnlockClick) viewModel.trySendAction(VerifyPasswordAction.ContinueClick)
viewModel.stateFlow.test { viewModel.stateFlow.test {
assertEquals( assertEquals(
initialState.copy( initialState.copy(
@ -220,7 +376,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `UnlockClick with non-empty input should show error dialog when switch account is unsuccessful`() = fun `ContinueClick with non-empty input should show error dialog when switch account is unsuccessful`() =
runTest { runTest {
val initialState = DEFAULT_STATE.copy( val initialState = DEFAULT_STATE.copy(
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM
@ -236,7 +392,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
viewModel.stateFlow.test { viewModel.stateFlow.test {
// Await initial state update // Await initial state update
awaitItem() awaitItem()
viewModel.trySendAction(VerifyPasswordAction.UnlockClick) viewModel.trySendAction(VerifyPasswordAction.ContinueClick)
coVerify { coVerify {
authRepository.activeUserId authRepository.activeUserId
authRepository.switchAccount(userId = "otherUserId") authRepository.switchAccount(userId = "otherUserId")
@ -259,7 +415,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `UnlockClick with non-empty input should show loading dialog, then unlock vault when vault is locked`() = fun `ContinueClick with non-empty input should show loading dialog, then unlock vault when vault is locked`() =
runTest { runTest {
val initialState = DEFAULT_STATE.copy(input = "mockInput") val initialState = DEFAULT_STATE.copy(input = "mockInput")
every { vaultRepository.isVaultUnlocked(any()) } returns false every { vaultRepository.isVaultUnlocked(any()) } returns false
@ -267,7 +423,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
vaultRepository.unlockVaultWithMasterPassword(masterPassword = "mockInput") vaultRepository.unlockVaultWithMasterPassword(masterPassword = "mockInput")
} just awaits } just awaits
createViewModel(state = initialState).also { viewModel -> createViewModel(state = initialState).also { viewModel ->
viewModel.trySendAction(VerifyPasswordAction.UnlockClick) viewModel.trySendAction(VerifyPasswordAction.ContinueClick)
viewModel.stateFlow.test { viewModel.stateFlow.test {
assertEquals( assertEquals(
initialState.copy( initialState.copy(
@ -309,22 +465,21 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
assertEquals(null, viewModel.stateFlow.value.dialog) assertEquals(null, viewModel.stateFlow.value.dialog)
} }
} }
}
@Nested
inner class InternalActions {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `ValidatePasswordResultReceive should send PasswordVerified event and clear input when result is Success and isValid is true`() = fun `ValidatePasswordResultReceive should send PasswordVerified event when result is Success and isValid is true`() =
runTest { runTest {
createViewModel(state = DEFAULT_STATE.copy(input = "mockInput")) createViewModel().also { viewModel ->
.also { viewModel ->
viewModel.trySendAction( viewModel.trySendAction(
VerifyPasswordAction.Internal.ValidatePasswordResultReceive( VerifyPasswordAction.Internal.ValidatePasswordResultReceive(
ValidatePasswordResult.Success(isValid = true), ValidatePasswordResult.Success(isValid = true),
), ),
) )
assertEquals(
DEFAULT_STATE.copy(input = ""),
viewModel.stateFlow.value,
)
viewModel.eventFlow.test { viewModel.eventFlow.test {
assertEquals( assertEquals(
VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID), VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID),
@ -356,7 +511,8 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
} }
@Test @Test
fun `ValidatePasswordResultReceive should show error dialog when result is Error`() = runTest { fun `ValidatePasswordResultReceive should show error dialog when result is Error`() =
runTest {
val throwable = Throwable() val throwable = Throwable()
createViewModel().also { viewModel -> createViewModel().also { viewModel ->
viewModel.trySendAction( viewModel.trySendAction(
@ -377,19 +533,14 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `UnlockVaultResultReceive should send PasswordVerified event and clear inputs when vault unlock result is Success`() = fun `UnlockVaultResultReceive should send PasswordVerified event when vault unlock result is Success`() =
runTest { runTest {
createViewModel(state = DEFAULT_STATE.copy(input = "mockInput")) createViewModel().also { viewModel ->
.also { viewModel ->
viewModel.trySendAction( viewModel.trySendAction(
VerifyPasswordAction.Internal.UnlockVaultResultReceive( VerifyPasswordAction.Internal.UnlockVaultResultReceive(
VaultUnlockResult.Success, VaultUnlockResult.Success,
), ),
) )
assertEquals(
DEFAULT_STATE.copy(input = ""),
viewModel.stateFlow.value,
)
viewModel.eventFlow.test { viewModel.eventFlow.test {
assertEquals( assertEquals(
VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID), VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID),
@ -399,6 +550,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
} }
} }
@Suppress("MaxLineLength")
@Test @Test
fun `UnlockVaultResultReceive should show error dialog when vault unlock result is Error`() = fun `UnlockVaultResultReceive should show error dialog when vault unlock result is Error`() =
runTest { runTest {
@ -517,6 +669,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
) )
} }
} }
}
private fun createViewModel( private fun createViewModel(
state: VerifyPasswordState? = null, state: VerifyPasswordState? = null,
@ -581,6 +734,8 @@ private val DEFAULT_ACCOUNT_SELECTION_LIST_ITEM = AccountSelectionListItem(
initials = DEFAULT_USER_STATE.activeAccount.initials, initials = DEFAULT_USER_STATE.activeAccount.initials,
) )
private val DEFAULT_STATE = VerifyPasswordState( private val DEFAULT_STATE = VerifyPasswordState(
title = BitwardenString.verify_your_master_password.asText(),
subtext = null,
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM, accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM,
input = "", input = "",
dialog = null, dialog = null,

View File

@ -1135,4 +1135,6 @@ Do you want to switch to this account?</string>
<string name="your_vault_may_be_empty_or_import_some_item_types_isnt_supported">Your vault may be empty, or importing some item types isnt allowed for your account.</string> <string name="your_vault_may_be_empty_or_import_some_item_types_isnt_supported">Your vault may be empty, or importing some item types isnt allowed for your account.</string>
<string name="no_items_available_to_import">No items available to import</string> <string name="no_items_available_to_import">No items available to import</string>
<string name="select_a_different_account">Select a different account</string> <string name="select_a_different_account">Select a different account</string>
<string name="verify_your_account_email_address">Verify your account email address</string>
<string name="enter_the_6_digit_code_that_was_emailed_to_the_address_below">Enter the 6-digit code that was emailed to the address below</string>
</resources> </resources>