mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
BIT-786: Export vault policy (#875)
This commit is contained in:
parent
7b6f9491b3
commit
96401aba79
9 changed files with 107 additions and 2 deletions
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in a new issue