PM-9681: Setup Bitwarden PIN on add edit view (#3627)

This commit is contained in:
Shannon Draeker 2024-07-26 09:06:49 -06:00 committed by GitHub
parent b0f0c0f33b
commit 680ebc2e47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 230 additions and 21 deletions

View file

@ -47,6 +47,7 @@ import com.x8bit.bitwarden.ui.platform.composition.LocalExitManager
import com.x8bit.bitwarden.ui.platform.composition.LocalFido2CompletionManager
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.composition.LocalPermissionsManager
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.PinInputDialog
import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager
import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
@ -206,6 +207,20 @@ fun VaultAddEditScreen(
)
}
},
onSubmitPinSetUpFido2Verification = remember(viewModel) {
{
viewModel.trySendAction(
VaultAddEditAction.Common.PinFido2SetUpSubmit(it),
)
}
},
onRetryPinSetUpFido2Verification = remember(viewModel) {
{
viewModel.trySendAction(
VaultAddEditAction.Common.PinFido2SetUpRetryClick,
)
}
},
onDismissFido2Verification = remember(viewModel) {
{
viewModel.trySendAction(
@ -353,6 +368,8 @@ private fun VaultAddEditItemDialogs(
onRetryFido2PasswordVerification: () -> Unit,
onSubmitPinFido2Verification: (pin: String) -> Unit,
onRetryFido2PinVerification: () -> Unit,
onSubmitPinSetUpFido2Verification: (pin: String) -> Unit,
onRetryPinSetUpFido2Verification: () -> Unit,
onDismissFido2Verification: () -> Unit,
) {
when (dialogState) {
@ -433,6 +450,24 @@ private fun VaultAddEditItemDialogs(
)
}
is VaultAddEditState.DialogState.Fido2PinSetUpPrompt -> {
PinInputDialog(
onCancelClick = onDismissFido2Verification,
onSubmitClick = onSubmitPinSetUpFido2Verification,
onDismissRequest = onDismissFido2Verification,
)
}
is VaultAddEditState.DialogState.Fido2PinSetUpError -> {
BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = null,
message = R.string.validation_field_required.asText(R.string.pin.asText()),
),
onDismissRequest = onRetryPinSetUpFido2Verification,
)
}
null -> Unit
}
}

View file

@ -305,6 +305,9 @@ class VaultAddEditViewModel @Inject constructor(
handleRetryFido2PinVerificationClick()
}
is VaultAddEditAction.Common.PinFido2SetUpSubmit -> handlePinFido2SetUpSubmit(action)
VaultAddEditAction.Common.PinFido2SetUpRetryClick -> handlePinFido2SetUpRetryClick()
VaultAddEditAction.Common.DismissFido2VerificationDialogClick -> {
handleDismissFido2VerificationDialogClick()
}
@ -644,7 +647,9 @@ class VaultAddEditViewModel @Inject constructor(
}
} else {
// Prompt the user to set up a PIN for their account.
// TODO: https://bitwarden.atlassian.net/browse/PM-9681
mutableStateFlow.update {
it.copy(dialog = VaultAddEditState.DialogState.Fido2PinSetUpPrompt)
}
}
}
@ -686,6 +691,32 @@ class VaultAddEditViewModel @Inject constructor(
}
}
private fun handlePinFido2SetUpSubmit(action: VaultAddEditAction.Common.PinFido2SetUpSubmit) {
if (action.pin.isBlank()) {
mutableStateFlow.update {
it.copy(dialog = VaultAddEditState.DialogState.Fido2PinSetUpError)
}
return
}
// There's no need to ask the user whether or not they want to use their master password
// on login, and shouldRequireMasterPasswordOnRestart is hardcoded to false, because the
// user can only reach this part of the flow if they have no master password.
settingsRepository.storeUnlockPin(
pin = action.pin,
shouldRequireMasterPasswordOnRestart = false,
)
// After storing the PIN, the user can proceed with their original FIDO 2 request.
handleValidAuthentication()
}
private fun handlePinFido2SetUpRetryClick() {
mutableStateFlow.update {
it.copy(dialog = VaultAddEditState.DialogState.Fido2PinSetUpPrompt)
}
}
private fun handleDismissFido2VerificationDialogClick() {
showFido2ErrorDialog()
}
@ -2273,6 +2304,19 @@ data class VaultAddEditState(
*/
@Parcelize
data object Fido2PinError : DialogState()
/**
* Displays a dialog to prompt the user to set up a PIN as part of the FIDO 2
* user verification flow.
*/
@Parcelize
data object Fido2PinSetUpPrompt : DialogState()
/**
* Displays a dialog to alert the user that the PIN is a required field.
*/
@Parcelize
data object Fido2PinSetUpError : DialogState()
}
}
@ -2563,6 +2607,18 @@ sealed class VaultAddEditAction {
*/
data object RetryFido2PinVerificationClick : Common()
/**
* The user has clicked to submit a PIN to set up for the FIDO 2 user verification flow.
*/
data class PinFido2SetUpSubmit(
val pin: String,
) : Common()
/**
* The user has clicked to retry setting up a PIN for the FIDO 2 user verification flow.
*/
data object PinFido2SetUpRetryClick : Common()
/**
* The user has clicked to dismiss the FIDO 2 password or PIN verification dialog.
*/

View file

@ -244,16 +244,6 @@ class VaultAddEditScreenTest : BaseComposeTest() {
)
}
composeTestRule
.onAllNodesWithText(text = "Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(
VaultAddEditAction.Common.DismissFido2VerificationDialogClick,
)
}
composeTestRule
.onAllNodesWithText(text = "Master password")
.filterToOne(hasAnyAncestor(isDialog()))
@ -323,16 +313,6 @@ class VaultAddEditScreenTest : BaseComposeTest() {
)
}
composeTestRule
.onAllNodesWithText(text = "Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(
VaultAddEditAction.Common.DismissFido2VerificationDialogClick,
)
}
composeTestRule
.onAllNodesWithText(text = "PIN")
.filterToOne(hasAnyAncestor(isDialog()))
@ -377,6 +357,75 @@ class VaultAddEditScreenTest : BaseComposeTest() {
}
}
@Test
fun `fido2 pin set up prompt dialog should display based on state`() {
val dialogMessage = "Enter your PIN code."
composeTestRule.onNode(isDialog()).assertDoesNotExist()
composeTestRule.onNodeWithText(dialogMessage).assertDoesNotExist()
mutableStateFlow.update {
it.copy(dialog = VaultAddEditState.DialogState.Fido2PinSetUpPrompt)
}
composeTestRule
.onNodeWithText(dialogMessage)
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
composeTestRule
.onAllNodesWithText(text = "Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(
VaultAddEditAction.Common.DismissFido2VerificationDialogClick,
)
}
composeTestRule
.onAllNodesWithText(text = "PIN")
.filterToOne(hasAnyAncestor(isDialog()))
.performTextInput("PIN")
composeTestRule
.onAllNodesWithText(text = "Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(
VaultAddEditAction.Common.PinFido2SetUpSubmit(
pin = "PIN",
),
)
}
}
@Test
fun `fido2 pin set up error dialog should display based on state`() {
val dialogMessage = "The PIN field is required."
composeTestRule.onNode(isDialog()).assertDoesNotExist()
composeTestRule.onNodeWithText(dialogMessage).assertDoesNotExist()
mutableStateFlow.update {
it.copy(dialog = VaultAddEditState.DialogState.Fido2PinSetUpError)
}
composeTestRule
.onNodeWithText(dialogMessage)
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
composeTestRule
.onAllNodesWithText(text = "Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(
VaultAddEditAction.Common.PinFido2SetUpRetryClick,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `clicking dismiss dialog on Fido2Error dialog should send Fido2ErrorDialogDismissed action`() {

View file

@ -3215,6 +3215,34 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `UserVerificationNotSupported should display Fido2PinSetUpPrompt when user has no password or pin`() {
val userState = createUserState()
mutableUserStateFlow.value = userState.copy(
accounts = listOf(
userState.accounts.first().copy(
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
trustedDevice = UserState.TrustedDevice(
isDeviceTrusted = true,
hasMasterPassword = false,
hasAdminApproval = true,
hasLoginApprovingDevice = true,
hasResetPasswordPermission = true,
),
),
),
)
viewModel.trySendAction(VaultAddEditAction.Common.UserVerificationNotSupported)
verify { fido2CredentialManager.isUserVerified = false }
assertEquals(
VaultAddEditState.DialogState.Fido2PinSetUpPrompt,
viewModel.stateFlow.value.dialog,
)
}
@Suppress("MaxLineLength")
@Test
fun `MasterPasswordFido2VerificationSubmit should display Fido2Error when password verification fails`() {
@ -3424,6 +3452,47 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `PinFido2SetUpSubmit should display Fido2PinSetUpError for empty PIN`() {
val pin = ""
viewModel.trySendAction(VaultAddEditAction.Common.PinFido2SetUpSubmit(pin = pin))
assertEquals(
VaultAddEditState.DialogState.Fido2PinSetUpError,
viewModel.stateFlow.value.dialog,
)
}
@Test
fun `PinFido2SetUpSubmit should save PIN and register credential for non-empty PIN`() {
val pin = "PIN"
every {
settingsRepository.storeUnlockPin(
pin = pin,
shouldRequireMasterPasswordOnRestart = false,
)
} just runs
viewModel.trySendAction(VaultAddEditAction.Common.PinFido2SetUpSubmit(pin = pin))
verify(exactly = 1) {
settingsRepository.storeUnlockPin(
pin = pin,
shouldRequireMasterPasswordOnRestart = false,
)
}
}
@Test
fun `PinFido2SetUpRetryClick should display Fido2PinSetUpPrompt`() {
viewModel.trySendAction(VaultAddEditAction.Common.PinFido2SetUpRetryClick)
assertEquals(
VaultAddEditState.DialogState.Fido2PinSetUpPrompt,
viewModel.stateFlow.value.dialog,
)
}
@Test
fun `DismissFido2VerificationDialogClick should display Fido2ErrorDialog`() {
viewModel.trySendAction(