From e415145c536e26bb34a69562cd1e9ef6a2100491 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:35:15 -0500 Subject: [PATCH] PM-29491: Implement LeaveOrganizationScreen (#6253) Co-authored-by: Claude Sonnet 4.5 --- .../model/LeaveOrganizationResult.kt | 2 +- .../ui/platform/model/SnackbarRelay.kt | 1 + .../LeaveOrganizationNavigation.kt | 82 ++++++ .../LeaveOrganizationScreen.kt | 232 +++++++++++++++++ .../LeaveOrganizationViewModel.kt | 209 +++++++++++++++ .../handlers/LeaveOrganizationHandler.kt | 49 ++++ .../LeaveOrganizationScreenTest.kt | 191 ++++++++++++++ .../LeaveOrganizationViewModelTest.kt | 239 ++++++++++++++++++ .../res/drawable/ill_leave_organization.xml | 127 ++++++++++ ui/src/main/res/values/strings.xml | 5 + 10 files changed, 1136 insertions(+), 1 deletion(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationNavigation.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationScreen.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationViewModel.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/handlers/LeaveOrganizationHandler.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationScreenTest.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationViewModelTest.kt create mode 100644 ui/src/main/res/drawable/ill_leave_organization.xml diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/LeaveOrganizationResult.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/LeaveOrganizationResult.kt index f27e7ec9cd..851e4aa950 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/LeaveOrganizationResult.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/LeaveOrganizationResult.kt @@ -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 { /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/SnackbarRelay.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/SnackbarRelay.kt index 1954676cc3..82ef41c2d7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/SnackbarRelay.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/SnackbarRelay.kt @@ -24,4 +24,5 @@ enum class SnackbarRelay { LOGINS_IMPORTED, SEND_DELETED, SEND_UPDATED, + LEFT_ORGANIZATION, } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationNavigation.kt new file mode 100644 index 0000000000..d64c97aa18 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationNavigation.kt @@ -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() + 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 { + 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, + ) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationScreen.kt new file mode 100644 index 0000000000..f7eb0529cc --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationScreen.kt @@ -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(), + ) + } + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationViewModel.kt new file mode 100644 index 0000000000..d443d085d1 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationViewModel.kt @@ -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, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + 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() + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/handlers/LeaveOrganizationHandler.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/handlers/LeaveOrganizationHandler.kt new file mode 100644 index 0000000000..8dee9caca5 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/handlers/LeaveOrganizationHandler.kt @@ -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) +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationScreenTest.kt new file mode 100644 index 0000000000..9151bb180c --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationScreenTest.kt @@ -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() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val viewModel = mockk(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 you’ll " + + "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, +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationViewModelTest.kt new file mode 100644 index 0000000000..f6f8b60180 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/leaveorganization/LeaveOrganizationViewModelTest.kt @@ -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 = 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, + ), + ), +) diff --git a/ui/src/main/res/drawable/ill_leave_organization.xml b/ui/src/main/res/drawable/ill_leave_organization.xml new file mode 100644 index 0000000000..63da0cee3d --- /dev/null +++ b/ui/src/main/res/drawable/ill_leave_organization.xml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 260e739fc1..299cc0a769 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -496,6 +496,10 @@ Scanning will happen automatically. Remove master password Leave organization Leave %1$s? + Are you sure you want to leave %1$s? + Leave %1$s + By declining, your personal items will stay in your account, but you’ll lose access to shared items and organization features.\n\nContact your admin to regain access. + How to manage My vault Authenticate WebAuthn Return to app This organization has an enterprise policy that will automatically enroll you in password reset. Enrollment will allow organization administrators to change your master password. @@ -1161,4 +1165,5 @@ Do you want to switch to this account? Use your device’s lock method to unlock the app Loading vault data… Resending + You left the organization