diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/model/ListingItemOverflowAction.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/model/ListingItemOverflowAction.kt index 856421268..092a68096 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/model/ListingItemOverflowAction.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/model/ListingItemOverflowAction.kt @@ -65,12 +65,19 @@ sealed class ListingItemOverflowAction : Parcelable { * Represents the vault actions. */ sealed class VaultAction : ListingItemOverflowAction() { + /** + * Whether the action requires a master password re-prompt if that + * setting is enabled for the selected item. + */ + abstract val requiresPasswordReprompt: Boolean + /** * Click on the view cipher overflow option. */ @Parcelize data class ViewClick(val cipherId: String) : VaultAction() { override val title: Text get() = R.string.view.asText() + override val requiresPasswordReprompt: Boolean get() = false } /** @@ -79,6 +86,7 @@ sealed class ListingItemOverflowAction : Parcelable { @Parcelize data class EditClick(val cipherId: String) : VaultAction() { override val title: Text get() = R.string.edit.asText() + override val requiresPasswordReprompt: Boolean get() = true } /** @@ -87,6 +95,7 @@ sealed class ListingItemOverflowAction : Parcelable { @Parcelize data class CopyUsernameClick(val username: String) : VaultAction() { override val title: Text get() = R.string.copy_username.asText() + override val requiresPasswordReprompt: Boolean get() = false } /** @@ -95,6 +104,7 @@ sealed class ListingItemOverflowAction : Parcelable { @Parcelize data class CopyPasswordClick(val password: String) : VaultAction() { override val title: Text get() = R.string.copy_password.asText() + override val requiresPasswordReprompt: Boolean get() = true } /** @@ -103,6 +113,7 @@ sealed class ListingItemOverflowAction : Parcelable { @Parcelize data class CopyTotpClick(val totpCode: String) : VaultAction() { override val title: Text get() = R.string.copy_totp.asText() + override val requiresPasswordReprompt: Boolean get() = false } /** @@ -111,6 +122,7 @@ sealed class ListingItemOverflowAction : Parcelable { @Parcelize data class CopyNumberClick(val number: String) : VaultAction() { override val title: Text get() = R.string.copy_number.asText() + override val requiresPasswordReprompt: Boolean get() = true } /** @@ -119,6 +131,7 @@ sealed class ListingItemOverflowAction : Parcelable { @Parcelize data class CopySecurityCodeClick(val securityCode: String) : VaultAction() { override val title: Text get() = R.string.copy_security_code.asText() + override val requiresPasswordReprompt: Boolean get() = true } /** @@ -127,6 +140,7 @@ sealed class ListingItemOverflowAction : Parcelable { @Parcelize data class CopyNoteClick(val notes: String) : VaultAction() { override val title: Text get() = R.string.copy_notes.asText() + override val requiresPasswordReprompt: Boolean get() = false } /** @@ -135,6 +149,7 @@ sealed class ListingItemOverflowAction : Parcelable { @Parcelize data class LaunchClick(val url: String) : VaultAction() { override val title: Text get() = R.string.launch.asText() + override val requiresPasswordReprompt: Boolean get() = false } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt index 16e49a7ca..85a7bbde7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt @@ -17,6 +17,7 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.components.BitwardenGroupItem import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderTextWithSupportLabel import com.x8bit.bitwarden.ui.platform.components.model.toIconResources +import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.vault.handlers.VaultHandlers import kotlinx.collections.immutable.toPersistentList @@ -28,6 +29,7 @@ import kotlinx.collections.immutable.toPersistentList fun VaultContent( state: VaultState.ViewState.Content, vaultHandlers: VaultHandlers, + onOverflowOptionClick: (action: ListingItemOverflowAction.VaultAction) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( @@ -86,7 +88,15 @@ fun VaultContent( supportingLabel = favoriteItem.supportingLabel?.invoke(), onClick = { vaultHandlers.vaultItemClick(favoriteItem) }, overflowOptions = favoriteItem.overflowOptions, - onOverflowOptionClick = vaultHandlers.overflowOptionClick, + onOverflowOptionClick = { action -> + if (favoriteItem.shouldShowMasterPasswordReprompt && + action.requiresPasswordReprompt + ) { + onOverflowOptionClick(action) + } else { + vaultHandlers.overflowOptionClick(action) + } + }, modifier = Modifier .fillMaxWidth() .padding( @@ -245,7 +255,15 @@ fun VaultContent( supportingLabel = noFolderItem.supportingLabel?.invoke(), onClick = { vaultHandlers.vaultItemClick(noFolderItem) }, overflowOptions = noFolderItem.overflowOptions, - onOverflowOptionClick = vaultHandlers.overflowOptionClick, + onOverflowOptionClick = { action -> + if (noFolderItem.shouldShowMasterPasswordReprompt && + action.requiresPasswordReprompt + ) { + onOverflowOptionClick(action) + } else { + vaultHandlers.overflowOptionClick(action) + } + }, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt index 2d9d5865a..04c29e162 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt @@ -43,6 +43,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog +import com.x8bit.bitwarden.ui.platform.components.BitwardenMasterPasswordDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold @@ -55,6 +56,7 @@ import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.theme.LocalExitManager import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager +import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.vault.handlers.VaultHandlers import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType import kotlinx.collections.immutable.persistentListOf @@ -190,6 +192,21 @@ private fun VaultScreenScaffold( ) } + var masterPasswordRepromptAction by remember { + mutableStateOf(null) + } + masterPasswordRepromptAction?.let { action -> + BitwardenMasterPasswordDialog( + onConfirmClick = { password -> + masterPasswordRepromptAction = null + vaultHandlers.masterPasswordRepromptSubmit(action, password) + }, + onDismissRequest = { + masterPasswordRepromptAction = null + }, + ) + } + BitwardenScaffold( topBar = { BitwardenMediumTopAppBar( @@ -276,6 +293,7 @@ private fun VaultScreenScaffold( is VaultState.ViewState.Content -> VaultContent( state = viewState, vaultHandlers = vaultHandlers, + onOverflowOptionClick = { masterPasswordRepromptAction = it }, modifier = innerModifier, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 5a0bbd744..9df2da692 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -7,6 +7,7 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.repository.SettingsRepository @@ -144,6 +145,11 @@ class VaultViewModel @Inject constructor( is VaultAction.DialogDismiss -> handleDialogDismiss() is VaultAction.RefreshPull -> handleRefreshPull() is VaultAction.OverflowOptionClick -> handleOverflowOptionClick(action) + + is VaultAction.MasterPasswordRepromptSubmit -> { + handleMasterPasswordRepromptSubmit(action) + } + is VaultAction.Internal -> handleInternalAction(action) } } @@ -326,6 +332,20 @@ class VaultViewModel @Inject constructor( } } + private fun handleMasterPasswordRepromptSubmit( + action: VaultAction.MasterPasswordRepromptSubmit, + ) { + viewModelScope.launch { + val result = authRepository.validatePassword(action.password) + sendAction( + VaultAction.Internal.ValidatePasswordResultReceive( + overflowAction = action.overflowAction, + result = result, + ), + ) + } + } + private fun handleCopyNoteClick(action: ListingItemOverflowAction.VaultAction.CopyNoteClick) { clipboardManager.setText(action.notes) } @@ -390,6 +410,10 @@ class VaultViewModel @Inject constructor( is VaultAction.Internal.IconLoadingSettingReceive -> handleIconLoadingSettingReceive( action, ) + + is VaultAction.Internal.ValidatePasswordResultReceive -> { + handleValidatePasswordResultReceive(action) + } } } @@ -525,6 +549,39 @@ class VaultViewModel @Inject constructor( } } + private fun handleValidatePasswordResultReceive( + action: VaultAction.Internal.ValidatePasswordResultReceive, + ) { + when (val result = action.result) { + ValidatePasswordResult.Error -> { + mutableStateFlow.update { + it.copy( + dialog = VaultState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + ), + ) + } + } + + is ValidatePasswordResult.Success -> { + if (!result.isValid) { + mutableStateFlow.update { + it.copy( + dialog = VaultState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.invalid_master_password.asText(), + ), + ) + } + return + } + // Complete the overflow action. + trySendAction(VaultAction.OverflowOptionClick(action.overflowAction)) + } + } + } + //endregion VaultAction Handlers } @@ -721,6 +778,11 @@ data class VaultState( */ abstract val overflowOptions: List + /** + * Whether to prompt the user for their password when they select an overflow option. + */ + abstract val shouldShowMasterPasswordReprompt: Boolean + /** * Represents a login item within the vault. * @@ -733,6 +795,7 @@ data class VaultState( override val startIcon: IconData = IconData.Local(R.drawable.ic_login_item), override val extraIconList: List = emptyList(), override val overflowOptions: List, + override val shouldShowMasterPasswordReprompt: Boolean, val username: Text?, ) : VaultItem() { override val supportingLabel: Text? get() = username @@ -751,6 +814,7 @@ data class VaultState( override val startIcon: IconData = IconData.Local(R.drawable.ic_card_item), override val extraIconList: List = emptyList(), override val overflowOptions: List, + override val shouldShowMasterPasswordReprompt: Boolean, val brand: Text? = null, val lastFourDigits: Text? = null, ) : VaultItem() { @@ -779,6 +843,7 @@ data class VaultState( override val startIcon: IconData = IconData.Local(R.drawable.ic_identity_item), override val extraIconList: List = emptyList(), override val overflowOptions: List, + override val shouldShowMasterPasswordReprompt: Boolean, val firstName: Text?, ) : VaultItem() { override val supportingLabel: Text? get() = firstName @@ -795,6 +860,7 @@ data class VaultState( override val startIcon: IconData = IconData.Local(R.drawable.ic_secure_note_item), override val extraIconList: List = emptyList(), override val overflowOptions: List, + override val shouldShowMasterPasswordReprompt: Boolean, ) : VaultItem() { override val supportingLabel: Text? get() = null } @@ -1033,6 +1099,15 @@ sealed class VaultAction { val overflowAction: ListingItemOverflowAction.VaultAction, ) : VaultAction() + /** + * User submitted their master password to authenticate before continuing with + * the selected overflow action. + */ + data class MasterPasswordRepromptSubmit( + val overflowAction: ListingItemOverflowAction.VaultAction, + val password: String, + ) : VaultAction() + /** * Models actions that the [VaultViewModel] itself might send. */ @@ -1070,6 +1145,14 @@ sealed class VaultAction { data class VaultDataReceive( val vaultData: DataState, ) : Internal() + + /** + * Indicates that a result for verifying the user's master password has been received. + */ + data class ValidatePasswordResultReceive( + val overflowAction: ListingItemOverflowAction.VaultAction, + val result: ValidatePasswordResult, + ) : Internal() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt index 1641f080e..006f82702 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt @@ -33,6 +33,7 @@ data class VaultHandlers( val tryAgainClick: () -> Unit, val dialogDismiss: () -> Unit, val overflowOptionClick: (ListingItemOverflowAction.VaultAction) -> Unit, + val masterPasswordRepromptSubmit: (ListingItemOverflowAction.VaultAction, String) -> Unit, ) { companion object { /** @@ -79,6 +80,14 @@ data class VaultHandlers( overflowOptionClick = { viewModel.trySendAction(VaultAction.OverflowOptionClick(it)) }, + masterPasswordRepromptSubmit = { action, password -> + viewModel.trySendAction( + VaultAction.MasterPasswordRepromptSubmit( + overflowAction = action, + password = password, + ), + ) + }, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt index 4019a068f..160be9fa6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.vault.feature.vault.util import android.net.Uri +import com.bitwarden.core.CipherRepromptType import com.bitwarden.core.CipherType import com.bitwarden.core.CipherView import com.bitwarden.core.CollectionView @@ -160,6 +161,7 @@ private fun CipherView.toVaultItemOrNull( ), overflowOptions = toOverflowActions(), extraIconList = toLabelIcons(), + shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD, ) CipherType.SECURE_NOTE -> VaultState.ViewState.VaultItem.SecureNote( @@ -167,6 +169,7 @@ private fun CipherView.toVaultItemOrNull( name = name.asText(), overflowOptions = toOverflowActions(), extraIconList = toLabelIcons(), + shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD, ) CipherType.CARD -> VaultState.ViewState.VaultItem.Card( @@ -178,6 +181,7 @@ private fun CipherView.toVaultItemOrNull( ?.asText(), overflowOptions = toOverflowActions(), extraIconList = toLabelIcons(), + shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD, ) CipherType.IDENTITY -> VaultState.ViewState.VaultItem.Identity( @@ -186,6 +190,7 @@ private fun CipherView.toVaultItemOrNull( firstName = identity?.firstName?.asText(), overflowOptions = toOverflowActions(), extraIconList = toLabelIcons(), + shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index d245cb244..e7e1f4cbb 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -386,7 +386,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `MasterPasswordRepromptSubmit for a request Success with a valid password should should post to the AutofillSelectionManager`() = + fun `MasterPasswordRepromptSubmit for a request Success with a valid password should post to the AutofillSelectionManager`() = runTest { setupMockUri() val cipherView = createMockCipherView(number = 1) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index 8de0fb6e9..02e7d1c39 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -736,6 +736,7 @@ class VaultScreenTest : BaseComposeTest() { name = itemText.asText(), username = username.asText(), overflowOptions = emptyList(), + shouldShowMasterPasswordReprompt = false, ) mutableStateFlow.update { it.copy( @@ -857,6 +858,7 @@ class VaultScreenTest : BaseComposeTest() { name = itemText.asText(), username = userName.asText(), overflowOptions = emptyList(), + shouldShowMasterPasswordReprompt = false, ) mutableStateFlow.update { it.copy( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index cd33780ba..0d7f052ca 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.Organization import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.repository.SettingsRepository @@ -1328,6 +1329,104 @@ class VaultViewModelTest : BaseViewModelTest() { } } + @Test + fun `MasterPasswordRepromptSubmit for a request Error should show a generic error dialog`() = + runTest { + val password = "password" + coEvery { + authRepository.validatePassword(password = password) + } returns ValidatePasswordResult.Error + + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE, + awaitItem(), + ) + + viewModel.trySendAction( + VaultAction.MasterPasswordRepromptSubmit( + overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick( + password = password, + ), + password = password, + ), + ) + + assertEquals( + DEFAULT_STATE.copy( + dialog = VaultState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + ), + ), + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `MasterPasswordRepromptSubmit for a request Success with an invalid password should show an invalid password dialog`() = + runTest { + val password = "password" + coEvery { + authRepository.validatePassword(password = password) + } returns ValidatePasswordResult.Success(isValid = false) + + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE, + awaitItem(), + ) + + viewModel.trySendAction( + VaultAction.MasterPasswordRepromptSubmit( + overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick( + password = password, + ), + password = password, + ), + ) + + assertEquals( + DEFAULT_STATE.copy( + dialog = VaultState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.invalid_master_password.asText(), + ), + ), + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `MasterPasswordRepromptSubmit for a request Success with a valid password should continue the action`() = + runTest { + val password = "password" + coEvery { + authRepository.validatePassword(password = password) + } returns ValidatePasswordResult.Success(isValid = true) + + val viewModel = createViewModel() + + viewModel.trySendAction( + VaultAction.MasterPasswordRepromptSubmit( + overflowAction = ListingItemOverflowAction.VaultAction.CopyPasswordClick( + password = password, + ), + password = password, + ), + ) + + verify(exactly = 1) { + clipboardManager.setText(password) + } + } + private fun createViewModel(): VaultViewModel = VaultViewModel( authRepository = authRepository,