[PM-14222] Managed user account deletion prevention (#5114)

Co-authored-by: Matt Portune <mportune@macbook-work.lan>
This commit is contained in:
aj-rosado 2025-05-02 21:22:58 +01:00 committed by GitHub
parent 186bea2d1d
commit 639ca02739
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 275 additions and 83 deletions

View File

@ -20,4 +20,5 @@ data class Organization(
val shouldUseKeyConnector: Boolean,
val role: OrganizationType,
val keyConnectorUrl: String?,
val userIsClaimedByOrganization: Boolean,
)

View File

@ -23,6 +23,7 @@ fun SyncResponseJson.Profile.Organization.toOrganization(): Organization =
role = this.type,
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
keyConnectorUrl = this.keyConnectorUrl,
userIsClaimedByOrganization = this.userIsClaimedByOrganization,
)
/**

View File

@ -2,12 +2,14 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deletea
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
@ -20,22 +22,27 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.cardStyle
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledErrorButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedErrorButton
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
@ -115,61 +122,52 @@ fun DeleteAccountScreen(
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(8.dp))
Icon(
painter = rememberVectorPainter(id = R.drawable.ic_warning),
contentDescription = null,
tint = BitwardenTheme.colorScheme.status.error,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(id = R.string.deleting_your_account_is_permanent),
style = BitwardenTheme.typography.headlineSmall,
color = BitwardenTheme.colorScheme.status.error,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(id = R.string.delete_account_explanation),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.primary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(24.dp))
DeleteAccountButton(
onDeleteAccountConfirmDialogClick = remember(viewModel) {
{
viewModel.trySendAction(
DeleteAccountAction.DeleteAccountConfirmDialogClick(it),
)
}
},
onDeleteAccountClick = remember(viewModel) {
{ viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick) }
},
isUnlockWithPasswordEnabled = state.isUnlockWithPasswordEnabled,
modifier = Modifier
.testTag("DELETE ACCOUNT")
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenOutlinedErrorButton(
label = stringResource(id = R.string.cancel),
onClick = remember(viewModel) {
{ viewModel.trySendAction(DeleteAccountAction.CancelClick) }
},
modifier = Modifier
.testTag("CANCEL")
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.navigationBarsPadding())
Spacer(modifier = Modifier.height(16.dp))
if (state.isUserManagedByOrganization) {
WarningMessageCard(
headerText = stringResource(id = R.string.cannot_delete_your_account),
subtitleText = stringResource(
id = R.string.cannot_delete_your_account_explanation,
),
modifier = Modifier.standardHorizontalMargin(),
)
} else {
WarningMessageCard(
headerText = stringResource(id = R.string.deleting_your_account_is_permanent),
subtitleText = stringResource(id = R.string.delete_account_explanation),
modifier = Modifier.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(24.dp))
DeleteAccountButton(
onDeleteAccountConfirmDialogClick = remember(viewModel) {
{
viewModel.trySendAction(
DeleteAccountAction.DeleteAccountConfirmDialogClick(it),
)
}
},
onDeleteAccountClick = remember(viewModel) {
{ viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick) }
},
isUnlockWithPasswordEnabled = state.isUnlockWithPasswordEnabled,
modifier = Modifier
.testTag("DELETE ACCOUNT")
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenOutlinedErrorButton(
label = stringResource(id = R.string.cancel),
onClick = remember(viewModel) {
{ viewModel.trySendAction(DeleteAccountAction.CancelClick) }
},
modifier = Modifier
.testTag("CANCEL")
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
}
@ -204,3 +202,50 @@ private fun DeleteAccountButton(
modifier = modifier,
)
}
@Composable
private fun WarningMessageCard(
headerText: String,
subtitleText: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.cardStyle(
cardStyle = CardStyle.Full,
paddingHorizontal = 12.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = rememberVectorPainter(id = R.drawable.ic_warning),
contentDescription = null,
tint = BitwardenTheme.colorScheme.status.weak1,
)
Spacer(Modifier.width(width = 12.dp))
Column(modifier = Modifier.weight(weight = 1f)) {
Text(
text = headerText,
style = BitwardenTheme.typography.titleSmall,
color = BitwardenTheme.colorScheme.status.weak1,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(height = 4.dp))
Text(
text = subtitleText,
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(modifier = Modifier.width(width = 4.dp))
}
}
@Preview
@Composable
private fun WarningMessageCard_preview() {
WarningMessageCard(
headerText = stringResource(id = R.string.cannot_delete_your_account),
subtitleText = stringResource(id = R.string.cannot_delete_your_account_explanation),
)
}

View File

@ -3,13 +3,13 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deletea
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
@ -32,12 +32,16 @@ class DeleteAccountViewModel @Inject constructor(
private val authRepository: AuthRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<DeleteAccountState, DeleteAccountEvent, DeleteAccountAction>(
initialState = savedStateHandle[KEY_STATE] ?: DeleteAccountState(
dialog = null,
isUnlockWithPasswordEnabled = requireNotNull(authRepository.userStateFlow.value)
.activeAccount
.hasMasterPassword,
),
initialState = savedStateHandle[KEY_STATE] ?: run {
val account = requireNotNull(authRepository.userStateFlow.value).activeAccount
DeleteAccountState(
dialog = null,
isUnlockWithPasswordEnabled = account.hasMasterPassword,
isUserManagedByOrganization = account
.organizations
.any { it.userIsClaimedByOrganization } == true,
)
},
) {
init {
@ -160,11 +164,13 @@ class DeleteAccountViewModel @Inject constructor(
* @param dialog The dialog for the [DeleteAccountScreen].
* @param isUnlockWithPasswordEnabled Whether or not the user is able to unlock the vault with
* their master password.
* @param isUserManagedByOrganization Whether or not the user is managed by an organization.
*/
@Parcelize
data class DeleteAccountState(
val dialog: DeleteAccountDialog?,
val isUnlockWithPasswordEnabled: Boolean,
val isUserManagedByOrganization: Boolean,
) : Parcelable {
/**

View File

@ -1,20 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="28dp"
android:height="28dp"
android:viewportHeight="28"
android:viewportWidth="28">
<group>
<clip-path android:pathData="M0.667,0.667h26.667v26.667h-26.667z" />
<path
android:fillColor="#BA1A1A"
android:fillType="evenOdd"
android:pathData="M16.036,3.434L26.957,20.361C28.069,22.083 26.646,24 24.921,24H3.079C1.354,24 -0.069,22.083 1.043,20.361L11.964,3.434C12.91,1.967 15.09,1.967 16.036,3.434ZM14.635,4.337C14.345,3.888 13.655,3.888 13.365,4.337L2.443,21.264C2.141,21.733 2.498,22.333 3.079,22.333H24.921C25.502,22.333 25.859,21.733 25.557,21.264L14.635,4.337Z" />
<path
android:fillColor="#BA1A1A"
android:fillType="evenOdd"
android:pathData="M14.001,9.499C14.461,9.499 14.834,9.872 14.834,10.332V15.528C14.834,15.989 14.461,16.362 14.001,16.362C13.541,16.362 13.168,15.989 13.168,15.528V10.332C13.168,9.872 13.541,9.499 14.001,9.499Z" />
<path
android:fillColor="#BA1A1A"
android:pathData="M15.126,19.785C15.126,20.406 14.622,20.91 14.001,20.91C13.38,20.91 12.876,20.406 12.876,19.785C12.876,19.163 13.38,18.66 14.001,18.66C14.622,18.66 15.126,19.163 15.126,19.785Z" />
</group>
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M13.25,16.587C13.25,17.284 12.69,17.848 12,17.848C11.31,17.848 10.75,17.284 10.75,16.587C10.75,15.891 11.31,15.327 12,15.327C12.69,15.327 13.25,15.891 13.25,16.587Z"
android:fillColor="#CB263A"/>
<path
android:pathData="M11.003,9.023C10.703,9.023 10.47,9.286 10.507,9.584L11.008,13.627C11.039,13.878 11.252,14.066 11.504,14.066H12.496C12.748,14.066 12.961,13.878 12.992,13.627L13.493,9.584C13.53,9.286 13.297,9.023 12.997,9.023H11.003Z"
android:fillColor="#CB263A"/>
<path
android:pathData="M13.717,4C12.954,2.667 11.046,2.667 10.283,4L2.269,18C1.505,19.333 2.459,21 3.986,21H20.014C21.541,21 22.495,19.333 21.731,18L13.717,4ZM20.43,18.745L12.415,4.745C12.228,4.418 11.772,4.418 11.585,4.745L3.57,18.745C3.369,19.098 3.634,19.5 3.986,19.5H20.014C20.366,19.5 20.631,19.098 20.43,18.745Z"
android:fillColor="#CB263A"
android:fillType="evenOdd"/>
</vector>

View File

@ -1263,4 +1263,6 @@ Do you want to switch to this account?</string>
<string name="a_certificate_with_the_alias_x_already_exists_do_you_want_to_replace_it">A certificate with this alias \"%s\" already exists. Do you want to replace it?\nReplace the certificate may impact your connection to any environments currently using it.</string>
<string name="replace_certificate">Replace certificate</string>
<string name="unable_to_read_certificate">Unable to read certificate.</string>
<string name="cannot_delete_your_account">Cannot delete your account</string>
<string name="cannot_delete_your_account_explanation">This action cannot be completed because your account is owned by an organization. Contact your organization administrator for additional details.</string>
</resources>

View File

@ -4679,6 +4679,7 @@ class AuthRepositoryTest {
every { shouldUseKeyConnector } returns true
every { type } returns OrganizationType.USER
every { keyConnectorUrl } returns null
every { userIsClaimedByOrganization } returns false
},
)
fakeAuthDiskSource.storeOrganizations(userId = USER_ID_1, organizations = organizations)
@ -4708,6 +4709,7 @@ class AuthRepositoryTest {
every { shouldUseKeyConnector } returns true
every { type } returns OrganizationType.USER
every { keyConnectorUrl } returns url
every { userIsClaimedByOrganization } returns false
},
)
fakeAuthDiskSource.storeOrganizations(userId = USER_ID_1, organizations = organizations)
@ -4745,6 +4747,7 @@ class AuthRepositoryTest {
every { shouldUseKeyConnector } returns true
every { type } returns OrganizationType.USER
every { keyConnectorUrl } returns url
every { userIsClaimedByOrganization } returns false
},
)
fakeAuthDiskSource.storeOrganizations(userId = USER_ID_1, organizations = organizations)
@ -4785,6 +4788,7 @@ class AuthRepositoryTest {
every { shouldUseKeyConnector } returns true
every { type } returns OrganizationType.USER
every { keyConnectorUrl } returns url
every { userIsClaimedByOrganization } returns false
},
)
fakeAuthDiskSource.storeOrganizations(userId = USER_ID_1, organizations = organizations)
@ -4824,6 +4828,7 @@ class AuthRepositoryTest {
every { shouldUseKeyConnector } returns true
every { type } returns OrganizationType.USER
every { keyConnectorUrl } returns url
every { userIsClaimedByOrganization } returns false
},
)
fakeAuthDiskSource.storeOrganizations(userId = USER_ID_1, organizations = organizations)
@ -6887,6 +6892,42 @@ class AuthRepositoryTest {
)
}
@Suppress("MaxLineLength")
@Test
fun `isUserManagedByOrganization should return true if any org userIsClaimedByOrganization is true`() =
runTest {
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
fakeAuthDiskSource.storeUserKey(userId = USER_ID_1, userKey = ENCRYPTED_USER_KEY)
val organizations = listOf(
createMockOrganization(number = 0)
.copy(
userIsClaimedByOrganization = true,
),
createMockOrganization(number = 1),
)
fakeAuthDiskSource.storeOrganizations(userId = USER_ID_1, organizations = organizations)
assertEquals(
SINGLE_USER_STATE_1.toUserState(
vaultState = VAULT_UNLOCK_DATA,
userAccountTokens = emptyList(),
userOrganizationsList = listOf(
UserOrganizations(
userId = USER_ID_1,
organizations = organizations.toOrganizations(),
),
),
userIsUsingKeyConnectorList = emptyList(),
hasPendingAccountAddition = false,
onboardingStatus = null,
isBiometricsEnabledProvider = { false },
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
isDeviceTrustedProvider = { false },
firstTimeState = FIRST_TIME_STATE,
),
repository.userStateFlow.value,
)
}
companion object {
private const val UNIQUE_APP_ID = "testUniqueAppId"
private const val NAME = "Example Name"

View File

@ -195,6 +195,7 @@ class AuthDiskSourceExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = "mockKeyConnectorUrl-1",
userIsClaimedByOrganization = false,
),
),
),
@ -208,6 +209,7 @@ class AuthDiskSourceExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = "mockKeyConnectorUrl-2",
userIsClaimedByOrganization = false,
),
),
),
@ -221,6 +223,7 @@ class AuthDiskSourceExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = "mockKeyConnectorUrl-3",
userIsClaimedByOrganization = false,
),
),
),
@ -369,6 +372,7 @@ class AuthDiskSourceExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = "mockKeyConnectorUrl-1",
userIsClaimedByOrganization = false,
),
),
),
@ -401,6 +405,7 @@ class AuthDiskSourceExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = "mockKeyConnectorUrl-1",
userIsClaimedByOrganization = false,
),
),
),
@ -414,6 +419,7 @@ class AuthDiskSourceExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = "mockKeyConnectorUrl-2",
userIsClaimedByOrganization = false,
),
),
),

View File

@ -24,6 +24,7 @@ class SyncResponseJsonExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = "mockKeyConnectorUrl-1",
userIsClaimedByOrganization = false,
),
createMockOrganization(number = 1).toOrganization(),
)
@ -40,6 +41,7 @@ class SyncResponseJsonExtensionsTest {
shouldUseKeyConnector = true,
role = OrganizationType.ADMIN,
keyConnectorUrl = "mockKeyConnectorUrl-1",
userIsClaimedByOrganization = false,
),
Organization(
id = "mockId-2",
@ -48,6 +50,7 @@ class SyncResponseJsonExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.USER,
keyConnectorUrl = "mockKeyConnectorUrl-2",
userIsClaimedByOrganization = false,
),
),
listOf(

View File

@ -367,6 +367,7 @@ class UserStateJsonExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
isBiometricsEnabled = false,
@ -432,6 +433,7 @@ class UserStateJsonExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
),
@ -477,6 +479,7 @@ class UserStateJsonExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
isBiometricsEnabled = true,
@ -538,6 +541,7 @@ class UserStateJsonExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
),
@ -584,6 +588,7 @@ class UserStateJsonExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
isBiometricsEnabled = false,
@ -653,6 +658,7 @@ class UserStateJsonExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
),
@ -699,6 +705,7 @@ class UserStateJsonExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
isBiometricsEnabled = false,
@ -768,6 +775,7 @@ class UserStateJsonExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
),
@ -814,6 +822,7 @@ class UserStateJsonExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
isBiometricsEnabled = false,
@ -883,6 +892,7 @@ class UserStateJsonExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
),
@ -930,6 +940,7 @@ class UserStateJsonExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.USER,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
isBiometricsEnabled = false,
@ -1002,6 +1013,7 @@ class UserStateJsonExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.USER,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
),
@ -1207,6 +1219,7 @@ class UserStateJsonExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.USER,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
isBiometricsEnabled = false,
@ -1278,6 +1291,7 @@ class UserStateJsonExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.USER,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
),
@ -1324,6 +1338,7 @@ class UserStateJsonExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
isBiometricsEnabled = false,
@ -1395,6 +1410,7 @@ class UserStateJsonExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
),

View File

@ -295,6 +295,7 @@ private val DEFAULT_ACCOUNT = UserState.Account(
shouldUseKeyConnector = true,
role = OrganizationType.USER,
keyConnectorUrl = KEY_CONNECTOR_URL,
userIsClaimedByOrganization = false,
),
),
needsMasterPassword = false,

View File

@ -387,6 +387,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
shouldUseKeyConnector = true,
role = OrganizationType.USER,
keyConnectorUrl = "bitwarden.com",
userIsClaimedByOrganization = false,
),
),
needsMasterPassword = false,

View File

@ -998,6 +998,7 @@ private val DEFAULT_USER_STATE = UserState(
shouldManageResetPassword = false,
role = OrganizationType.USER,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
Organization(
id = "organizationAdmin",
@ -1006,6 +1007,7 @@ private val DEFAULT_USER_STATE = UserState(
shouldManageResetPassword = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
Organization(
id = "organizationOwner",
@ -1014,6 +1016,7 @@ private val DEFAULT_USER_STATE = UserState(
shouldManageResetPassword = false,
role = OrganizationType.OWNER,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
Organization(
id = "organizationCustom",
@ -1022,6 +1025,7 @@ private val DEFAULT_USER_STATE = UserState(
shouldManageResetPassword = false,
role = OrganizationType.CUSTOM,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
needsMasterPassword = false,

View File

@ -214,9 +214,48 @@ class DeleteAccountScreenTest : BaseComposeTest() {
)
}
}
@Suppress("MaxLineLength")
@Test
fun `if isUserManagedByOrganization should display cannot delete message and hide delete button`() {
composeTestRule
.onNodeWithText("Cannot delete your account")
.assertDoesNotExist()
composeTestRule
.onNodeWithText("This action cannot be completed because your account " +
"is owned by an organization. " +
"Contact your organization administrator for additional details.")
.assertDoesNotExist()
composeTestRule
.onAllNodesWithText("Delete account")
.filterToOne(hasClickAction())
.assertExists()
mutableStateFlow.update {
it.copy(isUserManagedByOrganization = true)
}
composeTestRule
.onNodeWithText("Cannot delete your account")
.assertExists()
composeTestRule
.onNodeWithText("This action cannot be completed because your account " +
"is owned by an organization. " +
"Contact your organization administrator for additional details.")
.assertExists()
composeTestRule
.onAllNodesWithText("Delete account")
.filterToOne(hasClickAction())
.assertDoesNotExist()
}
}
private val DEFAULT_STATE: DeleteAccountState = DeleteAccountState(
dialog = null,
isUnlockWithPasswordEnabled = true,
isUserManagedByOrganization = false,
)

View File

@ -255,4 +255,5 @@ private val DEFAULT_USER_STATE: UserState = UserState(
private val DEFAULT_STATE: DeleteAccountState = DeleteAccountState(
dialog = null,
isUnlockWithPasswordEnabled = true,
isUserManagedByOrganization = false,
)

View File

@ -4619,6 +4619,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
isBiometricsEnabled = true,

View File

@ -565,6 +565,7 @@ class CipherViewExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
isBiometricsEnabled = true,

View File

@ -3350,6 +3350,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
shouldUseKeyConnector = false,
role = OrganizationType.OWNER,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
),

View File

@ -499,6 +499,7 @@ private val DEFAULT_USER_STATE = UserState(
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
Organization(
id = "mockOrganizationId-2",
@ -507,6 +508,7 @@ private val DEFAULT_USER_STATE = UserState(
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
Organization(
id = "mockOrganizationId-3",
@ -515,6 +517,7 @@ private val DEFAULT_USER_STATE = UserState(
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
trustedDevice = null,

View File

@ -109,6 +109,7 @@ private fun createMockUserState(hasOrganizations: Boolean = true): UserState =
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
Organization(
id = "mockOrganizationId-2",
@ -117,6 +118,7 @@ private fun createMockUserState(hasOrganizations: Boolean = true): UserState =
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
Organization(
id = "mockOrganizationId-3",
@ -125,6 +127,7 @@ private fun createMockUserState(hasOrganizations: Boolean = true): UserState =
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
)
} else {

View File

@ -259,6 +259,7 @@ class VaultViewModelTest : BaseViewModelTest() {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
trustedDevice = null,
@ -346,6 +347,7 @@ class VaultViewModelTest : BaseViewModelTest() {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
trustedDevice = null,
@ -634,6 +636,7 @@ class VaultViewModelTest : BaseViewModelTest() {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
),

View File

@ -83,6 +83,7 @@ class UserStateExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
trustedDevice = null,
@ -111,6 +112,7 @@ class UserStateExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
trustedDevice = null,
@ -143,6 +145,7 @@ class UserStateExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
trustedDevice = null,
@ -175,6 +178,7 @@ class UserStateExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
trustedDevice = null,
@ -222,6 +226,7 @@ class UserStateExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
trustedDevice = null,
@ -267,6 +272,7 @@ class UserStateExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
trustedDevice = null,
@ -316,6 +322,7 @@ class UserStateExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
trustedDevice = null,
@ -395,6 +402,7 @@ class UserStateExtensionsTest {
shouldManageResetPassword = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
Organization(
id = "organizationId-A",
@ -403,6 +411,7 @@ class UserStateExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
trustedDevice = null,
@ -455,6 +464,7 @@ class UserStateExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
Organization(
id = "organizationId-A",
@ -463,6 +473,7 @@ class UserStateExtensionsTest {
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
trustedDevice = null,

View File

@ -344,6 +344,9 @@ data class SyncResponseJson(
@SerialName("status")
val status: OrganizationStatusType,
@SerialName("userIsClaimedByOrganization")
val userIsClaimedByOrganization: Boolean,
)
/**

View File

@ -101,7 +101,8 @@ private const val SYNC_SUCCESS_JSON = """
"name": "mockName-1",
"useApi": false,
"familySponsorshipValidUntil": "2023-10-27T12:00:00.00Z",
"status": 1
"status": 1,
"userIsClaimedByOrganization": false
}
],
"providers": [
@ -176,7 +177,8 @@ private const val SYNC_SUCCESS_JSON = """
"name": "mockName-1",
"useApi": false,
"familySponsorshipValidUntil": "2023-10-27T12:00:00.00Z",
"status": 1
"status": 1,
"userIsClaimedByOrganization": false
}
]
},

View File

@ -69,6 +69,7 @@ fun createMockOrganization(
shouldUseApi = false,
familySponsorshipValidUntil = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
status = OrganizationStatusType.ACCEPTED,
userIsClaimedByOrganization = false,
)
/**