[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,61 +138,121 @@ 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))
BitwardenPasswordField( if (state.showResendCodeButton) {
label = stringResource(BitwardenString.master_password), BitwardenPasswordField(
value = state.input, label = stringResource(id = BitwardenString.verification_code),
onValueChange = onInputChanged, value = state.input,
showPasswordTestTag = "PasswordVisibilityToggle", onValueChange = onInputChanged,
imeAction = ImeAction.Done, keyboardType = KeyboardType.Number,
keyboardActions = KeyboardActions( imeAction = ImeAction.Done,
onDone = { keyboardActions = KeyboardActions(
if (state.isUnlockButtonEnabled) { onDone = {
onUnlockClick() if (state.isContinueButtonEnabled) {
} else { onContinueClick()
defaultKeyboardAction(ImeAction.Done) } else {
} defaultKeyboardAction(ImeAction.Done)
}, }
), },
supportingText = stringResource(BitwardenString.vault_locked_master_password), ),
passwordFieldTestTag = "MasterPasswordEntry", autoFocus = true,
cardStyle = CardStyle.Full, cardStyle = CardStyle.Full,
modifier = Modifier.fillMaxWidth(), passwordFieldTestTag = "VerificationCodeEntry",
) showPasswordTestTag = "VerificationCodeVisibilityToggle",
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
} else {
BitwardenPasswordField(
label = stringResource(BitwardenString.master_password),
value = state.input,
onValueChange = onInputChanged,
showPasswordTestTag = "PasswordVisibilityToggle",
imeAction = ImeAction.Done,
keyboardActions = KeyboardActions(
onDone = {
if (state.isContinueButtonEnabled) {
onContinueClick()
} else {
defaultKeyboardAction(ImeAction.Done)
}
},
),
autoFocus = true,
supportingText = stringResource(BitwardenString.vault_locked_master_password),
passwordFieldTestTag = "MasterPasswordEntry",
cardStyle = CardStyle.Full,
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,
) )
VerifyPasswordContent( ExportItemsScaffold(
state = state, navIcon = rememberVectorPainter(
onInputChanged = {}, BitwardenDrawable.ic_back,
onUnlockClick = {}, ),
modifier = Modifier onNavigationIconClick = {},
.fillMaxSize() navigationIconContentDescription = stringResource(BitwardenString.back),
.standardHorizontalMargin(), scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
) modifier = Modifier.fillMaxSize(),
) {
VerifyPasswordContent(
state = state,
onInputChanged = {},
onContinueClick = {},
onResendCodeClick = {},
modifier = Modifier
.fillMaxSize(),
)
}
}
@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

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