BIT-786: Export vault policy (#875)

This commit is contained in:
Shannon Draeker 2024-01-30 14:26:36 -07:00 committed by Álison Fernandes
parent 7b6f9491b3
commit 96401aba79
9 changed files with 107 additions and 2 deletions

View file

@ -84,6 +84,11 @@ interface AuthRepository : AuthenticatorProvider {
*/ */
val passwordPolicies: List<PolicyInformation.MasterPassword> val passwordPolicies: List<PolicyInformation.MasterPassword>
/**
* 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. * Clears the pending deletion state that occurs when the an account is successfully deleted.
*/ */

View file

@ -229,6 +229,14 @@ class AuthRepositoryImpl(
.orEmpty() .orEmpty()
} ?: emptyList() } ?: 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 { init {
pushManager pushManager
.syncOrgKeysFlow .syncOrgKeysFlow

View file

@ -20,6 +20,7 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
* *
* @param label The text to be displayed on the button. * @param label The text to be displayed on the button.
* @param onClick A lambda which will be invoked when the button is clicked. * @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 * @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. * 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( fun BitwardenFilledTonalButton(
label: String, label: String,
onClick: () -> Unit, onClick: () -> Unit,
isEnabled: Boolean = true,
modifier: Modifier, modifier: Modifier,
) { ) {
Button( Button(
@ -35,6 +37,7 @@ fun BitwardenFilledTonalButton(
vertical = 10.dp, vertical = 10.dp,
horizontal = 24.dp, horizontal = 24.dp,
), ),
enabled = isEnabled,
colors = ButtonDefaults.filledTonalButtonColors(), colors = ButtonDefaults.filledTonalButtonColors(),
modifier = modifier, modifier = modifier,
) { ) {

View file

@ -49,6 +49,7 @@ import kotlinx.collections.immutable.persistentListOf
* (or `null` if no option is selected). * (or `null` if no option is selected).
* @param onOptionSelected A lambda that is invoked when an option * @param onOptionSelected A lambda that is invoked when an option
* is selected from the dropdown menu. * 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 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 supportingText A optional supporting text that will appear below the text field.
* @param tooltip A nullable [TooltipData], representing the tooltip icon. * @param tooltip A nullable [TooltipData], representing the tooltip icon.
@ -60,6 +61,7 @@ fun BitwardenMultiSelectButton(
options: ImmutableList<String>, options: ImmutableList<String>,
selectedOption: String?, selectedOption: String?,
onOptionSelected: (String) -> Unit, onOptionSelected: (String) -> Unit,
isEnabled: Boolean = true,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
supportingText: String? = null, supportingText: String? = null,
tooltip: TooltipData? = null, tooltip: TooltipData? = null,
@ -80,6 +82,7 @@ fun BitwardenMultiSelectButton(
.fillMaxWidth() .fillMaxWidth()
.clickable( .clickable(
indication = null, indication = null,
enabled = isEnabled,
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
) { ) {
shouldShowDialog = !shouldShowDialog shouldShowDialog = !shouldShowDialog
@ -93,6 +96,7 @@ fun BitwardenMultiSelectButton(
Spacer(modifier = Modifier.width(3.dp)) Spacer(modifier = Modifier.width(3.dp))
IconButton( IconButton(
onClick = it.onClick, onClick = it.onClick,
enabled = isEnabled,
colors = IconButtonDefaults.iconButtonColors( colors = IconButtonDefaults.iconButtonColors(
contentColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.primary,
), ),

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize 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.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -154,6 +156,7 @@ fun ExportVaultScreen(
} }
@Composable @Composable
@Suppress("LongMethod")
private fun ExportVaultScreenContent( private fun ExportVaultScreenContent(
state: ExportVaultState, state: ExportVaultState,
onExportFormatOptionSelected: (ExportVaultFormat) -> Unit, onExportFormatOptionSelected: (ExportVaultFormat) -> Unit,
@ -168,6 +171,26 @@ private fun ExportVaultScreenContent(
.verticalScroll(rememberScrollState()), .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( BitwardenMultiSelectButton(
label = stringResource(id = R.string.file_format), label = stringResource(id = R.string.file_format),
options = ExportVaultFormat.entries.map { it.displayLabel }.toImmutableList(), options = ExportVaultFormat.entries.map { it.displayLabel }.toImmutableList(),
@ -178,6 +201,7 @@ private fun ExportVaultScreenContent(
.first { it.displayLabel == selectedOptionLabel } .first { it.displayLabel == selectedOptionLabel }
onExportFormatOptionSelected(selectedOption) onExportFormatOptionSelected(selectedOption)
}, },
isEnabled = !state.policyPreventsExport,
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.fillMaxWidth(), .fillMaxWidth(),
@ -188,6 +212,7 @@ private fun ExportVaultScreenContent(
BitwardenPasswordField( BitwardenPasswordField(
label = stringResource(id = R.string.master_password), label = stringResource(id = R.string.master_password),
value = state.passwordInput, value = state.passwordInput,
readOnly = state.policyPreventsExport,
onValueChange = onPasswordInputChanged, onValueChange = onPasswordInputChanged,
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
@ -211,6 +236,7 @@ private fun ExportVaultScreenContent(
BitwardenFilledTonalButton( BitwardenFilledTonalButton(
label = stringResource(id = R.string.export_vault), label = stringResource(id = R.string.export_vault),
onClick = onExportVaultClick, onClick = onExportVaultClick,
isEnabled = !state.policyPreventsExport,
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.fillMaxWidth(), .fillMaxWidth(),

View file

@ -33,6 +33,7 @@ class ExportVaultViewModel @Inject constructor(
dialogState = null, dialogState = null,
exportFormat = ExportVaultFormat.JSON, exportFormat = ExportVaultFormat.JSON,
passwordInput = "", passwordInput = "",
policyPreventsExport = authRepository.hasExportVaultPoliciesEnabled,
), ),
) { ) {
init { init {
@ -165,6 +166,7 @@ data class ExportVaultState(
val dialogState: DialogState?, val dialogState: DialogState?,
val exportFormat: ExportVaultFormat, val exportFormat: ExportVaultFormat,
val passwordInput: String, val passwordInput: String,
val policyPreventsExport: Boolean,
) : Parcelable { ) : Parcelable {
/** /**
* Represents the current state of any dialogs on the screen. * Represents the current state of any dialogs on the screen.

View file

@ -499,6 +499,38 @@ class AuthRepositoryTest {
assertNull(repository.rememberedOrgIdentifier) 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 @Test
fun `clear Pending Account Deletion should unblock userState updates`() = runTest { fun `clear Pending Account Deletion should unblock userState updates`() = runTest {
val masterPassword = "hello world" val masterPassword = "hello world"

View file

@ -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 @Test
fun `file format selection button should send ExportFormatOptionSelect action`() { fun `file format selection button should send ExportFormatOptionSelect action`() {
// Open the menu. // Open the menu.
@ -186,4 +199,5 @@ private val DEFAULT_STATE = ExportVaultState(
dialogState = null, dialogState = null,
exportFormat = ExportVaultFormat.JSON, exportFormat = ExportVaultFormat.JSON,
passwordInput = "", passwordInput = "",
policyPreventsExport = false,
) )

View file

@ -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.base.util.asText
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class ExportVaultViewModelTest : BaseViewModelTest() { class ExportVaultViewModelTest : BaseViewModelTest() {
private val authRepository: AuthRepository = mockk() private val authRepository: AuthRepository = mockk {
every { hasExportVaultPoliciesEnabled } returns false
}
private val savedStateHandle = SavedStateHandle() private val savedStateHandle = SavedStateHandle()
@Test @Test
fun `initial state should be correct`() = runTest { fun `initial state should be correct`() = runTest {
every { authRepository.hasExportVaultPoliciesEnabled } returns true
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.stateFlow.test { 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, dialogState = null,
exportFormat = ExportVaultFormat.JSON, exportFormat = ExportVaultFormat.JSON,
passwordInput = "", passwordInput = "",
policyPreventsExport = false,
) )