From 96401aba792660f8be4d95b2418f7f578c6729a8 Mon Sep 17 00:00:00 2001 From: Shannon Draeker <125921730+shannon-livefront@users.noreply.github.com> Date: Tue, 30 Jan 2024 14:26:36 -0700 Subject: [PATCH] BIT-786: Export vault policy (#875) --- .../data/auth/repository/AuthRepository.kt | 5 +++ .../auth/repository/AuthRepositoryImpl.kt | 8 +++++ .../components/BitwardenFilledTonalButton.kt | 3 ++ .../components/BitwardenMultiSelectButton.kt | 4 +++ .../settings/exportvault/ExportVaultScreen.kt | 26 +++++++++++++++ .../exportvault/ExportVaultViewModel.kt | 2 ++ .../auth/repository/AuthRepositoryTest.kt | 32 +++++++++++++++++++ .../exportvault/ExportVaultScreenTest.kt | 14 ++++++++ .../exportvault/ExportVaultViewModelTest.kt | 15 +++++++-- 9 files changed, 107 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index 0ba34a0a1..7cf57d85a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -84,6 +84,11 @@ interface AuthRepository : AuthenticatorProvider { */ val passwordPolicies: List + /** + * Return whether there are any export vault policies enabled for the current user. + */ + val hasExportVaultPoliciesEnabled: Boolean + /** * Clears the pending deletion state that occurs when the an account is successfully deleted. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 85e5aa49e..32ceec9d0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -229,6 +229,14 @@ class AuthRepositoryImpl( .orEmpty() } ?: emptyList() + override val hasExportVaultPoliciesEnabled: Boolean + get() = activeUserId?.let { userId -> + authDiskSource + .getPolicies(userId) + ?.any { it.type == PolicyTypeJson.DISABLE_PERSONAL_VAULT_EXPORT && it.isEnabled } + ?: false + } ?: false + init { pushManager .syncOrgKeysFlow diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenFilledTonalButton.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenFilledTonalButton.kt index 62f3e2b96..fe36632d9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenFilledTonalButton.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenFilledTonalButton.kt @@ -20,6 +20,7 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme * * @param label The text to be displayed on the button. * @param onClick A lambda which will be invoked when the button is clicked. + * @param isEnabled Whether or not the button is enabled. * @param modifier A [Modifier] for this composable, allowing for adjustments to its appearance * or behavior. This can be used to apply padding, layout, and other Modifiers. */ @@ -27,6 +28,7 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme fun BitwardenFilledTonalButton( label: String, onClick: () -> Unit, + isEnabled: Boolean = true, modifier: Modifier, ) { Button( @@ -35,6 +37,7 @@ fun BitwardenFilledTonalButton( vertical = 10.dp, horizontal = 24.dp, ), + enabled = isEnabled, colors = ButtonDefaults.filledTonalButtonColors(), modifier = modifier, ) { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenMultiSelectButton.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenMultiSelectButton.kt index a3e4ecba2..71f9a01e3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenMultiSelectButton.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenMultiSelectButton.kt @@ -49,6 +49,7 @@ import kotlinx.collections.immutable.persistentListOf * (or `null` if no option is selected). * @param onOptionSelected A lambda that is invoked when an option * is selected from the dropdown menu. + * @param isEnabled Whether or not the button is enabled. * @param modifier A [Modifier] that you can use to apply custom modifications to the composable. * @param supportingText A optional supporting text that will appear below the text field. * @param tooltip A nullable [TooltipData], representing the tooltip icon. @@ -60,6 +61,7 @@ fun BitwardenMultiSelectButton( options: ImmutableList, selectedOption: String?, onOptionSelected: (String) -> Unit, + isEnabled: Boolean = true, modifier: Modifier = Modifier, supportingText: String? = null, tooltip: TooltipData? = null, @@ -80,6 +82,7 @@ fun BitwardenMultiSelectButton( .fillMaxWidth() .clickable( indication = null, + enabled = isEnabled, interactionSource = remember { MutableInteractionSource() }, ) { shouldShowDialog = !shouldShowDialog @@ -93,6 +96,7 @@ fun BitwardenMultiSelectButton( Spacer(modifier = Modifier.width(3.dp)) IconButton( onClick = it.onClick, + enabled = isEnabled, colors = IconButtonDefaults.iconButtonColors( contentColor = MaterialTheme.colorScheme.primary, ), diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt index dfdc290f4..53510f856 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault import android.widget.Toast +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -154,6 +156,7 @@ fun ExportVaultScreen( } @Composable +@Suppress("LongMethod") private fun ExportVaultScreenContent( state: ExportVaultState, onExportFormatOptionSelected: (ExportVaultFormat) -> Unit, @@ -168,6 +171,26 @@ private fun ExportVaultScreenContent( .verticalScroll(rememberScrollState()), ) { + if (state.policyPreventsExport) { + Text( + text = stringResource(id = R.string.disable_personal_vault_export_policy_in_effect), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(4.dp), + ) + .padding(8.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + } + BitwardenMultiSelectButton( label = stringResource(id = R.string.file_format), options = ExportVaultFormat.entries.map { it.displayLabel }.toImmutableList(), @@ -178,6 +201,7 @@ private fun ExportVaultScreenContent( .first { it.displayLabel == selectedOptionLabel } onExportFormatOptionSelected(selectedOption) }, + isEnabled = !state.policyPreventsExport, modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth(), @@ -188,6 +212,7 @@ private fun ExportVaultScreenContent( BitwardenPasswordField( label = stringResource(id = R.string.master_password), value = state.passwordInput, + readOnly = state.policyPreventsExport, onValueChange = onPasswordInputChanged, modifier = Modifier .padding(horizontal = 16.dp) @@ -211,6 +236,7 @@ private fun ExportVaultScreenContent( BitwardenFilledTonalButton( label = stringResource(id = R.string.export_vault), onClick = onExportVaultClick, + isEnabled = !state.policyPreventsExport, modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth(), diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt index 366d6a25a..455b503a8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt @@ -33,6 +33,7 @@ class ExportVaultViewModel @Inject constructor( dialogState = null, exportFormat = ExportVaultFormat.JSON, passwordInput = "", + policyPreventsExport = authRepository.hasExportVaultPoliciesEnabled, ), ) { init { @@ -165,6 +166,7 @@ data class ExportVaultState( val dialogState: DialogState?, val exportFormat: ExportVaultFormat, val passwordInput: String, + val policyPreventsExport: Boolean, ) : Parcelable { /** * Represents the current state of any dialogs on the screen. diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 00639ae7c..af953985b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -499,6 +499,38 @@ class AuthRepositoryTest { assertNull(repository.rememberedOrgIdentifier) } + @Test + fun `hasExportVaultPoliciesEnabled checks if any export vault policies are enabled`() { + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + + // No stored policies returns false. + assertFalse(repository.hasExportVaultPoliciesEnabled) + + // Stored but disabled policies returns false. + fakeAuthDiskSource.storePolicies( + userId = USER_ID_1, + policies = listOf( + createMockPolicy( + type = PolicyTypeJson.DISABLE_PERSONAL_VAULT_EXPORT, + isEnabled = false, + ), + ), + ) + assertFalse(repository.hasExportVaultPoliciesEnabled) + + // Stored enabled policies returns true. + fakeAuthDiskSource.storePolicies( + userId = USER_ID_1, + policies = listOf( + createMockPolicy( + type = PolicyTypeJson.DISABLE_PERSONAL_VAULT_EXPORT, + isEnabled = true, + ), + ), + ) + assertTrue(repository.hasExportVaultPoliciesEnabled) + } + @Test fun `clear Pending Account Deletion should unblock userState updates`() = runTest { val masterPassword = "hello world" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt index c50da5e6d..bfd4ae39f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt @@ -116,6 +116,19 @@ class ExportVaultScreenTest : BaseComposeTest() { } } + @Test + fun `policy text should update according to state`() { + val text = + "One or more organization policies prevents your from exporting your individual vault." + composeTestRule.onNodeWithText(text).assertDoesNotExist() + + mutableStateFlow.update { + it.copy(policyPreventsExport = true) + } + + composeTestRule.onNodeWithText(text).assertIsDisplayed() + } + @Test fun `file format selection button should send ExportFormatOptionSelect action`() { // Open the menu. @@ -186,4 +199,5 @@ private val DEFAULT_STATE = ExportVaultState( dialogState = null, exportFormat = ExportVaultFormat.JSON, passwordInput = "", + policyPreventsExport = false, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt index 40383b783..07d76a390 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt @@ -9,21 +9,31 @@ import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat import io.mockk.coEvery +import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class ExportVaultViewModelTest : BaseViewModelTest() { - private val authRepository: AuthRepository = mockk() + private val authRepository: AuthRepository = mockk { + every { hasExportVaultPoliciesEnabled } returns false + } private val savedStateHandle = SavedStateHandle() @Test fun `initial state should be correct`() = runTest { + every { authRepository.hasExportVaultPoliciesEnabled } returns true + val viewModel = createViewModel() viewModel.stateFlow.test { - assertEquals(DEFAULT_STATE, awaitItem()) + assertEquals( + DEFAULT_STATE.copy( + policyPreventsExport = true, + ), + awaitItem(), + ) } } @@ -183,4 +193,5 @@ private val DEFAULT_STATE = ExportVaultState( dialogState = null, exportFormat = ExportVaultFormat.JSON, passwordInput = "", + policyPreventsExport = false, )