diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt index 9cfc8d4e71..80bed6c3d3 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt @@ -37,6 +37,7 @@ sealed class FlagKey { RestrictCipherItemDeletion, PreAuthSettings, UserManagedPrivilegedApps, + RemoveCardPolicy, ) } } @@ -181,6 +182,15 @@ sealed class FlagKey { override val defaultValue: Boolean = false } + /** + * Data object holding the feature flag key to enable the removal of card item types. + * This flag will hide card types from organizations with policy enable and individual vaults + */ + data object RemoveCardPolicy : FlagKey() { + override val keyName: String = "pm-16442-remove-card-item-type-policy" + override val defaultValue: Boolean = false + } + //region Dummy keys for testing /** * Data object holding the key for a [Boolean] flag to be used in tests. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt index 99c7b0faca..228c00a1cf 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt @@ -42,6 +42,7 @@ fun FlagKey.ListItemContent( FlagKey.RestrictCipherItemDeletion, FlagKey.PreAuthSettings, FlagKey.UserManagedPrivilegedApps, + FlagKey.RemoveCardPolicy, -> { @Suppress("UNCHECKED_CAST") BooleanFlagItem( @@ -105,4 +106,5 @@ private fun FlagKey.getDisplayLabel(): String = when (this) { FlagKey.UserManagedPrivilegedApps -> { stringResource(R.string.user_trusted_privileged_app_management) } + FlagKey.RemoveCardPolicy -> stringResource(R.string.remove_card_policy) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index e26ed1fd47..ea66f8f8c3 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -11,7 +11,6 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.core.data.repository.util.map -import com.bitwarden.core.util.persistentListOfNotNull import com.bitwarden.data.repository.util.baseIconUrl import com.bitwarden.data.repository.util.baseWebSendUrl import com.bitwarden.fido.Fido2CredentialAutofillView @@ -49,11 +48,13 @@ import com.x8bit.bitwarden.data.credentials.model.ValidateOriginResult import com.x8bit.bitwarden.data.credentials.parser.RelyingPartyParser import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository import com.x8bit.bitwarden.data.credentials.util.getCreatePasskeyCredentialRequestOrNull +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSelectionDataOrNull @@ -93,6 +94,7 @@ import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.toSendItemType import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.toVaultItemCipherType import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.toViewState import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.updateWithAdditionalDataIfNecessary +import com.x8bit.bitwarden.ui.vault.feature.vault.VaultAction import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries import com.x8bit.bitwarden.ui.vault.feature.vault.util.toActiveAccountSummary @@ -102,7 +104,10 @@ import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType import com.x8bit.bitwarden.ui.vault.util.toVaultItemCipherType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -111,6 +116,7 @@ import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import java.time.Clock import javax.inject.Inject +import kotlin.collections.map /** * Manages [VaultItemListingState], handles [VaultItemListingsAction], @@ -136,6 +142,7 @@ class VaultItemListingViewModel @Inject constructor( private val bitwardenCredentialManager: BitwardenCredentialManager, private val organizationEventManager: OrganizationEventManager, private val networkConnectionManager: NetworkConnectionManager, + private val featureFlagManager: FeatureFlagManager, private val snackbarRelayManager: SnackbarRelayManager, private val relyingPartyParser: RelyingPartyParser, ) : BaseViewModel( @@ -165,6 +172,7 @@ class VaultItemListingViewModel @Inject constructor( policyDisablesSend = policyManager .getActivePolicies(type = PolicyTypeJson.DISABLE_SEND) .any(), + restrictItemTypesPolicyOrgIds = persistentListOf(), autofillSelectionData = specialCircumstance?.toAutofillSelectionDataOrNull(), hasMasterPassword = userState.activeAccount.hasMasterPassword, totpData = specialCircumstance?.toTotpDataOrNull(), @@ -195,6 +203,21 @@ class VaultItemListingViewModel @Inject constructor( .onEach(::sendAction) .launchIn(viewModelScope) + policyManager + .getActivePoliciesFlow(type = PolicyTypeJson.RESTRICT_ITEM_TYPES) + .combine( + featureFlagManager.getFeatureFlagFlow(FlagKey.RemoveCardPolicy), + ) { policies, enabledFlag -> + if (enabledFlag) { + policies.map { it.organizationId } + } else { + emptyList() + } + } + .map { VaultItemListingsAction.Internal.RestrictItemTypesPolicyUpdateReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + snackbarRelayManager .getSnackbarDataFlow(SnackbarRelay.SEND_DELETED, SnackbarRelay.SEND_UPDATED) .map { VaultItemListingsAction.Internal.SnackbarDataReceived(it) } @@ -763,16 +786,29 @@ class VaultItemListingViewModel @Inject constructor( ) } + private fun createVaultItemTypeSelectionExcludedOptions(): ImmutableList { + // If policy is enable for any organization, exclude the card option + return if (state.restrictItemTypesPolicyOrgIds.isNotEmpty()) { + persistentListOf( + CreateVaultItemType.CARD, + CreateVaultItemType.FOLDER, + CreateVaultItemType.SSH_KEY, + ) + } else { + persistentListOf( + CreateVaultItemType.SSH_KEY, + CreateVaultItemType.FOLDER, + ) + } + } + private fun handleAddVaultItemClick() { when (val itemListingType = state.itemListingType) { is VaultItemListingState.ItemListingType.Vault.Collection -> { mutableStateFlow.update { it.copy( dialogState = VaultItemListingState.DialogState.VaultItemTypeSelection( - excludedOptions = persistentListOfNotNull( - CreateVaultItemType.SSH_KEY, - CreateVaultItemType.FOLDER, - ), + excludedOptions = createVaultItemTypeSelectionExcludedOptions(), ), ) } @@ -782,10 +818,7 @@ class VaultItemListingViewModel @Inject constructor( mutableStateFlow.update { it.copy( dialogState = VaultItemListingState.DialogState.VaultItemTypeSelection( - excludedOptions = persistentListOfNotNull( - CreateVaultItemType.SSH_KEY, - CreateVaultItemType.FOLDER, - ), + excludedOptions = createVaultItemTypeSelectionExcludedOptions(), ), ) } @@ -1427,12 +1460,32 @@ class VaultItemListingViewModel @Inject constructor( handleGetCredentialEntriesResultReceive(action) } + is VaultItemListingsAction.Internal.RestrictItemTypesPolicyUpdateReceive -> { + handleRestrictItemTypesPolicyUpdateReceive(action) + } + is VaultItemListingsAction.Internal.SnackbarDataReceived -> { handleSnackbarDataReceived(action) } } } + private fun handleRestrictItemTypesPolicyUpdateReceive( + action: VaultItemListingsAction.Internal.RestrictItemTypesPolicyUpdateReceive, + ) { + mutableStateFlow.update { + it.copy( + restrictItemTypesPolicyOrgIds = action + .restrictItemTypesPolicyOrdIds + .toImmutableList(), + ) + } + + vaultRepository.vaultDataStateFlow.value.data?.let { vaultData -> + updateStateWithVaultData(vaultData, clearDialogState = false) + } + } + private fun handleInternetConnectionErrorReceived() { mutableStateFlow.update { it.copy( @@ -2119,6 +2172,7 @@ class VaultItemListingViewModel @Inject constructor( .fido2CredentialAutofillViewList, totpData = state.totpData, isPremiumUser = state.isPremium, + restrictItemTypesPolicyOrgIds = state.restrictItemTypesPolicyOrgIds, ) } @@ -2263,6 +2317,7 @@ data class VaultItemListingState( val isIconLoadingDisabled: Boolean, val dialogState: DialogState?, val policyDisablesSend: Boolean, + val restrictItemTypesPolicyOrgIds: ImmutableList, // Internal private val isPullToRefreshSettingEnabled: Boolean, val totpData: TotpData? = null, @@ -2278,8 +2333,13 @@ data class VaultItemListingState( * Whether or not the add FAB should be shown. */ val hasAddItemFabButton: Boolean - get() = itemListingType.hasFab || - (viewState is ViewState.NoItems && viewState.shouldShowAddButton) + get() = if (restrictItemTypesPolicyOrgIds.isNotEmpty() && + itemListingType == VaultItemListingState.ItemListingType.Vault.Card) { + false + } else { + itemListingType.hasFab || + (viewState as? ViewState.NoItems)?.shouldShowAddButton == true + } /** * Whether or not this represents a listing screen for autofill. @@ -3160,6 +3220,13 @@ sealed class VaultItemListingsAction { val policyDisablesSend: Boolean, ) : Internal() + /** + * Indicates that a restrict item types policy update has been received. + */ + data class RestrictItemTypesPolicyUpdateReceive( + val restrictItemTypesPolicyOrdIds: List, + ) : Internal() + /** * Indicates that a credential creation request has been received from the * CredentialManager. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt index 5e8e5079e1..1c3917f30b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt @@ -32,6 +32,7 @@ import com.x8bit.bitwarden.ui.vault.feature.util.toFolderDisplayName import com.x8bit.bitwarden.ui.vault.feature.util.toLabelIcons import com.x8bit.bitwarden.ui.vault.feature.util.toOverflowActions import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType +import com.x8bit.bitwarden.ui.vault.feature.vault.util.applyRestrictItemTypesPolicy import com.x8bit.bitwarden.ui.vault.feature.vault.util.toFilteredList import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData import com.x8bit.bitwarden.ui.vault.model.TotpData @@ -111,11 +112,13 @@ fun VaultData.toViewState( fido2CredentialAutofillViews: List?, totpData: TotpData?, isPremiumUser: Boolean, + restrictItemTypesPolicyOrgIds: List, ): VaultItemListingState.ViewState { val filteredCipherViewList = cipherViewList .filter { cipherView -> cipherView.determineListingPredicate(itemListingType) } + .applyRestrictItemTypesPolicy(restrictItemTypesPolicyOrgIds) .toFilteredList(vaultFilterType) val folderList = @@ -214,13 +217,12 @@ fun VaultData.toViewState( } .asText() } - val shouldShowAddButton = when (itemListingType) { - VaultItemListingState.ItemListingType.Vault.Trash, - VaultItemListingState.ItemListingType.Vault.SshKey, - -> false - else -> true - } + val restrictItemTypePolicyEnabled = restrictItemTypesPolicyOrgIds.isNotEmpty() && + itemListingType == VaultItemListingState.ItemListingType.Vault.Card + + val shouldShowAddButton = !restrictItemTypePolicyEnabled && itemListingType.hasFab + VaultItemListingState.ViewState.NoItems( header = totpData ?.let { R.string.no_items_for_vault.asText(it.issuer ?: it.accountName ?: "--") }, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt index 3bb8ab9f0f..a73612a0f1 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt @@ -36,7 +36,7 @@ private const val TRASH_TYPES_COUNT: Int = 1 * Content view for the [VaultScreen]. */ @Composable -@Suppress("LongMethod") +@Suppress("LongMethod", "CyclomaticComplexMethod") fun VaultContent( state: VaultState.ViewState.Content, vaultHandlers: VaultHandlers, @@ -183,20 +183,22 @@ fun VaultContent( ) } - item { - BitwardenGroupItem( - startIcon = rememberVectorPainter(id = BitwardenDrawable.ic_payment_card), - startIconTestTag = "CardCipherIcon", - label = stringResource(id = R.string.type_card), - supportingLabel = state.cardItemsCount.toString(), - onClick = vaultHandlers.cardGroupClick, - showDivider = false, - cardStyle = CardStyle.Middle(dividerPadding = 56.dp), - modifier = Modifier - .fillMaxWidth() - .testTag("CardFilter") - .standardHorizontalMargin(), - ) + if (state.showCardGroup) { + item { + BitwardenGroupItem( + startIcon = rememberVectorPainter(id = BitwardenDrawable.ic_payment_card), + startIconTestTag = "CardCipherIcon", + label = stringResource(id = R.string.type_card), + supportingLabel = state.cardItemsCount.toString(), + onClick = vaultHandlers.cardGroupClick, + showDivider = false, + cardStyle = CardStyle.Middle(dividerPadding = 56.dp), + modifier = Modifier + .fillMaxWidth() + .testTag("CardFilter") + .standardHorizontalMargin(), + ) + } } item { diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt index fa9eb760d6..6973a79165 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt @@ -422,11 +422,12 @@ private fun VaultDialogs( onDismissRequest = vaultHandlers.dialogDismiss, ) - VaultState.DialogState.SelectVaultAddItemType -> VaultItemSelectionDialog( + is VaultState.DialogState.SelectVaultAddItemType -> VaultItemSelectionDialog( onOptionSelected = { vaultHandlers.addItemClickAction(it) }, onDismissRequest = vaultHandlers.dialogDismiss, + excludedOptions = dialogState.excludedOptions, ) null -> Unit diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index d329929bda..baf155744a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -72,6 +72,8 @@ import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import java.time.Clock import javax.inject.Inject +import kotlin.collections.emptyList +import kotlin.collections.map /** * Manages [VaultState], handles [VaultAction], and launches [VaultEvent] for the [VaultScreen]. @@ -120,6 +122,7 @@ class VaultViewModel @Inject constructor( flightRecorderSnackBar = settingsRepository .flightRecorderData .toSnackbarData(clock = clock), + restrictItemTypesPolicyOrgIds = emptyList(), ) }, ) { @@ -177,6 +180,21 @@ class VaultViewModel @Inject constructor( .map { VaultAction.Internal.FlightRecorderDataReceive(data = it) } .onEach(::sendAction) .launchIn(viewModelScope) + + policyManager + .getActivePoliciesFlow(type = PolicyTypeJson.RESTRICT_ITEM_TYPES) + .combine( + featureFlagManager.getFeatureFlagFlow(FlagKey.RemoveCardPolicy), + ) { policies, enabledFlag -> + if (enabledFlag && policies.isNotEmpty()) { + policies.map { it.organizationId } + } else { + null + } + } + .map { VaultAction.Internal.PolicyUpdateReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: VaultAction) { @@ -233,9 +251,20 @@ class VaultViewModel @Inject constructor( } private fun handleSelectAddItemType() { + // If policy is enable for any organization, exclude the card option + var excludedOptions = + if (!state.restrictItemTypesPolicyOrgIds.isNullOrEmpty()) { + persistentListOf( + CreateVaultItemType.SSH_KEY, + CreateVaultItemType.CARD, + ) + } else { + persistentListOf(CreateVaultItemType.SSH_KEY) + } + mutableStateFlow.update { it.copy( - dialog = VaultState.DialogState.SelectVaultAddItemType, + dialog = VaultState.DialogState.SelectVaultAddItemType(excludedOptions), ) } } @@ -647,6 +676,20 @@ class VaultViewModel @Inject constructor( is VaultAction.Internal.FlightRecorderDataReceive -> { handleFlightRecorderDataReceive(action) } + + is VaultAction.Internal.PolicyUpdateReceive -> { + handlePolicyUpdateReceive(action) + } + } + } + + private fun handlePolicyUpdateReceive(action: VaultAction.Internal.PolicyUpdateReceive) { + mutableStateFlow.update { + it.copy(restrictItemTypesPolicyOrgIds = action.restrictItemTypesPolicyOrdIds) + } + + vaultRepository.vaultDataStateFlow.value.data?.let { vaultData -> + updateVaultState(vaultData, clearDialog = false) } } @@ -767,6 +810,7 @@ class VaultViewModel @Inject constructor( errorTitle = R.string.an_error_has_occurred.asText(), errorMessage = R.string.generic_error_message.asText(), isRefreshing = false, + restrictItemTypesPolicyOrgIds = state.restrictItemTypesPolicyOrgIds, ) } @@ -783,6 +827,7 @@ class VaultViewModel @Inject constructor( private fun updateVaultState( vaultData: VaultData, + clearDialog: Boolean = true, ) { mutableStateFlow.update { it.copy( @@ -792,8 +837,9 @@ class VaultViewModel @Inject constructor( isPremium = state.isPremium, hasMasterPassword = state.hasMasterPassword, vaultFilterType = vaultFilterTypeOrDefault, + restrictItemTypesPolicyOrgIds = state.restrictItemTypesPolicyOrgIds, ), - dialog = null, + dialog = if (clearDialog) null else state.dialog, isRefreshing = false, ) } @@ -826,6 +872,7 @@ class VaultViewModel @Inject constructor( isPremium = state.isPremium, hasMasterPassword = state.hasMasterPassword, vaultFilterType = vaultFilterTypeOrDefault, + restrictItemTypesPolicyOrgIds = state.restrictItemTypesPolicyOrgIds, ), ) } @@ -931,6 +978,7 @@ data class VaultState( private val isPullToRefreshSettingEnabled: Boolean, val baseIconUrl: String, val isIconLoadingDisabled: Boolean, + val restrictItemTypesPolicyOrgIds: List?, ) : Parcelable { /** @@ -1025,6 +1073,7 @@ data class VaultState( val noFolderItems: List, val collectionItems: List, val trashItemsCount: Int, + val showCardGroup: Boolean, ) : ViewState() { override val hasFab: Boolean get() = true override val isPullToRefreshEnabled: Boolean get() = true @@ -1246,7 +1295,9 @@ data class VaultState( * Represents a dialog for selecting a vault item type to add. */ @Parcelize - data object SelectVaultAddItemType : DialogState() + data class SelectVaultAddItemType( + val excludedOptions: ImmutableList, + ) : DialogState() /** * Represents an error dialog with the given [title] and [message]. @@ -1612,6 +1663,13 @@ sealed class VaultAction { data class FlightRecorderDataReceive( val data: FlightRecorderDataSet, ) : Internal() + + /** + * Indicates that a policy update has been received. + */ + data class PolicyUpdateReceive( + val restrictItemTypesPolicyOrdIds: List?, + ) : Internal() } } @@ -1626,6 +1684,7 @@ private fun MutableStateFlow.updateToErrorStateOrDialog( errorTitle: Text, errorMessage: Text, isRefreshing: Boolean, + restrictItemTypesPolicyOrgIds: List?, ) { this.update { if (vaultData != null) { @@ -1636,6 +1695,7 @@ private fun MutableStateFlow.updateToErrorStateOrDialog( hasMasterPassword = hasMasterPassword, vaultFilterType = vaultFilterType, isIconLoadingDisabled = isIconLoadingDisabled, + restrictItemTypesPolicyOrgIds = restrictItemTypesPolicyOrgIds, ), dialog = VaultState.DialogState.Error( title = errorTitle, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt index 5bee481d60..cca79fc3ac 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt @@ -40,10 +40,13 @@ fun VaultData.toViewState( isIconLoadingDisabled: Boolean, baseIconUrl: String, vaultFilterType: VaultFilterType, + restrictItemTypesPolicyOrgIds: List?, ): VaultState.ViewState { val filteredCipherViewListWithDeletedItems = - cipherViewList.toFilteredList(vaultFilterType) + cipherViewList + .applyRestrictItemTypesPolicy(restrictItemTypesPolicyOrgIds ?: emptyList()) + .toFilteredList(vaultFilterType) val filteredCipherViewList = filteredCipherViewListWithDeletedItems .filter { it.deletedDate == null } @@ -70,6 +73,7 @@ fun VaultData.toViewState( val totpItems = filteredCipherViewList.filter { it.login?.totp != null } val shouldShowUnGroupedItems = filteredCollectionViewList.isEmpty() && noFolderItems.size < NO_FOLDER_ITEM_THRESHOLD + val cardCount = filteredCipherViewList.count { it.type == CipherType.CARD } VaultState.ViewState.Content( itemTypesCount = itemTypesCount, totpItemsCount = if (isPremium) { @@ -78,7 +82,7 @@ fun VaultData.toViewState( totpItems.count { it.organizationUseTotp } }, loginItemsCount = filteredCipherViewList.count { it.type == CipherType.LOGIN }, - cardItemsCount = filteredCipherViewList.count { it.type == CipherType.CARD }, + cardItemsCount = cardCount, identityItemsCount = filteredCipherViewList.count { it.type == CipherType.IDENTITY }, secureNoteItemsCount = filteredCipherViewList .count { it.type == CipherType.SECURE_NOTE }, @@ -145,6 +149,7 @@ fun VaultData.toViewState( trashItemsCount = filteredCipherViewListWithDeletedItems.count { it.deletedDate != null }, + showCardGroup = cardCount != 0 || restrictItemTypesPolicyOrgIds == null, ) } } @@ -356,3 +361,26 @@ fun List.toFilteredList( } } } + +/** + * Filters out [CipherType.CARD] [CipherView]s that are in [restrictItemTypesPolicyOrgIds] list. + * When [restrictItemTypesPolicyOrgIds] is not empty, individual vault items are also removed. + */ +fun List.applyRestrictItemTypesPolicy( + restrictItemTypesPolicyOrgIds: List, +): List = + this + .filterNot { cipherView -> + if (restrictItemTypesPolicyOrgIds.isEmpty()) { + // No policy, so don't apply removal + false + } else if (cipherView.type != CipherType.CARD) { + // Policy only for cards + false + } else { + // If a policy is enable for a given organization then + // also hide cards from individual vault + cipherView.organizationId.isNullOrEmpty() || + restrictItemTypesPolicyOrgIds.contains(cipherView.organizationId) + } + } diff --git a/app/src/main/res/values/strings_non_localized.xml b/app/src/main/res/values/strings_non_localized.xml index 857ac98da8..717774e5f9 100644 --- a/app/src/main/res/values/strings_non_localized.xml +++ b/app/src/main/res/values/strings_non_localized.xml @@ -31,5 +31,6 @@ Generate error report Error reports User-trusted privileged app management + Remove Card Policy diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/FlagKeyTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/FlagKeyTest.kt index 08fc448841..2b1f896f5b 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/FlagKeyTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/FlagKeyTest.kt @@ -73,6 +73,10 @@ class FlagKeyTest { FlagKey.UserManagedPrivilegedApps.keyName, "pm-18970-user-managed-privileged-apps", ) + assertEquals( + FlagKey.RemoveCardPolicy.keyName, + "pm-16442-remove-card-item-type-policy", + ) } @Test @@ -95,6 +99,7 @@ class FlagKeyTest { FlagKey.RestrictCipherItemDeletion, FlagKey.PreAuthSettings, FlagKey.UserManagedPrivilegedApps, + FlagKey.RemoveCardPolicy, ).all { !it.defaultValue }, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt index af4137bf59..6f0c5f0489 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt @@ -160,6 +160,7 @@ private val DEFAULT_MAP_VALUE: ImmutableMap, Any> = persistentMapOf FlagKey.RestrictCipherItemDeletion to true, FlagKey.PreAuthSettings to true, FlagKey.UserManagedPrivilegedApps to true, + FlagKey.RemoveCardPolicy to true, ) private val UPDATED_MAP_VALUE: ImmutableMap, Any> = persistentMapOf( @@ -179,6 +180,7 @@ private val UPDATED_MAP_VALUE: ImmutableMap, Any> = persistentMapOf FlagKey.RestrictCipherItemDeletion to false, FlagKey.PreAuthSettings to false, FlagKey.UserManagedPrivilegedApps to false, + FlagKey.RemoveCardPolicy to false, ) private val DEFAULT_STATE = DebugMenuState( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index d9f8f34ef8..f66228ca37 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -2384,6 +2384,7 @@ private val DEFAULT_STATE = VaultItemListingState( hasMasterPassword = true, isPremium = false, isRefreshing = false, + restrictItemTypesPolicyOrgIds = persistentListOf(), ) private val STATE_FOR_AUTOFILL = DEFAULT_STATE.copy( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index 9bbe79244f..0bfe8dd8da 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -23,6 +23,7 @@ import com.bitwarden.data.repository.model.Environment import com.bitwarden.data.repository.util.baseIconUrl import com.bitwarden.data.repository.util.baseWebSendUrl import com.bitwarden.network.model.PolicyTypeJson +import com.bitwarden.network.model.SyncResponseJson import com.bitwarden.send.SendType import com.bitwarden.ui.platform.base.BaseViewModelTest import com.bitwarden.ui.platform.components.icon.model.IconData @@ -60,6 +61,7 @@ import com.x8bit.bitwarden.data.credentials.model.createMockFido2CredentialAsser import com.x8bit.bitwarden.data.credentials.model.createMockGetCredentialsRequest import com.x8bit.bitwarden.data.credentials.parser.RelyingPartyParser import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl @@ -67,6 +69,7 @@ import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingMa import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager @@ -197,9 +200,14 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { authRepository = mockAuthRepository, dispatcherManager = FakeDispatcherManager(), ) + private val mutableActivePoliciesFlow: MutableStateFlow> = + MutableStateFlow(emptyList()) private val policyManager: PolicyManager = mockk { every { getActivePolicies(type = PolicyTypeJson.DISABLE_SEND) } returns emptyList() every { getActivePoliciesFlow(type = PolicyTypeJson.DISABLE_SEND) } returns emptyFlow() + every { + getActivePoliciesFlow(type = PolicyTypeJson.RESTRICT_ITEM_TYPES) + } returns mutableActivePoliciesFlow } private val bitwardenCredentialManager: BitwardenCredentialManager = mockk { every { isUserVerified } returns false @@ -232,6 +240,13 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { coEvery { addTrustedPrivilegedApp(any(), any()) } just runs } + private val mutableRemoveCardPolicyFeatureFlow = MutableStateFlow(false) + private val featureFlagManager: FeatureFlagManager = mockk { + every { + getFeatureFlagFlow(FlagKey.RemoveCardPolicy) + } returns mutableRemoveCardPolicyFeatureFlow + } + private val initialState = createVaultItemListingState() private val initialSavedStateHandle get() = createSavedStateHandleWithVaultItemListingType( @@ -346,6 +361,64 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `RESTRICT_ITEM_TYPES policy changes should update restrictItemTypesPolicyOrgIds accordingly if RemoveCardPolicy flag is enable`() = + runTest { + mutableRemoveCardPolicyFeatureFlow.value = true + + val viewModel = createVaultItemListingViewModel() + assertEquals( + initialState.copy(restrictItemTypesPolicyOrgIds = persistentListOf()), + viewModel.stateFlow.value, + ) + mutableActivePoliciesFlow.emit( + listOf( + SyncResponseJson.Policy( + organizationId = "Test Organization", + id = "testId", + type = PolicyTypeJson.RESTRICT_ITEM_TYPES, + isEnabled = true, + data = null, + ), + ), + ) + + assertEquals( + initialState.copy( + restrictItemTypesPolicyOrgIds = persistentListOf("Test Organization"), + ), + viewModel.stateFlow.value, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `RESTRICT_ITEM_TYPES policy changes should update restrictItemTypesPolicyOrgIds accordingly if RemoveCardPolicy flag is disabled`() = + runTest { + val viewModel = createVaultItemListingViewModel() + assertEquals( + initialState, + viewModel.stateFlow.value, + ) + mutableActivePoliciesFlow.emit( + listOf( + SyncResponseJson.Policy( + organizationId = "Test Organization", + id = "testId", + type = PolicyTypeJson.RESTRICT_ITEM_TYPES, + isEnabled = true, + data = null, + ), + ), + ) + + assertEquals( + initialState.copy(restrictItemTypesPolicyOrgIds = persistentListOf()), + viewModel.stateFlow.value, + ) + } + @Test fun `on LockAccountClick should call lockVault for the given account`() { val accountUserId = "userId" @@ -1227,6 +1300,108 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } + @Test + fun `AddVaultItemClick inside a collection should show item selection dialog state`() { + val viewModel = createVaultItemListingViewModel( + savedStateHandle = createSavedStateHandleWithVaultItemListingType( + vaultItemListingType = VaultItemListingType.Collection(collectionId = "id"), + ), + ) + viewModel.trySendAction(VaultItemListingsAction.AddVaultItemClick) + assertEquals( + createVaultItemListingState( + itemListingType = VaultItemListingState.ItemListingType.Vault.Collection( + collectionId = "id", + ), + dialogState = VaultItemListingState.DialogState.VaultItemTypeSelection( + excludedOptions = persistentListOf( + CreateVaultItemType.SSH_KEY, + CreateVaultItemType.FOLDER, + ), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `AddVaultItemClick inside a folder should hide card item selection dialog state when RESTRICT_ITEM_TYPES policy is enabled`() = + runTest { + mutableRemoveCardPolicyFeatureFlow.value = true + val viewModel = createVaultItemListingViewModel( + savedStateHandle = createSavedStateHandleWithVaultItemListingType( + vaultItemListingType = VaultItemListingType.Folder(folderId = "id"), + ), + ) + mutableActivePoliciesFlow.emit( + listOf( + SyncResponseJson.Policy( + organizationId = "Test Organization", + id = "testId", + type = PolicyTypeJson.RESTRICT_ITEM_TYPES, + isEnabled = true, + data = null, + ), + ), + ) + viewModel.trySendAction(VaultItemListingsAction.AddVaultItemClick) + assertEquals( + createVaultItemListingState( + itemListingType = VaultItemListingState.ItemListingType.Vault.Folder( + folderId = "id", + ), + dialogState = VaultItemListingState.DialogState.VaultItemTypeSelection( + excludedOptions = persistentListOf( + CreateVaultItemType.CARD, + CreateVaultItemType.FOLDER, + CreateVaultItemType.SSH_KEY, + ), + ), + ).copy(restrictItemTypesPolicyOrgIds = persistentListOf("Test Organization")), + viewModel.stateFlow.value, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `AddVaultItemClick inside a collection should hide card item selection dialog state when RESTRICT_ITEM_TYPES policy is enabled`() = + runTest { + mutableRemoveCardPolicyFeatureFlow.value = true + val viewModel = createVaultItemListingViewModel( + savedStateHandle = createSavedStateHandleWithVaultItemListingType( + vaultItemListingType = VaultItemListingType.Collection(collectionId = "id"), + ), + ) + mutableActivePoliciesFlow.emit( + listOf( + SyncResponseJson.Policy( + organizationId = "Test Organization", + id = "testId", + type = PolicyTypeJson.RESTRICT_ITEM_TYPES, + isEnabled = true, + data = null, + ), + ), + ) + viewModel.trySendAction(VaultItemListingsAction.AddVaultItemClick) + assertEquals( + createVaultItemListingState( + itemListingType = VaultItemListingState.ItemListingType.Vault.Collection( + collectionId = "id", + ), + dialogState = VaultItemListingState.DialogState.VaultItemTypeSelection( + excludedOptions = persistentListOf( + CreateVaultItemType.CARD, + CreateVaultItemType.FOLDER, + CreateVaultItemType.SSH_KEY, + ), + ), + ).copy(restrictItemTypesPolicyOrgIds = persistentListOf("Test Organization")), + viewModel.stateFlow.value, + ) + } + @Test fun `AddVaultItemClick for vault item should emit NavigateToAddVaultItem`() = runTest { val viewModel = createVaultItemListingViewModel() @@ -5112,6 +5287,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { originManager = originManager, networkConnectionManager = networkConnectionManager, privilegedAppRepository = privilegedAppRepository, + featureFlagManager = featureFlagManager, snackbarRelayManager = snackbarRelayManager, relyingPartyParser = relyingPartyParser, ) @@ -5140,6 +5316,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { createCredentialRequest = null, isPremium = true, isRefreshing = false, + restrictItemTypesPolicyOrgIds = persistentListOf(), ) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt index 602ceebe9f..4037f046a8 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt @@ -470,6 +470,7 @@ class VaultItemListingDataExtensionsTest { fido2CredentialAutofillViews = null, totpData = null, isPremiumUser = true, + restrictItemTypesPolicyOrgIds = emptyList(), ) assertEquals( @@ -563,6 +564,7 @@ class VaultItemListingDataExtensionsTest { fido2CredentialAutofillViews = fido2CredentialAutofillViews, totpData = null, isPremiumUser = true, + restrictItemTypesPolicyOrgIds = emptyList(), ) assertEquals( @@ -649,6 +651,7 @@ class VaultItemListingDataExtensionsTest { fido2CredentialAutofillViews = fido2CredentialAutofillViews, totpData = null, isPremiumUser = true, + restrictItemTypesPolicyOrgIds = emptyList(), ) assertEquals( @@ -715,6 +718,7 @@ class VaultItemListingDataExtensionsTest { fido2CredentialAutofillViews = null, totpData = null, isPremiumUser = true, + restrictItemTypesPolicyOrgIds = emptyList(), ), ) @@ -738,6 +742,7 @@ class VaultItemListingDataExtensionsTest { fido2CredentialAutofillViews = null, totpData = null, isPremiumUser = true, + restrictItemTypesPolicyOrgIds = emptyList(), ), ) @@ -759,6 +764,7 @@ class VaultItemListingDataExtensionsTest { fido2CredentialAutofillViews = null, totpData = null, isPremiumUser = true, + restrictItemTypesPolicyOrgIds = emptyList(), ), ) @@ -781,6 +787,7 @@ class VaultItemListingDataExtensionsTest { fido2CredentialAutofillViews = null, totpData = null, isPremiumUser = true, + restrictItemTypesPolicyOrgIds = emptyList(), ), ) @@ -802,6 +809,7 @@ class VaultItemListingDataExtensionsTest { fido2CredentialAutofillViews = null, totpData = null, isPremiumUser = true, + restrictItemTypesPolicyOrgIds = emptyList(), ), ) @@ -823,6 +831,7 @@ class VaultItemListingDataExtensionsTest { fido2CredentialAutofillViews = null, totpData = null, isPremiumUser = true, + restrictItemTypesPolicyOrgIds = emptyList(), ), ) @@ -844,6 +853,7 @@ class VaultItemListingDataExtensionsTest { fido2CredentialAutofillViews = null, totpData = null, isPremiumUser = true, + restrictItemTypesPolicyOrgIds = emptyList(), ), ) @@ -869,6 +879,7 @@ class VaultItemListingDataExtensionsTest { fido2CredentialAutofillViews = null, totpData = null, isPremiumUser = true, + restrictItemTypesPolicyOrgIds = emptyList(), ), ) @@ -894,6 +905,7 @@ class VaultItemListingDataExtensionsTest { fido2CredentialAutofillViews = null, totpData = null, isPremiumUser = true, + restrictItemTypesPolicyOrgIds = emptyList(), ), ) @@ -920,6 +932,7 @@ class VaultItemListingDataExtensionsTest { every { issuer } returns "issuer" }, isPremiumUser = true, + restrictItemTypesPolicyOrgIds = emptyList(), ), ) } @@ -1162,6 +1175,7 @@ class VaultItemListingDataExtensionsTest { fido2CredentialAutofillViews = null, totpData = null, isPremiumUser = true, + restrictItemTypesPolicyOrgIds = emptyList(), ) assertEquals( @@ -1206,6 +1220,7 @@ class VaultItemListingDataExtensionsTest { fido2CredentialAutofillViews = null, totpData = null, isPremiumUser = true, + restrictItemTypesPolicyOrgIds = emptyList(), ) assertEquals( @@ -1228,4 +1243,117 @@ class VaultItemListingDataExtensionsTest { actual, ) } + + @Suppress("MaxLineLength") + @Test + fun `toViewState should properly filter cards when cipher have organizationId in restrictItemTypesPolicyOrgIds`() { + mockkStatic(CipherView::subtitle) + mockkStatic(Uri::class) + val uriMock = mockk() + every { any().subtitle } returns null + every { Uri.parse(any()) } returns uriMock + every { uriMock.host } returns "www.mockuri.com" + + val vaultData = VaultData( + cipherViewList = listOf( + createMockCipherView( + number = 1, + organizationId = "restrict_item_type_policy_id", + cipherType = CipherType.LOGIN, + ), + createMockCipherView( + number = 2, + organizationId = "restrict_item_type_policy_id", + cipherType = CipherType.CARD, + ), + createMockCipherView( + number = 3, + organizationId = null, + cipherType = CipherType.CARD, + ), + createMockCipherView( + number = 4, + organizationId = "another_id", + cipherType = CipherType.CARD, + ), + ), + + collectionViewList = listOf(), + folderViewList = listOf(), + sendViewList = listOf(), + fido2CredentialAutofillViewList = listOf(), + ) + + val actual = vaultData.toViewState( + itemListingType = VaultItemListingState.ItemListingType.Vault.Card, + vaultFilterType = VaultFilterType.AllVaults, + hasMasterPassword = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + autofillSelectionData = null, + createCredentialRequestData = null, + fido2CredentialAutofillViews = null, + totpData = null, + isPremiumUser = true, + restrictItemTypesPolicyOrgIds = listOf("restrict_item_type_policy_id"), + ) + + assertEquals( + VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), + displayItemList = listOf( + createMockDisplayItemForCipher( + number = 4, + cipherType = CipherType.CARD, + subtitle = null, + ).copy(secondSubtitleTestTag = "PasskeySite"), + ), + displayFolderList = emptyList(), + ), + actual, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toViewState should set properly shouldShowAddButton false when restrictItemTypesPolicyOrgIds has values, vault type is card and there is an empty vault state`() { + mockkStatic(CipherView::subtitle) + mockkStatic(Uri::class) + val uriMock = mockk() + every { any().subtitle } returns null + every { Uri.parse(any()) } returns uriMock + every { uriMock.host } returns "www.mockuri.com" + + val vaultData = VaultData( + cipherViewList = listOf(), + collectionViewList = listOf(), + folderViewList = listOf(), + sendViewList = listOf(), + fido2CredentialAutofillViewList = listOf(), + ) + + val actual = vaultData.toViewState( + itemListingType = VaultItemListingState.ItemListingType.Vault.Card, + vaultFilterType = VaultFilterType.AllVaults, + hasMasterPassword = true, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + isIconLoadingDisabled = false, + autofillSelectionData = null, + createCredentialRequestData = null, + fido2CredentialAutofillViews = null, + totpData = null, + isPremiumUser = true, + restrictItemTypesPolicyOrgIds = listOf("restrict_item_type_policy_id"), + ) + + // Card type + assertEquals( + VaultItemListingState.ViewState.NoItems( + message = R.string.no_cards.asText(), + shouldShowAddButton = false, + buttonText = R.string.new_card.asText(), + ), + actual, + ) + } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index 9370879136..859e5d48c3 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -1551,6 +1551,36 @@ class VaultScreenTest : BitwardenComposeTest() { } } + @Test + fun `card section should be visible based on state`() { + mutableStateFlow.update { state -> + state.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + cardItemsCount = 1, + showCardGroup = true, + ), + ) + } + + composeTestRule + .onNodeWithText("Card") + .performScrollTo() + .assertIsDisplayed() + + mutableStateFlow.update { state -> + state.copy( + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + cardItemsCount = 0, + showCardGroup = false, + ), + ) + } + + composeTestRule + .onNodeWithText("Card") + .assertIsNotDisplayed() + } + @Test fun `card item count should update according to state`() { val rowText = "Card" @@ -1857,7 +1887,11 @@ class VaultScreenTest : BitwardenComposeTest() { @Test fun `SelectVaultAddItemType dialog state show vault item type selection dialog`() { mutableStateFlow.update { - it.copy(dialog = VaultState.DialogState.SelectVaultAddItemType) + it.copy( + dialog = VaultState.DialogState.SelectVaultAddItemType( + persistentListOf(CreateVaultItemType.SSH_KEY), + ), + ) } composeTestRule @@ -1870,10 +1904,52 @@ class VaultScreenTest : BitwardenComposeTest() { .assertIsDisplayed() } + @Test + fun `SelectVaultAddItemType dialog state hide vault item type selection if excluded`() { + mutableStateFlow.update { + it.copy( + dialog = VaultState.DialogState.SelectVaultAddItemType( + persistentListOf( + CreateVaultItemType.SSH_KEY, + CreateVaultItemType.CARD, + ), + ), + ) + } + + composeTestRule + .onNode(isDialog()) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithText("Type") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithText("Card") + .filterToOne(hasAnyAncestor(isDialog())) + .assertDoesNotExist() + + composeTestRule + .onAllNodesWithText("SSH key") + .filterToOne(hasAnyAncestor(isDialog())) + .assertDoesNotExist() + + composeTestRule + .onAllNodesWithText("Card") + .filterToOne(hasAnyAncestor(isDialog())) + .assertDoesNotExist() + } + @Test fun `when option is selected in SelectVaultAddItemType dialog add item action is sent`() { mutableStateFlow.update { - it.copy(dialog = VaultState.DialogState.SelectVaultAddItemType) + it.copy( + dialog = VaultState.DialogState.SelectVaultAddItemType( + persistentListOf(CreateVaultItemType.SSH_KEY), + ), + ) } composeTestRule @@ -1998,6 +2074,7 @@ private val DEFAULT_STATE: VaultState = VaultState( isRefreshing = false, showImportActionCard = false, flightRecorderSnackBar = null, + restrictItemTypesPolicyOrgIds = null, ) private val DEFAULT_CONTENT_VIEW_STATE: VaultState.ViewState.Content = VaultState.ViewState.Content( @@ -2013,4 +2090,5 @@ private val DEFAULT_CONTENT_VIEW_STATE: VaultState.ViewState.Content = VaultStat totpItemsCount = 0, itemTypesCount = 4, sshKeyItemsCount = 0, + showCardGroup = true, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 017ec5a689..29ada594cb 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -93,10 +93,16 @@ class VaultViewModelTest : BaseViewModelTest() { private val clipboardManager: BitwardenClipboardManager = mockk { every { setText(text = any(), toastDescriptorOverride = any()) } just runs } + + private val mutableActivePoliciesFlow: MutableStateFlow> = + MutableStateFlow(emptyList()) private val policyManager: PolicyManager = mockk { every { getActivePolicies(type = PolicyTypeJson.PERSONAL_OWNERSHIP) } returns emptyList() + every { + getActivePoliciesFlow(type = PolicyTypeJson.RESTRICT_ITEM_TYPES) + } returns mutableActivePoliciesFlow } private val mutablePullToRefreshEnabledFlow = MutableStateFlow(false) @@ -152,11 +158,15 @@ class VaultViewModelTest : BaseViewModelTest() { } private val mutableImportLoginsFeatureFlow = MutableStateFlow(true) + private val mutableRemoveCardPolicyFeatureFlow = MutableStateFlow(false) private val mutableSshKeyVaultItemsEnabledFlow = MutableStateFlow(false) private val featureFlagManager: FeatureFlagManager = mockk { every { getFeatureFlagFlow(FlagKey.ImportLoginsFlow) } returns mutableImportLoginsFeatureFlow + every { + getFeatureFlagFlow(FlagKey.RemoveCardPolicy) + } returns mutableRemoveCardPolicyFeatureFlow } private val reviewPromptManager: ReviewPromptManager = mockk() @@ -392,6 +402,62 @@ class VaultViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `RESTRICT_ITEM_TYPES policy changes should update restrictItemTypesPolicyOrgIds accordingly if RemoveCardPolicy flag is enable`() = + runTest { + mutableRemoveCardPolicyFeatureFlow.value = true + + val viewModel = createViewModel() + assertEquals( + DEFAULT_STATE, + viewModel.stateFlow.value, + ) + mutableActivePoliciesFlow.emit( + listOf( + SyncResponseJson.Policy( + organizationId = "Test Organization", + id = "testId", + type = PolicyTypeJson.RESTRICT_ITEM_TYPES, + isEnabled = true, + data = null, + ), + ), + ) + + assertEquals( + DEFAULT_STATE.copy(restrictItemTypesPolicyOrgIds = listOf("Test Organization")), + viewModel.stateFlow.value, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `RESTRICT_ITEM_TYPES policy changes should update restrictItemTypesPolicyOrgIds accordingly if RemoveCardPolicy flag is disabled`() = + runTest { + val viewModel = createViewModel() + assertEquals( + DEFAULT_STATE, + viewModel.stateFlow.value, + ) + mutableActivePoliciesFlow.emit( + listOf( + SyncResponseJson.Policy( + organizationId = "Test Organization", + id = "testId", + type = PolicyTypeJson.RESTRICT_ITEM_TYPES, + isEnabled = true, + data = null, + ), + ), + ) + + assertEquals( + DEFAULT_STATE.copy(restrictItemTypesPolicyOrgIds = null), + viewModel.stateFlow.value, + ) + } + @Test fun `Flight Recorder changes should update flightRecorderSnackbar accordingly`() = runTest { mockkStatic(FlightRecorderDataSet::toSnackbarData) @@ -648,6 +714,7 @@ class VaultViewModelTest : BaseViewModelTest() { isIconLoadingDisabled = viewModel.stateFlow.value.isIconLoadingDisabled, baseIconUrl = viewModel.stateFlow.value.baseIconUrl, hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = null, ), ) .copy( @@ -672,6 +739,7 @@ class VaultViewModelTest : BaseViewModelTest() { isIconLoadingDisabled = viewModel.stateFlow.value.isIconLoadingDisabled, baseIconUrl = viewModel.stateFlow.value.baseIconUrl, hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = null, ), ), viewModel.stateFlow.value, @@ -785,6 +853,7 @@ class VaultViewModelTest : BaseViewModelTest() { totpItemsCount = 1, itemTypesCount = CipherType.entries.size, sshKeyItemsCount = 1, + showCardGroup = true, ), ), viewModel.stateFlow.value, @@ -809,6 +878,7 @@ class VaultViewModelTest : BaseViewModelTest() { totpItemsCount = 1, itemTypesCount = 5, sshKeyItemsCount = 0, + showCardGroup = true, ), ) val viewModel = createViewModel() @@ -928,6 +998,7 @@ class VaultViewModelTest : BaseViewModelTest() { totpItemsCount = 1, itemTypesCount = 5, sshKeyItemsCount = 0, + showCardGroup = true, ), ), viewModel.stateFlow.value, @@ -1033,6 +1104,7 @@ class VaultViewModelTest : BaseViewModelTest() { totpItemsCount = 1, itemTypesCount = 5, sshKeyItemsCount = 0, + showCardGroup = true, ), dialog = VaultState.DialogState.Error( title = R.string.an_error_has_occurred.asText(), @@ -1136,6 +1208,7 @@ class VaultViewModelTest : BaseViewModelTest() { totpItemsCount = 1, itemTypesCount = 5, sshKeyItemsCount = 0, + showCardGroup = true, ), dialog = null, ), @@ -1213,6 +1286,7 @@ class VaultViewModelTest : BaseViewModelTest() { totpItemsCount = 1, itemTypesCount = CipherType.entries.size, sshKeyItemsCount = 1, + showCardGroup = true, ), ), viewModel.stateFlow.value, @@ -2143,7 +2217,9 @@ class VaultViewModelTest : BaseViewModelTest() { val viewModel = createViewModel() viewModel.trySendAction(VaultAction.SelectAddItemType) val expectedState = DEFAULT_STATE.copy( - dialog = VaultState.DialogState.SelectVaultAddItemType, + dialog = VaultState.DialogState.SelectVaultAddItemType( + excludedOptions = persistentListOf(CreateVaultItemType.SSH_KEY), + ), ) assertEquals( expectedState, @@ -2151,6 +2227,40 @@ class VaultViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `SelectAddItemType action should set dialog state to SelectVaultAddItemType accordingly when RESTRICT_ITEM_TYPES is enabled`() = + runTest { + mutableRemoveCardPolicyFeatureFlow.value = true + val viewModel = createViewModel() + mutableActivePoliciesFlow.emit( + listOf( + SyncResponseJson.Policy( + organizationId = "Test Organization", + id = "testId", + type = PolicyTypeJson.RESTRICT_ITEM_TYPES, + isEnabled = true, + data = null, + ), + ), + ) + + viewModel.trySendAction(VaultAction.SelectAddItemType) + val expectedState = DEFAULT_STATE.copy( + dialog = VaultState.DialogState.SelectVaultAddItemType( + excludedOptions = persistentListOf( + CreateVaultItemType.SSH_KEY, + CreateVaultItemType.CARD, + ), + ), + restrictItemTypesPolicyOrgIds = listOf("Test Organization"), + ) + assertEquals( + expectedState, + viewModel.stateFlow.value, + ) + } + @Test fun `InternetConnectionErrorReceived should show network error if no internet connection`() = runTest { @@ -2292,4 +2402,5 @@ private fun createMockVaultState( showImportActionCard = true, isRefreshing = false, flightRecorderSnackBar = null, + restrictItemTypesPolicyOrgIds = null, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt index da7571d433..b197b78b61 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt @@ -64,6 +64,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = null, ) assertEquals( @@ -107,6 +108,7 @@ class VaultDataExtensionsTest { totpItemsCount = 1, itemTypesCount = 5, sshKeyItemsCount = 0, + showCardGroup = true, ), actual, ) @@ -131,6 +133,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.MyVault, hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = null, ) assertEquals( @@ -153,6 +156,7 @@ class VaultDataExtensionsTest { totpItemsCount = 1, itemTypesCount = 5, sshKeyItemsCount = 0, + showCardGroup = true, ), actual, ) @@ -186,6 +190,7 @@ class VaultDataExtensionsTest { organizationName = "Mock Organization 1", ), hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = null, ) assertEquals( @@ -219,6 +224,7 @@ class VaultDataExtensionsTest { totpItemsCount = 1, itemTypesCount = 5, sshKeyItemsCount = 0, + showCardGroup = true, ), actual, ) @@ -239,6 +245,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = null, ) assertEquals( @@ -262,6 +269,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = null, ) assertEquals( @@ -286,6 +294,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = null, ) assertEquals( @@ -302,6 +311,7 @@ class VaultDataExtensionsTest { totpItemsCount = 1, itemTypesCount = 5, sshKeyItemsCount = 0, + showCardGroup = true, ), actual, ) @@ -323,6 +333,7 @@ class VaultDataExtensionsTest { isIconLoadingDisabled = false, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = null, ) assertEquals( @@ -339,6 +350,7 @@ class VaultDataExtensionsTest { totpItemsCount = 0, itemTypesCount = 5, sshKeyItemsCount = 0, + showCardGroup = true, ), actual, ) @@ -360,6 +372,7 @@ class VaultDataExtensionsTest { isIconLoadingDisabled = false, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = null, ) assertEquals( @@ -376,6 +389,7 @@ class VaultDataExtensionsTest { totpItemsCount = 1, itemTypesCount = 5, sshKeyItemsCount = 0, + showCardGroup = true, ), actual, ) @@ -399,6 +413,7 @@ class VaultDataExtensionsTest { isIconLoadingDisabled = false, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = null, ) assertEquals( @@ -415,6 +430,7 @@ class VaultDataExtensionsTest { totpItemsCount = 1, itemTypesCount = 5, sshKeyItemsCount = 0, + showCardGroup = true, ), actual, ) @@ -622,6 +638,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = null, ) assertEquals( @@ -638,6 +655,7 @@ class VaultDataExtensionsTest { totpItemsCount = 1, itemTypesCount = 5, sshKeyItemsCount = 0, + showCardGroup = true, ), actual, ) @@ -661,6 +679,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = null, ) assertEquals( @@ -677,6 +696,7 @@ class VaultDataExtensionsTest { totpItemsCount = 0, itemTypesCount = 5, sshKeyItemsCount = 0, + showCardGroup = true, ), actual, ) @@ -703,6 +723,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = null, ) assertEquals( @@ -725,6 +746,7 @@ class VaultDataExtensionsTest { totpItemsCount = 100, itemTypesCount = 5, sshKeyItemsCount = 0, + showCardGroup = true, ), actual, ) @@ -752,6 +774,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = null, ) assertEquals( @@ -785,6 +808,7 @@ class VaultDataExtensionsTest { totpItemsCount = 1, itemTypesCount = 5, sshKeyItemsCount = 0, + showCardGroup = true, ), actual, ) @@ -818,6 +842,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = null, ) assertEquals( @@ -866,6 +891,123 @@ class VaultDataExtensionsTest { totpItemsCount = 1, itemTypesCount = 5, sshKeyItemsCount = 0, + showCardGroup = true, + ), + actual, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toViewState should excluded card vault items and adjust type count for ciphers with orgId in restrictItemTypesPolicyOrgIds and set showCardGroup to true if there are remaining cards`() { + val vaultData = VaultData( + cipherViewList = listOf( + createMockCipherView( + number = 1, + cipherType = CipherType.CARD, + ), + createMockCipherView( + number = 2, + organizationId = "restrict_item_type_policy_id", + cipherType = CipherType.CARD, + ), + createMockCipherView( + number = 3, + organizationId = "another_id", + cipherType = CipherType.CARD, + ), + createMockCipherView( + number = 4, + organizationId = null, + cipherType = CipherType.CARD, + ), + ), + collectionViewList = listOf(), + folderViewList = listOf(), + sendViewList = listOf(), + fido2CredentialAutofillViewList = null, + ) + + val actual = vaultData.toViewState( + isPremium = true, + isIconLoadingDisabled = false, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + vaultFilterType = VaultFilterType.AllVaults, + hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = listOf("restrict_item_type_policy_id"), + ) + + assertEquals( + VaultState.ViewState.Content( + loginItemsCount = 0, + cardItemsCount = 2, + identityItemsCount = 0, + secureNoteItemsCount = 0, + sshKeyItemsCount = 0, + favoriteItems = listOf(), + collectionItems = listOf(), + folderItems = listOf(), + noFolderItems = listOf(), + trashItemsCount = 0, + totpItemsCount = 0, + itemTypesCount = CipherType.entries.size, + showCardGroup = true, + ), + actual, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toViewState should excluded card vault items for ciphers with orgId in restrictItemTypesPolicyOrgIds and set showCardGroup to false if there are no remaining cards`() { + val vaultData = VaultData( + cipherViewList = listOf( + createMockCipherView( + number = 1, + organizationId = "restrict_item_type_policy_id", + cipherType = CipherType.LOGIN, + ), + createMockCipherView( + number = 2, + organizationId = "restrict_item_type_policy_id", + cipherType = CipherType.CARD, + ), + createMockCipherView( + number = 3, + organizationId = "restrict_item_type_policy_id", + cipherType = CipherType.CARD, + ), + ), + collectionViewList = listOf(), + folderViewList = listOf(), + sendViewList = listOf(), + fido2CredentialAutofillViewList = null, + ) + + val actual = vaultData.toViewState( + isPremium = true, + isIconLoadingDisabled = false, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + vaultFilterType = VaultFilterType.AllVaults, + hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = listOf("restrict_item_type_policy_id"), + ) + + assertEquals( + VaultState.ViewState.Content( + loginItemsCount = 1, + cardItemsCount = 0, + identityItemsCount = 0, + secureNoteItemsCount = 0, + sshKeyItemsCount = 0, + favoriteItems = listOf(), + collectionItems = listOf(), + folderItems = listOf(), + noFolderItems = listOf(), + trashItemsCount = 0, + totpItemsCount = 1, + itemTypesCount = CipherType.entries.size, + showCardGroup = false, ), actual, ) @@ -890,6 +1032,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = null, ) assertEquals( @@ -908,6 +1051,7 @@ class VaultDataExtensionsTest { totpItemsCount = 1, // Verify item types count includes all CipherTypes when showSshKeys is true. itemTypesCount = CipherType.entries.size, + showCardGroup = true, ), actual, ) @@ -947,6 +1091,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, + restrictItemTypesPolicyOrgIds = null, ) assertEquals( @@ -978,6 +1123,7 @@ class VaultDataExtensionsTest { trashItemsCount = 0, totpItemsCount = 0, itemTypesCount = CipherType.entries.size, + showCardGroup = true, ), actual, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultStateExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultStateExtensionsTest.kt index 992ff80b77..6f6f0f4874 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultStateExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultStateExtensionsTest.kt @@ -85,5 +85,6 @@ class VaultStateExtensionsTest { totpItemsCount = 1, itemTypesCount = 4, sshKeyItemsCount = 0, + showCardGroup = true, ) } diff --git a/network/src/main/kotlin/com/bitwarden/network/model/PolicyTypeJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/PolicyTypeJson.kt index 84c87982d5..acd823ed12 100644 --- a/network/src/main/kotlin/com/bitwarden/network/model/PolicyTypeJson.kt +++ b/network/src/main/kotlin/com/bitwarden/network/model/PolicyTypeJson.kt @@ -88,6 +88,12 @@ enum class PolicyTypeJson { @SerialName("14") REMOVE_UNLOCK_WITH_PIN, + /** + * Restricts the types of items that can be shown in the vault. + */ + @SerialName("15") + RESTRICT_ITEM_TYPES, + /** * Represents an unknown policy type. *