mirror of
https://github.com/bitwarden/android.git
synced 2025-12-10 09:56:45 -06:00
[PM-26810] Add OTP support to VerifyPasswordScreen (#6034)
This commit is contained in:
parent
ae3470c598
commit
74aa0a78ec
@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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 isn’t 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 isn’t 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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user