PM-29491: Implement LeaveOrganizationScreen (#6253)

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Honkonen
2025-12-11 16:35:15 +00:00
committed by GitHub
co-authored by Claude Sonnet 4.5
parent 54ea921b25
commit e415145c53
10 changed files with 1136 additions and 1 deletions
@@ -1,7 +1,7 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of deleting an account.
* Models result of leaving an organization.
*/
sealed class LeaveOrganizationResult {
/**
@@ -24,4 +24,5 @@ enum class SnackbarRelay {
LOGINS_IMPORTED,
SEND_DELETED,
SEND_UPDATED,
LEFT_ORGANIZATION,
}
@@ -0,0 +1,82 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.ui.vault.feature.leaveorganization
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.toRoute
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
import kotlinx.serialization.Serializable
/**
* The type-safe route for the leave organization screen.
*
* @property organizationId The ID of the organization to leave.
* @property organizationName The name of the organization to leave.
*/
@OmitFromCoverage
@Serializable
data class LeaveOrganizationRoute(
val organizationId: String,
val organizationName: String,
)
/**
* Class to retrieve leave organization arguments from the [SavedStateHandle].
*
* @property organizationId The ID of the organization to leave.
* @property organizationName The name of the organization to leave.
*/
data class LeaveOrganizationArgs(
val organizationId: String,
val organizationName: String,
)
/**
* Constructs a [LeaveOrganizationArgs] from the [SavedStateHandle] and internal route data.
*/
fun SavedStateHandle.toLeaveOrganizationArgs(): LeaveOrganizationArgs {
val route = this.toRoute<LeaveOrganizationRoute>()
return LeaveOrganizationArgs(
organizationId = route.organizationId,
organizationName = route.organizationName,
)
}
/**
* Add the leave organization screen to the nav graph.
*/
fun NavGraphBuilder.leaveOrganizationDestination(
onNavigateBack: () -> Unit,
onNavigateToVault: () -> Unit,
) {
composableWithPushTransitions<LeaveOrganizationRoute> {
LeaveOrganizationScreen(
onNavigateBack = onNavigateBack,
onNavigateToVault = onNavigateToVault,
)
}
}
/**
* Navigate to the leave organization screen.
*
* @param organizationId The ID of the organization to leave.
* @param organizationName The name of the organization to leave.
*/
fun NavController.navigateToLeaveOrganization(
organizationId: String,
organizationName: String,
navOptions: NavOptions? = null,
) {
this.navigate(
route = LeaveOrganizationRoute(
organizationId = organizationId,
organizationName = organizationName,
),
navOptions = navOptions,
)
}
@@ -0,0 +1,232 @@
package com.x8bit.bitwarden.ui.vault.feature.leaveorganization
import androidx.compose.foundation.Image
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.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.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.layout.ContentScale
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.vault.feature.leaveorganization.handlers.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(
state = state,
onLeaveClick = handlers.onLeaveClick,
onHelpLinkClick = handlers.onHelpClick,
modifier = Modifier.fillMaxSize(),
)
}
}
@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
}
}
@Suppress("LongMethod")
@Composable
private fun LeaveOrganizationContent(
state: LeaveOrganizationState,
onLeaveClick: () -> Unit,
onHelpLinkClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(32.dp))
Image(
painter = rememberVectorPainter(id = BitwardenDrawable.ill_leave_organization),
contentDescription = null,
contentScale = ContentScale.FillHeight,
modifier = Modifier
.standardHorizontalMargin()
.size(100.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(
id = BitwardenString.are_you_sure_you_want_to_leave_organization,
state.organizationName,
),
style = BitwardenTheme.typography.titleMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
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,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(24.dp))
BitwardenFilledErrorButton(
label = stringResource(
id = BitwardenString.leave_organization_button,
state.organizationName,
),
onClick = onLeaveClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenTextButton(
label = stringResource(id = BitwardenString.how_to_manage_my_vault),
onClick = onHelpLinkClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
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(
state = LeaveOrganizationState(
organizationId = "",
organizationName = "Test Organization",
dialogState = null,
),
onLeaveClick = {},
onHelpLinkClick = {},
modifier = Modifier.fillMaxSize(),
)
}
}
}
@@ -0,0 +1,209 @@
package com.x8bit.bitwarden.ui.vault.feature.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.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 snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<LeaveOrganizationState, LeaveOrganizationEvent, LeaveOrganizationAction>(
initialState = savedStateHandle[KEY_STATE] ?: run {
val args = savedStateHandle.toLeaveOrganizationArgs()
LeaveOrganizationState(
organizationId = args.organizationId,
organizationName = args.organizationName,
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 organizationName: String,
val dialogState: DialogState?,
) : 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()
}
}
@@ -0,0 +1,49 @@
package com.x8bit.bitwarden.ui.vault.feature.leaveorganization.handlers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.x8bit.bitwarden.ui.vault.feature.leaveorganization.LeaveOrganizationAction
import com.x8bit.bitwarden.ui.vault.feature.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)
}
@@ -0,0 +1,191 @@
package com.x8bit.bitwarden.ui.vault.feature.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.onNodeWithContentDescription
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 `back button click should emit NavigateBack event`() {
composeTestRule
.onNodeWithContentDescription("Back")
.performClick()
verify { viewModel.trySendAction(LeaveOrganizationAction.BackClick) }
}
@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 = "By declining, your personal items will stay in your account, but youll " +
"lose access to shared items and organization features.\n\nContact your " +
"admin to regain access.",
)
.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,
organizationName = ORGANIZATION_NAME,
dialogState = null,
)
@@ -0,0 +1,239 @@
package com.x8bit.bitwarden.ui.vault.feature.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.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.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class LeaveOrganizationViewModelTest : BaseViewModelTest() {
private val mockAuthRepository: AuthRepository = mockk {
every { userStateFlow } returns MutableStateFlow(DEFAULT_USER_STATE)
}
private val mockSnackbarRelayManager: SnackbarRelayManager<SnackbarRelay> = mockk {
every { sendSnackbarData(data = any(), relay = any()) } just runs
}
@BeforeEach
fun setup() {
mockkStatic(SavedStateHandle::toLeaveOrganizationArgs)
}
@AfterEach
fun tearDown() {
unmockkStatic(SavedStateHandle::toLeaveOrganizationArgs)
}
@Test
fun `initial state should be correct`() {
val viewModel = createViewModel()
val expectedState = LeaveOrganizationState(
organizationId = ORGANIZATION_ID,
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",
organizationName = "Saved Organization",
dialogState = null,
)
val savedStateHandle = SavedStateHandle(mapOf("state" to savedState))
val viewModel = LeaveOrganizationViewModel(
authRepository = mockAuthRepository,
snackbarRelayManager = mockSnackbarRelayManager,
savedStateHandle = savedStateHandle,
)
assertEquals(savedState, viewModel.stateFlow.value)
}
private fun createViewModel(
savedStateHandle: SavedStateHandle = SavedStateHandle(),
): LeaveOrganizationViewModel {
every { savedStateHandle.toLeaveOrganizationArgs() } returns LeaveOrganizationArgs(
organizationId = ORGANIZATION_ID,
organizationName = ORGANIZATION_NAME,
)
return LeaveOrganizationViewModel(
authRepository = mockAuthRepository,
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,
),
),
)
@@ -0,0 +1,127 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="100dp"
android:height="100dp"
android:viewportHeight="100"
android:viewportWidth="100">
<path
android:name="secondary"
android:fillColor="#AAC3EF"
android:pathData="M56.25,8.33C56.25,3.73 59.98,0 64.58,0H72.92C77.52,0 81.25,3.73 81.25,8.33V12.5H56.25V8.33Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M72.92,2.08H64.58C61.13,2.08 58.33,4.88 58.33,8.33V10.42H79.17V8.33C79.17,4.88 76.37,2.08 72.92,2.08ZM64.58,0C59.98,0 56.25,3.73 56.25,8.33V12.5H81.25V8.33C81.25,3.73 77.52,0 72.92,0H64.58Z" />
<path
android:name="secondary"
android:fillColor="#AAC3EF"
android:pathData="M48.96,16.67C48.96,12.06 52.69,8.33 57.29,8.33H80.21C84.81,8.33 88.54,12.06 88.54,16.67V22.92H48.96V16.67Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M80.21,10.42H57.29C53.84,10.42 51.04,13.21 51.04,16.67V20.83H86.46V16.67C86.46,13.21 83.66,10.42 80.21,10.42ZM57.29,8.33C52.69,8.33 48.96,12.06 48.96,16.67V22.92H88.54V16.67C88.54,12.06 84.81,8.33 80.21,8.33H57.29Z" />
<path
android:name="secondary"
android:fillColor="#AAC3EF"
android:pathData="M41.67,27.08C41.67,22.48 45.4,18.75 50,18.75H87.5C92.1,18.75 95.83,22.48 95.83,27.08V95.83H41.67V27.08Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M87.5,20.83H50C46.55,20.83 43.75,23.63 43.75,27.08V93.75H93.75V27.08C93.75,23.63 90.95,20.83 87.5,20.83ZM50,18.75C45.4,18.75 41.67,22.48 41.67,27.08V95.83H95.83V27.08C95.83,22.48 92.1,18.75 87.5,18.75H50Z" />
<path
android:name="primary"
android:fillColor="#DBE5F6"
android:pathData="M4.17,45.83C4.17,41.23 7.9,37.5 12.5,37.5H52.08C56.69,37.5 60.42,41.23 60.42,45.83V95.83H4.17V45.83Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M52.08,39.58H12.5C9.05,39.58 6.25,42.38 6.25,45.83V93.75H58.33V45.83C58.33,42.38 55.54,39.58 52.08,39.58ZM12.5,37.5C7.9,37.5 4.17,41.23 4.17,45.83V95.83H60.42V45.83C60.42,41.23 56.69,37.5 52.08,37.5H12.5Z" />
<path
android:name="accent"
android:fillColor="#FFBF00"
android:pathData="M71.54,62.95C73.55,59.5 78.54,59.5 80.54,62.95L97.53,92.17C99.55,95.65 97.05,100 93.03,100H59.05C55.04,100 52.53,95.65 54.55,92.17L71.54,62.95Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M95.73,93.22L78.74,64C77.54,61.93 74.54,61.93 73.34,64L56.35,93.22C55.14,95.3 56.64,97.92 59.05,97.92H93.03C95.44,97.92 96.94,95.3 95.73,93.22ZM80.54,62.95C78.54,59.5 73.55,59.5 71.54,62.95L54.55,92.17C52.53,95.65 55.04,100 59.05,100H93.03C97.05,100 99.55,95.65 97.53,92.17L80.54,62.95Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:pathData="M78.13,91.67C78.13,92.82 77.19,93.75 76.04,93.75C74.89,93.75 73.96,92.82 73.96,91.67C73.96,90.52 74.89,89.58 76.04,89.58C77.19,89.58 78.13,90.52 78.13,91.67Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:pathData="M72.98,73.49C72.95,73.19 73.19,72.92 73.5,72.92H78.59C78.9,72.92 79.14,73.19 79.1,73.49L77.65,87.03C77.63,87.3 77.4,87.5 77.14,87.5H74.95C74.68,87.5 74.46,87.3 74.43,87.03L72.98,73.49Z" />
<path
android:name="secondary"
android:fillColor="#AAC3EF"
android:pathData="M21.88,83.33C21.88,78.73 25.61,75 30.21,75H34.38C38.98,75 42.71,78.73 42.71,83.33V95.83H21.88V83.33Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M34.38,77.08H30.21C26.76,77.08 23.96,79.88 23.96,83.33V93.75H40.63V83.33C40.63,79.88 37.83,77.08 34.38,77.08ZM30.21,75C25.61,75 21.88,78.73 21.88,83.33V95.83H42.71V83.33C42.71,78.73 38.98,75 34.38,75H30.21Z" />
<path
android:name="tertiary"
android:fillColor="#ffffff"
android:pathData="M13.54,47.92C13.54,47.34 14.01,46.88 14.58,46.88H22.92C23.49,46.88 23.96,47.34 23.96,47.92V56.25C23.96,56.83 23.49,57.29 22.92,57.29H14.58C14.01,57.29 13.54,56.83 13.54,56.25V47.92Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M15.63,48.96V55.21H21.88V48.96H15.63ZM14.58,46.88C14.01,46.88 13.54,47.34 13.54,47.92V56.25C13.54,56.83 14.01,57.29 14.58,57.29H22.92C23.49,57.29 23.96,56.83 23.96,56.25V47.92C23.96,47.34 23.49,46.88 22.92,46.88H14.58Z" />
<path
android:name="tertiary"
android:fillColor="#ffffff"
android:pathData="M13.54,61.46C13.54,60.88 14.01,60.42 14.58,60.42H22.92C23.49,60.42 23.96,60.88 23.96,61.46V69.79C23.96,70.37 23.49,70.83 22.92,70.83H14.58C14.01,70.83 13.54,70.37 13.54,69.79V61.46Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M15.63,62.5V68.75H21.88V62.5H15.63ZM14.58,60.42C14.01,60.42 13.54,60.88 13.54,61.46V69.79C13.54,70.37 14.01,70.83 14.58,70.83H22.92C23.49,70.83 23.96,70.37 23.96,69.79V61.46C23.96,60.88 23.49,60.42 22.92,60.42H14.58Z" />
<path
android:name="tertiary"
android:fillColor="#ffffff"
android:pathData="M27.08,47.92C27.08,47.34 27.55,46.88 28.13,46.88H36.46C37.03,46.88 37.5,47.34 37.5,47.92V56.25C37.5,56.83 37.03,57.29 36.46,57.29H28.13C27.55,57.29 27.08,56.83 27.08,56.25V47.92Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M29.17,48.96V55.21H35.42V48.96H29.17ZM28.13,46.88C27.55,46.88 27.08,47.34 27.08,47.92V56.25C27.08,56.83 27.55,57.29 28.13,57.29H36.46C37.03,57.29 37.5,56.83 37.5,56.25V47.92C37.5,47.34 37.03,46.88 36.46,46.88H28.13Z" />
<path
android:name="tertiary"
android:fillColor="#ffffff"
android:pathData="M27.08,61.46C27.08,60.88 27.55,60.42 28.13,60.42H36.46C37.03,60.42 37.5,60.88 37.5,61.46V69.79C37.5,70.37 37.03,70.83 36.46,70.83H28.13C27.55,70.83 27.08,70.37 27.08,69.79V61.46Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M29.17,62.5V68.75H35.42V62.5H29.17ZM28.13,60.42C27.55,60.42 27.08,60.88 27.08,61.46V69.79C27.08,70.37 27.55,70.83 28.13,70.83H36.46C37.03,70.83 37.5,70.37 37.5,69.79V61.46C37.5,60.88 37.03,60.42 36.46,60.42H28.13Z" />
<path
android:name="tertiary"
android:fillColor="#ffffff"
android:pathData="M40.63,61.46C40.63,60.88 41.09,60.42 41.67,60.42H50C50.58,60.42 51.04,60.88 51.04,61.46V69.79C51.04,70.37 50.58,70.83 50,70.83H41.67C41.09,70.83 40.63,70.37 40.63,69.79V61.46Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M42.71,62.5V68.75H48.96V62.5H42.71ZM41.67,60.42C41.09,60.42 40.63,60.88 40.63,61.46V69.79C40.63,70.37 41.09,70.83 41.67,70.83H50C50.58,70.83 51.04,70.37 51.04,69.79V61.46C51.04,60.88 50.58,60.42 50,60.42H41.67Z" />
<path
android:name="tertiary"
android:fillColor="#ffffff"
android:pathData="M40.63,47.92C40.63,47.34 41.09,46.88 41.67,46.88H50C50.58,46.88 51.04,47.34 51.04,47.92V56.25C51.04,56.83 50.58,57.29 50,57.29H41.67C41.09,57.29 40.63,56.83 40.63,56.25V47.92Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M42.71,48.96V55.21H48.96V48.96H42.71ZM41.67,46.88C41.09,46.88 40.63,47.34 40.63,47.92V56.25C40.63,56.83 41.09,57.29 41.67,57.29H50C50.58,57.29 51.04,56.83 51.04,56.25V47.92C51.04,47.34 50.58,46.88 50,46.88H41.67Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M48.96,28.13C48.96,27.55 49.42,27.08 50,27.08H56.25C56.83,27.08 57.29,27.55 57.29,28.13C57.29,28.7 56.83,29.17 56.25,29.17L50,29.17C49.42,29.17 48.96,28.7 48.96,28.13ZM60.42,27.08C59.84,27.08 59.38,27.55 59.38,28.13C59.38,28.7 59.84,29.17 60.42,29.17L66.67,29.17C67.24,29.17 67.71,28.7 67.71,28.13C67.71,27.55 67.24,27.08 66.67,27.08H60.42ZM69.79,28.13C69.79,27.55 70.26,27.08 70.83,27.08H77.08C77.66,27.08 78.13,27.55 78.13,28.13C78.13,28.7 77.66,29.17 77.08,29.17L70.83,29.17C70.26,29.17 69.79,28.7 69.79,28.13ZM80.21,28.13C80.21,27.55 80.67,27.08 81.25,27.08H87.5C88.08,27.08 88.54,27.55 88.54,28.13C88.54,28.7 88.08,29.17 87.5,29.17L81.25,29.17C80.67,29.17 80.21,28.7 80.21,28.13ZM80.21,34.38C80.21,33.8 80.67,33.33 81.25,33.33L87.5,33.33C88.08,33.33 88.54,33.8 88.54,34.38C88.54,34.95 88.08,35.42 87.5,35.42L81.25,35.42C80.67,35.42 80.21,34.95 80.21,34.38ZM81.25,39.58C80.67,39.58 80.21,40.05 80.21,40.63C80.21,41.2 80.67,41.67 81.25,41.67L87.5,41.67C88.08,41.67 88.54,41.2 88.54,40.63C88.54,40.05 88.08,39.58 87.5,39.58L81.25,39.58ZM80.21,46.88C80.21,46.3 80.67,45.83 81.25,45.83H87.5C88.08,45.83 88.54,46.3 88.54,46.88C88.54,47.45 88.08,47.92 87.5,47.92H81.25C80.67,47.92 80.21,47.45 80.21,46.88ZM81.25,52.08C80.67,52.08 80.21,52.55 80.21,53.13C80.21,53.7 80.67,54.17 81.25,54.17H87.5C88.08,54.17 88.54,53.7 88.54,53.13C88.54,52.55 88.08,52.08 87.5,52.08H81.25ZM70.83,33.33C70.26,33.33 69.79,33.8 69.79,34.38C69.79,34.95 70.26,35.42 70.83,35.42L77.08,35.42C77.66,35.42 78.13,34.95 78.13,34.38C78.13,33.8 77.66,33.33 77.08,33.33L70.83,33.33ZM69.79,40.63C69.79,40.05 70.26,39.58 70.83,39.58L77.08,39.58C77.66,39.58 78.13,40.05 78.13,40.63C78.13,41.2 77.66,41.67 77.08,41.67L70.83,41.67C70.26,41.67 69.79,41.2 69.79,40.63ZM70.83,45.83C70.26,45.83 69.79,46.3 69.79,46.88C69.79,47.45 70.26,47.92 70.83,47.92H77.08C77.66,47.92 78.13,47.45 78.13,46.88C78.13,46.3 77.66,45.83 77.08,45.83H70.83ZM69.79,53.13C69.79,52.55 70.26,52.08 70.83,52.08H77.08C77.66,52.08 78.13,52.55 78.13,53.13C78.13,53.7 77.66,54.17 77.08,54.17H70.83C70.26,54.17 69.79,53.7 69.79,53.13ZM60.42,33.33C59.84,33.33 59.38,33.8 59.38,34.38C59.38,34.95 59.84,35.42 60.42,35.42L66.67,35.42C67.24,35.42 67.71,34.95 67.71,34.38C67.71,33.8 67.24,33.33 66.67,33.33L60.42,33.33ZM48.96,34.38C48.96,33.8 49.42,33.33 50,33.33L56.25,33.33C56.83,33.33 57.29,33.8 57.29,34.38C57.29,34.95 56.83,35.42 56.25,35.42L50,35.42C49.42,35.42 48.96,34.95 48.96,34.38ZM60.42,39.58C59.84,39.58 59.38,40.05 59.38,40.63C59.38,41.2 59.84,41.67 60.42,41.67L66.67,41.67C67.24,41.67 67.71,41.2 67.71,40.63C67.71,40.05 67.24,39.58 66.67,39.58L60.42,39.58ZM59.38,46.88C59.38,46.3 59.84,45.83 60.42,45.83H66.67C67.24,45.83 67.71,46.3 67.71,46.88C67.71,47.45 67.24,47.92 66.67,47.92H60.42C59.84,47.92 59.38,47.45 59.38,46.88ZM60.42,52.08C59.84,52.08 59.38,52.55 59.38,53.13C59.38,53.7 59.84,54.17 60.42,54.17H66.67C67.24,54.17 67.71,53.7 67.71,53.13C67.71,52.55 67.24,52.08 66.67,52.08H60.42Z" />
</vector>
+5
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">By declining, your personal items will stay in your account, but youll lose access to shared items and organization features.\n\nContact your admin to regain access.</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>