PM-29491: Implement LeaveOrganizationScreen with vault navigation

- Add LeaveOrganizationScreen MVVM implementation following PR #6239 patterns
- Implement parameterless navigation route with organizationId from PolicyManager
- Send snackbar notification on successful organization departure
- Navigate to VaultGraph after leaving organization (not back navigation)
- Add comprehensive test coverage for ViewModel and Screen
- Add LEFT_ORGANIZATION SnackbarRelay enum value
- Add 4 new string resources for leave organization flow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Honkonen 2025-12-09 16:11:55 -05:00
parent 4a874668f2
commit 4e89b35377
No known key found for this signature in database
GPG Key ID: 27C65CF8B03CC9FB
8 changed files with 957 additions and 0 deletions

View File

@ -0,0 +1,40 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.leaveorganization
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import kotlinx.serialization.Serializable
/**
* The type-safe route for the leave organization screen.
*/
@Serializable
data object LeaveOrganizationRoute
/**
* Add the leave organization screen to the nav graph.
*/
fun NavGraphBuilder.leaveOrganizationDestination(
onNavigateBack: () -> Unit,
onNavigateToVault: () -> Unit,
) {
composableWithSlideTransitions<LeaveOrganizationRoute> {
LeaveOrganizationScreen(
onNavigateBack = onNavigateBack,
onNavigateToVault = onNavigateToVault,
)
}
}
/**
* Navigate to the leave organization screen.
*/
fun NavController.navigateToLeaveOrganization(
navOptions: NavOptions? = null,
) {
this.navigate(
route = LeaveOrganizationRoute,
navOptions = navOptions,
)
}

View File

@ -0,0 +1,216 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.leaveorganization
import androidx.compose.foundation.layout.Column
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.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.button.BitwardenFilledErrorButton
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.composition.LocalIntentManager
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.platform.feature.settings.leaveorganization.handler.rememberLeaveOrganizationHandler
/**
* Top-level composable for the Leave Organization screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LeaveOrganizationScreen(
onNavigateBack: () -> Unit,
onNavigateToVault: () -> Unit,
viewModel: LeaveOrganizationViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val handlers = rememberLeaveOrganizationHandler(viewModel)
EventsEffect(viewModel = viewModel) { event ->
when (event) {
LeaveOrganizationEvent.NavigateBack -> onNavigateBack()
LeaveOrganizationEvent.NavigateToVault -> onNavigateToVault()
is LeaveOrganizationEvent.LaunchUri -> {
intentManager.launchUri(event.uri.toUri())
}
}
}
LeaveOrganizationDialogs(
dialogState = state.dialogState,
onDismissRequest = handlers.onDismissDialog,
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = BitwardenString.leave_organization),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
navigationIconContentDescription = stringResource(id = BitwardenString.back),
onNavigationIconClick = handlers.onBackClick,
)
},
) {
LeaveOrganizationContent(
organizationName = state.viewState.organizationName,
onLeaveClick = handlers.onLeaveClick,
onHelpLinkClick = handlers.onHelpClick,
modifier = Modifier
.fillMaxSize()
.imePadding(),
)
}
}
@Composable
private fun LeaveOrganizationDialogs(
dialogState: LeaveOrganizationState.DialogState?,
onDismissRequest: () -> Unit,
) {
when (dialogState) {
LeaveOrganizationState.DialogState.Loading -> {
BitwardenLoadingDialog(
text = stringResource(id = BitwardenString.loading),
)
}
is LeaveOrganizationState.DialogState.Error -> {
BitwardenBasicDialog(
title = stringResource(id = BitwardenString.an_error_has_occurred),
message = dialogState.message(),
throwable = dialogState.error,
onDismissRequest = onDismissRequest,
)
}
null -> Unit
}
}
@Composable
private fun LeaveOrganizationContent(
organizationName: String,
onLeaveClick: () -> Unit,
onHelpLinkClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.standardHorizontalMargin(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(32.dp))
Icon(
painter = rememberVectorPainter(id = BitwardenDrawable.ic_organization),
contentDescription = null,
modifier = Modifier.size(100.dp),
tint = BitwardenTheme.colorScheme.icon.secondary,
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(
id = BitwardenString.are_you_sure_you_want_to_leave_organization,
organizationName,
),
style = BitwardenTheme.typography.titleMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(id = BitwardenString.leave_organization_warning),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(24.dp))
BitwardenFilledErrorButton(
label = stringResource(
id = BitwardenString.leave_organization_button,
organizationName,
),
onClick = onLeaveClick,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenTextButton(
label = stringResource(id = BitwardenString.how_to_manage_my_vault),
onClick = onHelpLinkClick,
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(showBackground = true)
@Composable
private fun LeaveOrganizationScreen_preview() {
BitwardenTheme {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
topBar = {
BitwardenTopAppBar(
title = "Leave organization",
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
navigationIconContentDescription = "Back",
onNavigationIconClick = {},
)
},
) {
LeaveOrganizationContent(
organizationName = "Acme Corporation",
onLeaveClick = {},
onHelpLinkClick = {},
modifier = Modifier.fillMaxSize(),
)
}
}
}

View File

@ -0,0 +1,232 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.leaveorganization
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* ViewModel for the Leave Organization screen.
*/
@HiltViewModel
class LeaveOrganizationViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val policyManager: PolicyManager,
private val snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<LeaveOrganizationState, LeaveOrganizationEvent, LeaveOrganizationAction>(
initialState = savedStateHandle[KEY_STATE] ?: run {
val organizationId = requireNotNull(
policyManager.getPersonalOwnershipPolicyOrganizationId(),
)
val organization = requireNotNull(
authRepository
.userStateFlow
.value
?.activeAccount
?.organizations
?.firstOrNull { it.id == organizationId },
)
LeaveOrganizationState(
organizationId = organizationId,
viewState = LeaveOrganizationState.ViewState(
organizationName = organization.name.orEmpty(),
),
dialogState = null,
)
},
) {
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: LeaveOrganizationAction) {
when (action) {
LeaveOrganizationAction.BackClick -> handleBackClick()
LeaveOrganizationAction.LeaveOrganizationClick -> handleLeaveOrganizationClick()
LeaveOrganizationAction.HelpLinkClick -> handleHelpLinkClick()
LeaveOrganizationAction.DismissDialog -> handleDismissDialog()
is LeaveOrganizationAction.Internal.LeaveOrganizationResultReceived -> {
handleLeaveOrganizationResultReceived(action)
}
}
}
private fun handleBackClick() {
sendEvent(LeaveOrganizationEvent.NavigateBack)
}
private fun handleLeaveOrganizationClick() {
mutableStateFlow.update {
it.copy(dialogState = LeaveOrganizationState.DialogState.Loading)
}
viewModelScope.launch {
val result = authRepository.leaveOrganization(state.organizationId)
sendAction(
LeaveOrganizationAction.Internal.LeaveOrganizationResultReceived(result),
)
}
}
private fun handleHelpLinkClick() {
sendEvent(
LeaveOrganizationEvent.LaunchUri(
uri = "https://bitwarden.com/help/transfer-ownership/",
),
)
}
private fun handleDismissDialog() {
mutableStateFlow.update {
it.copy(dialogState = null)
}
}
private fun handleLeaveOrganizationResultReceived(
action: LeaveOrganizationAction.Internal.LeaveOrganizationResultReceived,
) {
when (val result = action.result) {
is LeaveOrganizationResult.Success -> {
mutableStateFlow.update {
it.copy(dialogState = null)
}
snackbarRelayManager.sendSnackbarData(
relay = SnackbarRelay.LEFT_ORGANIZATION,
data = BitwardenSnackbarData(
message = BitwardenString.you_left_the_organization.asText(),
),
)
sendEvent(LeaveOrganizationEvent.NavigateToVault)
}
is LeaveOrganizationResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = LeaveOrganizationState.DialogState.Error(
message = BitwardenString.generic_error_message.asText(),
error = result.error,
),
)
}
}
}
}
}
/**
* State for the Leave Organization screen.
*/
@Parcelize
data class LeaveOrganizationState(
val organizationId: String,
val viewState: ViewState,
val dialogState: DialogState?,
) : Parcelable {
/**
* Display data for the view.
*/
@Parcelize
data class ViewState(
val organizationName: String,
) : Parcelable
/**
* Dialog states for transient UI.
*/
sealed class DialogState : Parcelable {
/**
* Loading dialog during leave operation.
*/
@Parcelize
data object Loading : DialogState()
/**
* Error dialog when leave operation fails.
*/
@Parcelize
data class Error(
val message: Text,
val error: Throwable? = null,
) : DialogState()
}
}
/**
* Events for the Leave Organization screen.
*/
sealed class LeaveOrganizationEvent {
/**
* Navigate back to previous screen.
*/
data object NavigateBack : LeaveOrganizationEvent()
/**
* Navigate to the Vault screen.
*/
data object NavigateToVault : LeaveOrganizationEvent()
/**
* Launch external URI.
*/
data class LaunchUri(val uri: String) : LeaveOrganizationEvent()
}
/**
* Actions for the Leave Organization screen.
*/
sealed class LeaveOrganizationAction {
/**
* User clicked the back button.
*/
data object BackClick : LeaveOrganizationAction()
/**
* User clicked the leave organization button.
*/
data object LeaveOrganizationClick : LeaveOrganizationAction()
/**
* User clicked the help link.
*/
data object HelpLinkClick : LeaveOrganizationAction()
/**
* User dismissed a dialog.
*/
data object DismissDialog : LeaveOrganizationAction()
/**
* Internal actions for ViewModel processing.
*/
sealed class Internal : LeaveOrganizationAction() {
/**
* Leave organization result received from repository.
*/
data class LeaveOrganizationResultReceived(
val result: LeaveOrganizationResult,
) : Internal()
}
}

View File

@ -0,0 +1,49 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.leaveorganization.handler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.x8bit.bitwarden.ui.platform.feature.settings.leaveorganization.LeaveOrganizationAction
import com.x8bit.bitwarden.ui.platform.feature.settings.leaveorganization.LeaveOrganizationViewModel
/**
* A class to handle user interactions for the Leave Organization screen.
*/
data class LeaveOrganizationHandler(
val onBackClick: () -> Unit,
val onLeaveClick: () -> Unit,
val onHelpClick: () -> Unit,
val onDismissDialog: () -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
/**
* Creates an instance of [LeaveOrganizationHandler] using the provided
* [LeaveOrganizationViewModel].
*/
fun create(viewModel: LeaveOrganizationViewModel): LeaveOrganizationHandler =
LeaveOrganizationHandler(
onBackClick = {
viewModel.trySendAction(LeaveOrganizationAction.BackClick)
},
onLeaveClick = {
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
},
onHelpClick = {
viewModel.trySendAction(LeaveOrganizationAction.HelpLinkClick)
},
onDismissDialog = {
viewModel.trySendAction(LeaveOrganizationAction.DismissDialog)
},
)
}
}
/**
* Helper function to create and remember a [LeaveOrganizationHandler] instance.
*/
@Composable
fun rememberLeaveOrganizationHandler(
viewModel: LeaveOrganizationViewModel,
): LeaveOrganizationHandler = remember(viewModel) {
LeaveOrganizationHandler.create(viewModel)
}

View File

@ -24,4 +24,5 @@ enum class SnackbarRelay {
LOGINS_IMPORTED,
SEND_DELETED,
SEND_UPDATED,
LEFT_ORGANIZATION,
}

View File

@ -0,0 +1,184 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.leaveorganization
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class LeaveOrganizationScreenTest : BitwardenComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToVaultCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<LeaveOrganizationEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<LeaveOrganizationViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setUp() {
setContent {
LeaveOrganizationScreen(
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToVault = { onNavigateToVaultCalled = true },
viewModel = viewModel,
)
}
}
@Test
fun `NavigateBack event should call onNavigateBack`() {
mutableEventFlow.tryEmit(LeaveOrganizationEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `NavigateToVault event should call onNavigateToVault`() {
mutableEventFlow.tryEmit(LeaveOrganizationEvent.NavigateToVault)
assertTrue(onNavigateToVaultCalled)
}
@Test
fun `leave organization button click should emit LeaveOrganizationClick action`() {
composeTestRule
.onAllNodesWithText("Leave $ORGANIZATION_NAME")
.filterToOne(hasClickAction())
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick) }
}
@Test
fun `help link button click should emit HelpLinkClick action`() {
composeTestRule
.onNodeWithText("How to manage My vault")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(LeaveOrganizationAction.HelpLinkClick) }
}
@Test
fun `organization name should be displayed in title`() {
composeTestRule
.onNodeWithText("Are you sure you want to leave $ORGANIZATION_NAME?")
.assertExists()
}
@Test
fun `organization name should be displayed in button`() {
composeTestRule
.onAllNodesWithText("Leave $ORGANIZATION_NAME")
.filterToOne(hasClickAction())
.assertExists()
}
@Test
fun `warning text should be displayed`() {
composeTestRule
.onNodeWithText(
text = "You will lose access to items shared with this organization. " +
"Your personal items will remain in your vault. " +
"If you need access again, contact your organization administrator.",
)
.assertExists()
}
@Test
fun `loading dialog should not be displayed by default`() {
composeTestRule
.onAllNodesWithText("Loading")
.filterToOne(hasAnyAncestor(isDialog()))
.assertDoesNotExist()
}
@Test
fun `loading dialog should be displayed when dialogState is Loading`() {
mutableStateFlow.update {
it.copy(dialogState = LeaveOrganizationState.DialogState.Loading)
}
composeTestRule
.onAllNodesWithText("Loading")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Test
fun `error dialog should not be displayed by default`() {
composeTestRule
.onAllNodesWithText("An error has occurred")
.filterToOne(hasAnyAncestor(isDialog()))
.assertDoesNotExist()
}
@Test
fun `error dialog should be displayed when dialogState is Error`() {
val errorMessage = "Something went wrong"
mutableStateFlow.update {
it.copy(
dialogState = LeaveOrganizationState.DialogState.Error(
message = errorMessage.asText(),
error = Throwable("Test error"),
),
)
}
composeTestRule
.onAllNodesWithText("An error has occurred")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
composeTestRule
.onAllNodesWithText(errorMessage)
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Test
fun `error dialog dismiss should emit DismissDialog action`() {
mutableStateFlow.update {
it.copy(
dialogState = LeaveOrganizationState.DialogState.Error(
message = "Error message".asText(),
),
)
}
composeTestRule
.onAllNodesWithText("Okay")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(LeaveOrganizationAction.DismissDialog)
}
}
}
private const val ORGANIZATION_ID = "organization-id-1"
private const val ORGANIZATION_NAME = "Test Organization"
private val DEFAULT_STATE = LeaveOrganizationState(
organizationId = ORGANIZATION_ID,
viewState = LeaveOrganizationState.ViewState(
organizationName = ORGANIZATION_NAME,
),
dialogState = null,
)

View File

@ -0,0 +1,230 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.leaveorganization
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class LeaveOrganizationViewModelTest : BaseViewModelTest() {
private val mockAuthRepository: AuthRepository = mockk {
every { userStateFlow } returns MutableStateFlow(DEFAULT_USER_STATE)
}
private val mockPolicyManager: PolicyManager = mockk {
every { getPersonalOwnershipPolicyOrganizationId() } returns ORGANIZATION_ID
}
private val mockSnackbarRelayManager: SnackbarRelayManager<SnackbarRelay> = mockk {
every { sendSnackbarData(data = any(), relay = any()) } just runs
}
@Test
fun `initial state should be correct`() {
val viewModel = createViewModel()
val expectedState = LeaveOrganizationState(
organizationId = ORGANIZATION_ID,
viewState = LeaveOrganizationState.ViewState(
organizationName = ORGANIZATION_NAME,
),
dialogState = null,
)
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Test
fun `BackClick should emit NavigateBack event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(LeaveOrganizationAction.BackClick)
assertEquals(LeaveOrganizationEvent.NavigateBack, awaitItem())
}
}
@Test
fun `HelpLinkClick should emit LaunchUri event with help URL`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(LeaveOrganizationAction.HelpLinkClick)
val event = awaitItem()
assert(event is LeaveOrganizationEvent.LaunchUri)
assertEquals(
"https://bitwarden.com/help/transfer-ownership/",
(event as LeaveOrganizationEvent.LaunchUri).uri,
)
}
}
@Test
fun `LeaveOrganizationClick should show loading dialog`() = runTest {
coEvery {
mockAuthRepository.leaveOrganization(any())
} coAnswers {
LeaveOrganizationResult.Success
}
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(null, awaitItem().dialogState)
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
val loadingState = awaitItem()
assert(loadingState.dialogState is LeaveOrganizationState.DialogState.Loading)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `LeaveOrganizationClick with Success should send snackbar and navigate to vault`() =
runTest {
coEvery {
mockAuthRepository.leaveOrganization(ORGANIZATION_ID)
} returns LeaveOrganizationResult.Success
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
assertEquals(LeaveOrganizationEvent.NavigateToVault, awaitItem())
}
verify {
mockSnackbarRelayManager.sendSnackbarData(
relay = SnackbarRelay.LEFT_ORGANIZATION,
data = BitwardenSnackbarData(
message = BitwardenString.you_left_the_organization.asText(),
),
)
}
}
@Test
fun `LeaveOrganizationClick with Error should show error dialog`() = runTest {
val error = Throwable("Test error")
coEvery {
mockAuthRepository.leaveOrganization(ORGANIZATION_ID)
} returns LeaveOrganizationResult.Error(error)
val viewModel = createViewModel()
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
val state = viewModel.stateFlow.value
assert(state.dialogState is LeaveOrganizationState.DialogState.Error)
val dialogState = state.dialogState as LeaveOrganizationState.DialogState.Error
assertEquals(BitwardenString.generic_error_message.asText(), dialogState.message)
assertEquals(error, dialogState.error)
}
@Test
fun `DismissDialog should clear dialog state`() = runTest {
coEvery {
mockAuthRepository.leaveOrganization(ORGANIZATION_ID)
} returns LeaveOrganizationResult.Error(Throwable("Error"))
val viewModel = createViewModel()
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
assert(viewModel.stateFlow.value.dialogState != null)
viewModel.trySendAction(LeaveOrganizationAction.DismissDialog)
assertNull(viewModel.stateFlow.value.dialogState)
}
@Test
fun `state should be restored from SavedStateHandle`() {
val savedState = LeaveOrganizationState(
organizationId = "saved-org-id",
viewState = LeaveOrganizationState.ViewState(
organizationName = "Saved Organization",
),
dialogState = null,
)
val savedStateHandle = SavedStateHandle(mapOf("state" to savedState))
val viewModel = LeaveOrganizationViewModel(
authRepository = mockAuthRepository,
policyManager = mockPolicyManager,
snackbarRelayManager = mockSnackbarRelayManager,
savedStateHandle = savedStateHandle,
)
assertEquals(savedState, viewModel.stateFlow.value)
}
private fun createViewModel(
savedStateHandle: SavedStateHandle = SavedStateHandle(),
): LeaveOrganizationViewModel = LeaveOrganizationViewModel(
authRepository = mockAuthRepository,
policyManager = mockPolicyManager,
snackbarRelayManager = mockSnackbarRelayManager,
savedStateHandle = savedStateHandle,
)
}
private const val ORGANIZATION_ID = "organization-id-1"
private const val ORGANIZATION_NAME = "Test Organization"
private val DEFAULT_ORGANIZATION = Organization(
id = ORGANIZATION_ID,
name = ORGANIZATION_NAME,
shouldManageResetPassword = false,
shouldUseKeyConnector = false,
role = OrganizationType.USER,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
limitItemDeletion = false,
)
private val DEFAULT_USER_STATE = UserState(
activeUserId = "user-id-1",
accounts = listOf(
UserState.Account(
userId = "user-id-1",
name = "Test User",
email = "test@example.com",
avatarColorHex = "#175DDC",
environment = Environment.Us,
isPremium = false,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
needsMasterPassword = false,
hasMasterPassword = true,
trustedDevice = null,
organizations = listOf(DEFAULT_ORGANIZATION),
isBiometricsEnabled = false,
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
isUsingKeyConnector = false,
onboardingStatus = OnboardingStatus.COMPLETE,
firstTimeState = FirstTimeState(),
isExportable = true,
),
),
)

View File

@ -496,6 +496,10 @@ Scanning will happen automatically.</string>
<string name="remove_master_password">Remove master password</string>
<string name="leave_organization">Leave organization</string>
<string name="leave_organization_name">Leave %1$s?</string>
<string name="are_you_sure_you_want_to_leave_organization">Are you sure you want to leave %1$s?</string>
<string name="leave_organization_button">Leave %1$s</string>
<string name="leave_organization_warning">You will lose access to items shared with this organization. Your personal items will remain in your vault. If you need access again, contact your organization administrator.</string>
<string name="how_to_manage_my_vault">How to manage My vault</string>
<string name="fido2_authenticate_web_authn">Authenticate WebAuthn</string>
<string name="fido2_return_to_app">Return to app</string>
<string name="reset_password_auto_enroll_invite_warning">This organization has an enterprise policy that will automatically enroll you in password reset. Enrollment will allow organization administrators to change your master password.</string>
@ -1161,4 +1165,5 @@ Do you want to switch to this account?</string>
<string name="use_your_devices_lock_method_to_unlock_the_app">Use your devices lock method to unlock the app</string>
<string name="loading_vault_data">Loading vault data…</string>
<string name="resending">Resending</string>
<string name="you_left_the_organization">You left the organization</string>
</resources>