diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt index cd1e882f0..f510d36e9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt @@ -92,6 +92,8 @@ fun LazyListScope.vaultAddEditLoginItems( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), + canRemovePasskey = loginState.canViewPassword, + loginItemTypeHandlers = loginItemTypeHandlers, ) } } @@ -517,14 +519,29 @@ private fun PasswordRow( @Composable private fun PasskeyField( creationDateTime: Text, + canRemovePasskey: Boolean, + loginItemTypeHandlers: VaultAddEditLoginTypeHandlers, modifier: Modifier = Modifier, ) { - BitwardenTextField( + BitwardenTextFieldWithActions( label = stringResource(id = R.string.passkey), value = creationDateTime.invoke(), onValueChange = { }, readOnly = true, singleLine = true, modifier = modifier, + actions = { + if (canRemovePasskey) { + BitwardenIconButtonWithResource( + iconRes = IconResource( + iconPainter = rememberVectorPainter(id = R.drawable.ic_minus), + contentDescription = stringResource(id = R.string.remove_passkey), + ), + onClick = loginItemTypeHandlers.onClearFido2CredentialClick, + modifier = Modifier + .testTag("RemovePasskeyButton"), + ) + } + }, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt index 99e451178..a0486fb9d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt @@ -934,6 +934,10 @@ class VaultAddEditViewModel @Inject constructor( is VaultAddEditAction.ItemType.LoginType.PasswordVisibilityChange -> { handlePasswordVisibilityChange(action) } + + VaultAddEditAction.ItemType.LoginType.ClearFido2CredentialClick -> { + handleLoginClearFido2Credential() + } } } @@ -1034,6 +1038,13 @@ class VaultAddEditViewModel @Inject constructor( } } + private fun handleLoginClearFido2Credential() { + updateLoginContent { loginType -> + loginType.copy(fido2CredentialCreationDateTime = null) + } + sendEvent(event = VaultAddEditEvent.ShowToast(R.string.passkey_removed.asText())) + } + private fun handlePasswordVisibilityChange( action: VaultAddEditAction.ItemType.LoginType.PasswordVisibilityChange, ) { @@ -2700,6 +2711,11 @@ sealed class VaultAddEditAction { * @property isVisible The new password visibility state. */ data class PasswordVisibilityChange(val isVisible: Boolean) : LoginType() + + /** + * Represents the action to clear the fido2 credential. + */ + data object ClearFido2CredentialClick : LoginType() } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditLoginTypeHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditLoginTypeHandlers.kt index 7f214bf16..933c0f9e2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditLoginTypeHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditLoginTypeHandlers.kt @@ -24,6 +24,8 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.model.UriItem * @property onAddNewUriClick Handles the action when the add new URI button is clicked. * @property onPasswordVisibilityChange Handles the action when the password visibility button is * clicked. + * @property onClearFido2CredentialClick Handles the action when the clear Fido2 credential button + * is clicked. */ @Suppress("LongParameterList") data class VaultAddEditLoginTypeHandlers( @@ -39,6 +41,7 @@ data class VaultAddEditLoginTypeHandlers( val onClearTotpKeyClick: () -> Unit, val onAddNewUriClick: () -> Unit, val onPasswordVisibilityChange: (Boolean) -> Unit, + val onClearFido2CredentialClick: () -> Unit, ) { companion object { @@ -114,6 +117,11 @@ data class VaultAddEditLoginTypeHandlers( VaultAddEditAction.ItemType.LoginType.PasswordVisibilityChange(it), ) }, + onClearFido2CredentialClick = { + viewModel.trySendAction( + VaultAddEditAction.ItemType.LoginType.ClearFido2CredentialClick, + ) + }, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt index 622c9ac00..bea35e23a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt @@ -147,7 +147,8 @@ private fun VaultAddEditState.ViewState.Content.ItemType.toLoginView( uris = it.uriList.toLoginUriView(), totp = it.totp, autofillOnPageLoad = common.originalCipher?.login?.autofillOnPageLoad, - fido2Credentials = common.originalCipher?.login?.fido2Credentials, + fido2Credentials = common.originalCipher?.login?.fido2Credentials + .takeIf { _ -> it.fido2CredentialCreationDateTime != null }, ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 492f0b4a2..fcbcc2d69 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -935,4 +935,6 @@ Do you want to switch to this account? Use the generator to create and save strong, unique passwords for all your accounts. Your data, when and where you need it Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps. + Remove passkey + Passkey removed diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt index 1e449ce80..d8b8be651 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt @@ -916,6 +916,58 @@ class VaultAddEditScreenTest : BaseComposeTest() { .assertTextContains("•••••••••••") } + @Test + fun `in ItemType_Login state the Passkey should change according to state`() { + mutableStateFlow.update { + it.copy( + viewState = VaultAddEditState.ViewState.Content( + common = VaultAddEditState.ViewState.Content.Common(), + type = VaultAddEditState.ViewState.Content.ItemType.Login( + fido2CredentialCreationDateTime = "fido2Credentials".asText(), + canViewPassword = false, + ), + isIndividualVaultDisabled = false, + ), + ) + } + + composeTestRule + .onNodeWithTextAfterScroll("Passkey") + .assertTextEquals("Passkey", "fido2Credentials") + .assertIsEnabled() + composeTestRule + .onNodeWithContentDescription("Remove passkey") + .assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + viewState = VaultAddEditState.ViewState.Content( + common = VaultAddEditState.ViewState.Content.Common(), + type = VaultAddEditState.ViewState.Content.ItemType.Login( + fido2CredentialCreationDateTime = "fido2Credentials".asText(), + canViewPassword = true, + ), + isIndividualVaultDisabled = false, + ), + ) + } + + // Click on Remove Passkey button + composeTestRule + .onNodeWithTextAfterScroll("Passkey") + .assertExists() + composeTestRule + .onNodeWithContentDescription("Remove passkey") + .assertIsDisplayed() + .performClick() + + verify { + viewModel.trySendAction( + VaultAddEditAction.ItemType.LoginType.ClearFido2CredentialClick, + ) + } + } + @Test fun `in ItemType_Login state the totp text field should be present based on state`() { mutableStateFlow.update { currentState -> diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index fcb52b26a..a5fe5acb7 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -1356,56 +1356,56 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `in edit mode during FIDO 2 registration, SaveClick should display ConfirmOverwriteExistingPasskeyDialog when original cipher has a passkey`() { - val cipherView = createMockCipherView( - number = 1, - fido2Credentials = createMockSdkFido2CredentialList(number = 1), - ) - val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID) - val stateWithName = createVaultAddItemState( - vaultAddEditType = vaultAddEditType, - commonContentViewState = createCommonContentViewState( - name = "mockName-1", - originalCipher = cipherView, - customFieldData = listOf( - VaultAddEditState.Custom.HiddenField( - itemId = "testId", - name = "mockName-1", - value = "mockValue-1", + fun `in edit mode during FIDO 2 registration, SaveClick should display ConfirmOverwriteExistingPasskeyDialog when original cipher has a passkey`() = + runTest { + val cipherView = createMockCipherView( + number = 1, + fido2Credentials = createMockSdkFido2CredentialList(number = 1), + ) + val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID) + val stateWithName = createVaultAddItemState( + commonContentViewState = createCommonContentViewState( + name = cipherView.name, + originalCipher = cipherView, + ), + typeContentViewState = createLoginTypeContentViewState( + fido2CredentialCreationDateTime = R.string.created_xy.asText( + "05/08/24", + "14:30 PM", ), ), - notes = "mockNotes-1", - ), - ) - specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( - fido2CredentialRequest = createMockFido2CredentialRequest(number = 1), - ) - every { - cipherView.toViewState( - isClone = false, - isIndividualVaultDisabled = false, - resourceManager = resourceManager, - clock = fixedClock, ) - } returns stateWithName.viewState - mutableVaultDataFlow.value = DataState.Loaded( - createVaultData(cipherView = cipherView), - ) + val mockFido2CredentialRequest = createMockFido2CredentialRequest(number = 1) - val viewModel = createAddVaultItemViewModel( - createSavedStateHandleWithState( - state = stateWithName, - vaultAddEditType = vaultAddEditType, - ), - ) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( + fido2CredentialRequest = mockFido2CredentialRequest, + ) + every { + cipherView.toViewState( + isClone = false, + isIndividualVaultDisabled = false, + resourceManager = resourceManager, + clock = fixedClock, + ) + } returns stateWithName.viewState + mutableVaultDataFlow.value = DataState.Loaded( + createVaultData(cipherView = cipherView), + ) - viewModel.trySendAction(VaultAddEditAction.Common.SaveClick) + val viewModel = createAddVaultItemViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = stateWithName, + vaultAddEditType = vaultAddEditType, + ), + ) - assertEquals( - VaultAddEditState.DialogState.OverwritePasskeyConfirmationPrompt, - viewModel.stateFlow.value.dialog, - ) - } + viewModel.trySendAction(VaultAddEditAction.Common.SaveClick) + + assertEquals( + VaultAddEditState.DialogState.OverwritePasskeyConfirmationPrompt, + viewModel.stateFlow.value.dialog, + ) + } @Suppress("MaxLineLength") @Test @@ -2107,6 +2107,42 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { viewModel.stateFlow.value, ) } + + @Test + fun `ClearFido2CredentialClick call should clear the fido2 credential`() { + val viewModel = createAddVaultItemViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = createVaultAddItemState( + typeContentViewState = createLoginTypeContentViewState( + fido2CredentialCreationDateTime = R.string.created_xy.asText( + "05/08/24", + "14:30 PM", + ), + ), + ), + vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID), + ), + ) + + val expectedState = loginInitialState.copy( + viewState = VaultAddEditState.ViewState.Content( + common = createCommonContentViewState(), + isIndividualVaultDisabled = false, + type = createLoginTypeContentViewState( + fido2CredentialCreationDateTime = null, + ), + ), + ) + + viewModel.trySendAction( + VaultAddEditAction.ItemType.LoginType.ClearFido2CredentialClick, + ) + + assertEquals( + expectedState, + viewModel.stateFlow.value, + ) + } } @Nested @@ -3436,7 +3472,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { shouldRequireMasterPasswordOnRestart = false, ) } - } + } @Test fun `PinFido2SetUpRetryClick should display Fido2PinSetUpPrompt`() { diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt index 7bd6a0e4d..97e4d79f5 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt @@ -13,6 +13,7 @@ import com.bitwarden.vault.PasswordHistoryView import com.bitwarden.vault.SecureNoteType import com.bitwarden.vault.SecureNoteView import com.bitwarden.vault.UriMatchType +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFido2CredentialList import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditState import com.x8bit.bitwarden.ui.vault.feature.addedit.model.UriItem import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand @@ -676,6 +677,59 @@ class VaultAddItemStateExtensionsTest { result, ) } + + @Suppress("MaxLineLength") + @Test + fun `toLoginView should transform Login ItemType to LoginView deleting fido2Credentials with original cipher`() { + val cipherView = DEFAULT_BASE_CIPHER_VIEW.copy( + type = CipherType.LOGIN, + notes = null, + fields = emptyList(), + login = LoginView( + username = "mockUsername-1", + password = "mockPassword-1", + passwordRevisionDate = Instant.MIN, + uris = null, + totp = null, + autofillOnPageLoad = false, + fido2Credentials = createMockSdkFido2CredentialList(1), + ), + ) + + val viewState = VaultAddEditState.ViewState.Content( + common = VaultAddEditState.ViewState.Content.Common( + originalCipher = cipherView, + name = "mockName-1", + customFieldData = emptyList(), + masterPasswordReprompt = true, + ), + isIndividualVaultDisabled = false, + type = VaultAddEditState.ViewState.Content.ItemType.Login( + username = "mockUsername-1", + password = "mockPassword-1", + totp = null, + fido2CredentialCreationDateTime = null, + ), + ) + + val result = viewState.toCipherView() + + assertEquals( + cipherView.copy( + name = "mockName-1", + login = LoginView( + username = "mockUsername-1", + password = "mockPassword-1", + totp = null, + fido2Credentials = null, + uris = null, + passwordRevisionDate = Instant.MIN, + autofillOnPageLoad = false, + ), + ), + result, + ) + } } private val DEFAULT_BASE_CIPHER_VIEW: CipherView = CipherView(