From bee09de972c3870de0d54a0067996be473ec55c7 Mon Sep 17 00:00:00 2001 From: aj-rosado <109146700+aj-rosado@users.noreply.github.com> Date: Thu, 17 Apr 2025 20:20:50 +0100 Subject: [PATCH] [PM-18936] Show key connector domain (#5034) --- .../data/auth/repository/AuthRepository.kt | 18 ++ .../auth/repository/AuthRepositoryImpl.kt | 47 ++++ .../model/LeaveOrganizationResult.kt | 18 ++ .../data/auth/repository/model/LoginResult.kt | 7 + .../auth/repository/model/LogoutReason.kt | 5 + .../auth/repository/model/Organization.kt | 2 + .../util/SyncResponseJsonExtensions.kt | 1 + .../EnterpriseSignOnScreen.kt | 24 ++ .../EnterpriseSignOnViewModel.kt | 57 +++++ .../ui/auth/feature/login/LoginViewModel.kt | 3 + .../LoginWithDeviceViewModel.kt | 3 + .../removepassword/RemovePasswordScreen.kt | 84 ++++++- .../removepassword/RemovePasswordViewModel.kt | 122 +++++++++- .../twofactorlogin/TwoFactorLoginViewModel.kt | 3 + app/src/main/res/values/strings.xml | 5 + .../auth/repository/AuthRepositoryTest.kt | 217 +++++++++++++++++- .../util/AuthDiskSourceExtensionsTest.kt | 6 + .../util/SyncResponseJsonExtensionsTest.kt | 7 +- .../util/UserStateJsonExtensionsTest.kt | 16 ++ .../EnterpriseSignOnScreenTest.kt | 73 ++++++ .../EnterpriseSignOnViewModelTest.kt | 157 +++++++++++++ .../RemovePasswordScreenTest.kt | 72 ++++++ .../RemovePasswordViewModelTest.kt | 105 ++++++++- .../feature/rootnav/RootNavViewModelTest.kt | 1 + .../AccountSecurityViewModelTest.kt | 4 + .../addedit/VaultAddEditViewModelTest.kt | 1 + .../addedit/util/CipherViewExtensionsTest.kt | 1 + .../feature/item/VaultItemViewModelTest.kt | 1 + .../VaultMoveToOrganizationViewModelTest.kt | 3 + .../VaultMoveToOrganizationExtensionsTest.kt | 3 + .../vault/feature/vault/VaultViewModelTest.kt | 3 + .../vault/util/UserStateExtensionsTest.kt | 11 + .../api/AuthenticatedOrganizationApi.kt | 9 + .../network/service/OrganizationService.kt | 7 + .../service/OrganizationServiceImpl.kt | 5 + .../service/OrganizationServiceTest.kt | 16 ++ 36 files changed, 1100 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/LeaveOrganizationResult.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index e950d6cf8b..0eaf38db7f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult +import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult @@ -243,6 +244,16 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager { orgIdentifier: String?, ): LoginResult + /** + * Continue the previously halted login attempt. + */ + suspend fun continueKeyConnectorLogin(): LoginResult + + /** + * Cancel the previously halted login attempt. + */ + fun cancelKeyConnectorLogin() + /** * Log out the current user. */ @@ -422,4 +433,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager { * Update the value of the onboarding status for the user. */ fun setOnboardingStatus(status: OnboardingStatus) + + /** + * Leaves the organization that matches the given [organizationId] + */ + suspend fun leaveOrganization( + organizationId: String, + ): LeaveOrganizationResult } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index a7a21bec97..6c231c64e0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -59,6 +59,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult +import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult @@ -238,6 +239,8 @@ class AuthRepositoryImpl( */ private var passwordsToCheckMap = mutableMapOf() + private var keyConnectorResponse: GetTokenResponseJson.Success? = null + override var twoFactorResponse: GetTokenResponseJson.TwoFactorRequired? = null override val ssoOrganizationIdentifier: String? get() = organizationIdentifier @@ -715,6 +718,25 @@ class AuthRepositoryImpl( error = MissingPropertyException("Identity Token Auth Model"), ) + override suspend fun continueKeyConnectorLogin(): LoginResult { + val response = keyConnectorResponse ?: return LoginResult.Error( + errorMessage = null, + error = MissingPropertyException("Key Connector Response"), + ) + return handleLoginCommonSuccess( + loginResponse = response, + email = rememberedEmailAddress.orEmpty(), + orgIdentifier = rememberedOrgIdentifier, + password = null, + deviceData = null, + userConfirmedKeyConnector = true, + ) + } + + override fun cancelKeyConnectorLogin() { + keyConnectorResponse = null + } + override suspend fun login( email: String, ssoCode: String, @@ -1405,6 +1427,12 @@ class AuthRepositoryImpl( } } + override suspend fun leaveOrganization(organizationId: String): LeaveOrganizationResult = + organizationService.leaveOrganization(organizationId).fold( + onSuccess = { LeaveOrganizationResult.Success }, + onFailure = { LeaveOrganizationResult.Error(error = it) }, + ) + @Suppress("CyclomaticComplexMethod") private suspend fun validatePasswordAgainstPolicy( password: String, @@ -1552,6 +1580,7 @@ class AuthRepositoryImpl( * A helper function to extract the common logic of logging in through * any of the available methods. */ + @Suppress("LongMethod") private suspend fun loginCommon( email: String, password: String? = null, @@ -1603,6 +1632,7 @@ class AuthRepositoryImpl( password = password, deviceData = deviceData, orgIdentifier = orgIdentifier, + userConfirmedKeyConnector = false, ) is GetTokenResponseJson.Invalid -> { @@ -1636,6 +1666,7 @@ class AuthRepositoryImpl( password: String?, deviceData: DeviceDataModel?, orgIdentifier: String?, + userConfirmedKeyConnector: Boolean, ): LoginResult = userStateTransaction { val userStateJson = loginResponse.toUserState( previousUserState = authDiskSource.userState, @@ -1665,6 +1696,21 @@ class AuthRepositoryImpl( deviceData = deviceData, ) } else if (keyConnectorUrl != null && orgIdentifier != null) { + val isNewKeyConnectorUser = + loginResponse.userDecryptionOptions?.hasMasterPassword == false && + loginResponse.key == null && + loginResponse.privateKey == null + val isNotConfirmed = !userConfirmedKeyConnector + + // If a new KeyConnector user is logging in for the first time, + // we should ask him to confirm the domain + if (isNewKeyConnectorUser && isNotConfirmed) { + keyConnectorResponse = loginResponse + return LoginResult.ConfirmKeyConnectorDomain( + domain = keyConnectorUrl, + ) + } + unlockVaultWithKeyConnectorOnLoginSuccess( profile = profile, keyConnectorUrl = keyConnectorUrl, @@ -1738,6 +1784,7 @@ class AuthRepositoryImpl( resendEmailRequestJson = null twoFactorDeviceData = null resendNewDeviceOtpRequestJson = null + keyConnectorResponse = null settingsRepository.setDefaultsIfNecessary(userId = userId) settingsRepository.storeUserHasLoggedInValue(userId) vaultRepository.syncIfNecessary() diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/LeaveOrganizationResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/LeaveOrganizationResult.kt new file mode 100644 index 0000000000..f27e7ec9cd --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/LeaveOrganizationResult.kt @@ -0,0 +1,18 @@ +package com.x8bit.bitwarden.data.auth.repository.model + +/** + * Models result of deleting an account. + */ +sealed class LeaveOrganizationResult { + /** + * Leave organization succeeded. + */ + data object Success : LeaveOrganizationResult() + + /** + * There was an error leaving the organization. + */ + data class Error( + val error: Throwable?, + ) : LeaveOrganizationResult() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/LoginResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/LoginResult.kt index 807ea1369a..729aaea965 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/LoginResult.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/LoginResult.kt @@ -19,6 +19,13 @@ sealed class LoginResult { */ data object TwoFactorRequired : LoginResult() + /** + * User should confirm KeyConnector domain + */ + data class ConfirmKeyConnectorDomain( + val domain: String, + ) : LoginResult() + /** * There was an error logging in. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/LogoutReason.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/LogoutReason.kt index 6ec03086ae..08542c6176 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/LogoutReason.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/LogoutReason.kt @@ -68,4 +68,9 @@ sealed class LogoutReason { * unsuccessfully too many times. */ data object TooManyUnlockAttempts : LogoutReason() + + /** + * Indicates that the logout is happening because the left the organization. + */ + data object LeftOrganization : LogoutReason() } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/Organization.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/Organization.kt index 21c517c782..48c8f1350a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/Organization.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/Organization.kt @@ -11,6 +11,7 @@ import com.bitwarden.network.model.OrganizationType * own password. * @property shouldUseKeyConnector Indicates that the organization uses a key connector. * @property role The user's role in the organization. + * @property keyConnectorUrl The key connector domain (if applicable). */ data class Organization( val id: String, @@ -18,4 +19,5 @@ data class Organization( val shouldManageResetPassword: Boolean, val shouldUseKeyConnector: Boolean, val role: OrganizationType, + val keyConnectorUrl: String?, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt index 5f8a6cc410..ba2dde0e8c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt @@ -22,6 +22,7 @@ fun SyncResponseJson.Profile.Organization.toOrganization(): Organization = shouldUseKeyConnector = this.shouldUseKeyConnector, role = this.type, shouldManageResetPassword = this.permissions.shouldManageResetPassword, + keyConnectorUrl = this.keyConnectorUrl, ) /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreen.kt index 827ab4e579..4523fb2148 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreen.kt @@ -31,6 +31,7 @@ import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField import com.x8bit.bitwarden.ui.platform.components.model.CardStyle import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold @@ -77,6 +78,12 @@ fun EnterpriseSignOnScreen( EnterpriseSignOnDialogs( dialogState = state.dialogState, + onConfirmKeyConnectorDomain = remember(viewModel) { + { viewModel.trySendAction(EnterpriseSignOnAction.ConfirmKeyConnectorDomainClick) } + }, + onDismissKeyConnectorDomain = remember(viewModel) { + { viewModel.trySendAction(EnterpriseSignOnAction.CancelKeyConnectorDomainClick) } + }, onDismissRequest = remember(viewModel) { { viewModel.trySendAction(EnterpriseSignOnAction.DialogDismiss) } }, @@ -163,6 +170,8 @@ private fun EnterpriseSignOnScreenContent( private fun EnterpriseSignOnDialogs( dialogState: EnterpriseSignOnState.DialogState?, onDismissRequest: () -> Unit, + onConfirmKeyConnectorDomain: () -> Unit, + onDismissKeyConnectorDomain: () -> Unit, ) { when (dialogState) { is EnterpriseSignOnState.DialogState.Error -> { @@ -178,6 +187,21 @@ private fun EnterpriseSignOnDialogs( BitwardenLoadingDialog(text = dialogState.message()) } + is EnterpriseSignOnState.DialogState.KeyConnectorDomain -> { + BitwardenTwoButtonDialog( + title = stringResource(R.string.confirm_key_connector_domain), + message = stringResource( + R.string.please_confirm_domain_with_admin, + dialogState.keyConnectorDomain, + ), + confirmButtonText = stringResource(R.string.confirm), + dismissButtonText = stringResource(R.string.cancel), + onConfirmClick = onConfirmKeyConnectorDomain, + onDismissRequest = onDismissKeyConnectorDomain, + onDismissClick = onDismissKeyConnectorDomain, + ) + } + null -> Unit } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt index b6cb1e7566..dfe3101a63 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModel.kt @@ -131,6 +131,14 @@ class EnterpriseSignOnViewModel @Inject constructor( is EnterpriseSignOnAction.Internal.OnVerifiedOrganizationDomainSsoDetailsReceive -> { handleOnVerifiedOrganizationDomainSsoDetailsReceive(action) } + + EnterpriseSignOnAction.CancelKeyConnectorDomainClick -> { + handleCancelKeyConnectorDomainClick() + } + + EnterpriseSignOnAction.ConfirmKeyConnectorDomainClick -> { + handleConfirmKeyConnectorDomainClick() + } } } @@ -199,6 +207,12 @@ class EnterpriseSignOnViewModel @Inject constructor( ?: R.string.login_sso_error.asText(), ) } + + is LoginResult.ConfirmKeyConnectorDomain -> { + showKeyConnectorDomainConfirmation( + keyConnectorDomain = loginResult.domain, + ) + } } } @@ -517,6 +531,29 @@ class EnterpriseSignOnViewModel @Inject constructor( ) } } + + private fun showKeyConnectorDomainConfirmation(keyConnectorDomain: String) { + mutableStateFlow.update { + it.copy( + dialogState = EnterpriseSignOnState.DialogState.KeyConnectorDomain( + keyConnectorDomain = keyConnectorDomain, + ), + ) + } + } + + private fun handleConfirmKeyConnectorDomainClick() { + showLoading() + viewModelScope.launch { + val result = authRepository.continueKeyConnectorLogin() + sendAction(EnterpriseSignOnAction.Internal.OnLoginResult(result)) + } + } + + private fun handleCancelKeyConnectorDomainClick() { + mutableStateFlow.update { it.copy(dialogState = null) } + authRepository.cancelKeyConnectorLogin() + } } /** @@ -550,6 +587,14 @@ data class EnterpriseSignOnState( data class Loading( val message: Text, ) : DialogState() + + /** + * Represents a dialog indicating that the user needs to confirm the [keyConnectorDomain]. + */ + @Parcelize + data class KeyConnectorDomain( + val keyConnectorDomain: String, + ) : DialogState() } } @@ -605,6 +650,18 @@ sealed class EnterpriseSignOnAction { */ data object LogInClick : EnterpriseSignOnAction() + /** + * Indicates that the confirm button has been clicked + * on the KeyConnector confirmation dialog. + */ + data object ConfirmKeyConnectorDomainClick : EnterpriseSignOnAction() + + /** + * Indicates that the cancel button has been clicked + * on the KeyConnector confirmation dialog. + */ + data object CancelKeyConnectorDomainClick : EnterpriseSignOnAction() + /** * Indicates that the organization identifier input has changed. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt index 80834d3d80..d162cb065a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt @@ -169,6 +169,9 @@ class LoginViewModel @Inject constructor( ) } + // NO-OP: This result should not be possible here + is LoginResult.ConfirmKeyConnectorDomain -> Unit + is LoginResult.Error -> { mutableStateFlow.update { it.copy( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt index 5b8c4eeef2..09b57061ae 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt @@ -283,6 +283,9 @@ class LoginWithDeviceViewModel @Inject constructor( ) } } + + // NO-OP: This result should not be possible here + is LoginResult.ConfirmKeyConnectorDomain -> Unit } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordScreen.kt index 947555929f..5cb5aa93ac 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordScreen.kt @@ -28,8 +28,10 @@ import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButton import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField import com.x8bit.bitwarden.ui.platform.components.model.CardStyle import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold @@ -49,6 +51,11 @@ fun RemovePasswordScreen( onDismissRequest = remember(viewModel) { { viewModel.trySendAction(RemovePasswordAction.DialogDismiss) } }, + onConfirmLeaveClick = remember(viewModel) { + { + viewModel.trySendAction(RemovePasswordAction.ConfirmLeaveOrganizationClick) + } + }, ) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) @@ -72,16 +79,21 @@ fun RemovePasswordScreen( onInputChanged = remember(viewModel) { { viewModel.trySendAction(RemovePasswordAction.InputChanged(it)) } }, + onLeaveOrganizationClick = remember(viewModel) { + { viewModel.trySendAction(RemovePasswordAction.LeaveOrganizationClick) } + }, modifier = Modifier.fillMaxSize(), ) } } @Composable +@Suppress("LongMethod") private fun RemovePasswordScreenContent( state: RemovePasswordState, onContinueClick: () -> Unit, onInputChanged: (String) -> Unit, + onLeaveOrganizationClick: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -98,6 +110,42 @@ private fun RemovePasswordScreenContent( .standardHorizontalMargin() .fillMaxWidth(), ) + + Spacer(modifier = Modifier.height(height = 12.dp)) + Text( + text = state.labelOrg(), + style = BitwardenTheme.typography.bodyMedium, + color = BitwardenTheme.colorScheme.text.primary, + modifier = Modifier + .standardHorizontalMargin() + .fillMaxWidth(), + ) + Text( + text = state.orgName?.invoke().orEmpty(), + style = BitwardenTheme.typography.bodyMedium, + color = BitwardenTheme.colorScheme.text.secondary, + modifier = Modifier + .standardHorizontalMargin() + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(height = 12.dp)) + Text( + text = state.labelDomain(), + style = BitwardenTheme.typography.bodyMedium, + color = BitwardenTheme.colorScheme.text.primary, + modifier = Modifier + .standardHorizontalMargin() + .fillMaxWidth(), + ) + + Text( + text = state.domainName?.invoke().orEmpty(), + style = BitwardenTheme.typography.bodyMedium, + color = BitwardenTheme.colorScheme.text.secondary, + modifier = Modifier + .standardHorizontalMargin() + .fillMaxWidth(), + ) Spacer(modifier = Modifier.height(16.dp)) BitwardenPasswordField( @@ -124,6 +172,17 @@ private fun RemovePasswordScreenContent( .fillMaxWidth(), ) + Spacer(modifier = Modifier.height(12.dp)) + + BitwardenOutlinedButton( + label = stringResource(id = R.string.leave_organization), + onClick = onLeaveOrganizationClick, + modifier = Modifier + .testTag("LeaveOrganizationButton") + .standardHorizontalMargin() + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.navigationBarsPadding()) } @@ -133,6 +192,7 @@ private fun RemovePasswordScreenContent( private fun RemovePasswordDialogs( dialogState: RemovePasswordState.DialogState?, onDismissRequest: () -> Unit, + onConfirmLeaveClick: () -> Unit, ) { when (dialogState) { is RemovePasswordState.DialogState.Error -> { @@ -147,6 +207,18 @@ private fun RemovePasswordDialogs( BitwardenLoadingDialog(text = dialogState.title()) } + is RemovePasswordState.DialogState.LeaveConfirmationPrompt -> { + BitwardenTwoButtonDialog( + title = stringResource(id = R.string.leave_organization), + message = dialogState.message.invoke(), + confirmButtonText = stringResource(id = R.string.confirm), + dismissButtonText = stringResource(id = R.string.cancel), + onConfirmClick = onConfirmLeaveClick, + onDismissClick = onDismissRequest, + onDismissRequest = onDismissRequest, + ) + } + null -> Unit } } @@ -158,11 +230,21 @@ private fun RemovePasswordScreen_preview() { RemovePasswordScreenContent( state = RemovePasswordState( input = "", - description = "Organization is using SSO with a self-hosted key server.".asText(), + description = + ("A master password is no longer required " + + "for members of the following organization. " + + "Please confirm the domain below with your " + + "organization administrator.").asText(), + labelOrg = "Organization name".asText(), + orgName = "Organization name".asText(), + labelDomain = "Key Connector domain".asText(), + domainName = "http://localhost:8080".asText(), dialogState = null, + organizationId = null, ), onContinueClick = { }, onInputChanged = { }, + onLeaveOrganizationClick = { }, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordViewModel.kt index 7babef272b..63f2fbac58 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordViewModel.kt @@ -3,12 +3,15 @@ package com.x8bit.bitwarden.ui.auth.feature.removepassword import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.x8bit.bitwarden.R -import com.x8bit.bitwarden.data.auth.repository.AuthRepository -import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult -import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult +import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason +import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -26,18 +29,20 @@ class RemovePasswordViewModel @Inject constructor( savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: run { - val orgName = authRepository.userStateFlow.value + val org = authRepository.userStateFlow.value ?.activeAccount ?.organizations ?.firstOrNull { it.shouldUseKeyConnector } - ?.name - .orEmpty() + RemovePasswordState( input = "", - description = R.string - .organization_is_using_sso_with_a_self_hosted_key_server - .asText(orgName), + description = R.string.password_no_longer_required_confirm_domain.asText(), + labelOrg = R.string.key_connector_organization.asText(), + orgName = org?.name?.asText(), + labelDomain = R.string.key_connector_domain.asText(), + domainName = org?.keyConnectorUrl?.asText(), dialogState = null, + organizationId = org?.id.orNullIfBlank(), ) }, ) { @@ -46,9 +51,29 @@ class RemovePasswordViewModel @Inject constructor( RemovePasswordAction.ContinueClick -> handleContinueClick() is RemovePasswordAction.InputChanged -> handleInputChanged(action) RemovePasswordAction.DialogDismiss -> handleDialogDismiss() + RemovePasswordAction.LeaveOrganizationClick -> handleLeaveOrganizationClick() + + is RemovePasswordAction.ConfirmLeaveOrganizationClick -> { + handleConfirmLeaveOrganizationResult() + } + is RemovePasswordAction.Internal.ReceiveRemovePasswordResult -> { handleReceiveRemovePasswordResult(action) } + + is RemovePasswordAction.Internal.ReceiveLeaveOrganizationResult -> { + handleReceiveLeaveOrganizationResult(action) + } + } + } + + private fun handleLeaveOrganizationClick() { + mutableStateFlow.update { + it.copy( + dialogState = RemovePasswordState.DialogState.LeaveConfirmationPrompt( + message = R.string.leave_organization_name.asText(state.orgName ?: ""), + ), + ) } } @@ -108,6 +133,53 @@ class RemovePasswordViewModel @Inject constructor( } } } + + private fun handleConfirmLeaveOrganizationResult() { + mutableStateFlow.update { + it.copy( + dialogState = RemovePasswordState.DialogState.Loading( + title = R.string.loading.asText(), + ), + ) + } + + viewModelScope.launch { + val result = + authRepository.leaveOrganization(organizationId = state.organizationId.orEmpty()) + sendAction( + RemovePasswordAction.Internal.ReceiveLeaveOrganizationResult( + result = result, + ), + ) + } + } + + private fun handleReceiveLeaveOrganizationResult( + action: RemovePasswordAction.Internal.ReceiveLeaveOrganizationResult, + ) { + when (val result = action.result) { + is LeaveOrganizationResult.Error -> { + mutableStateFlow.update { + it.copy( + dialogState = RemovePasswordState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + error = result.error, + ), + ) + } + } + + LeaveOrganizationResult.Success -> { + mutableStateFlow.update { + it.copy(dialogState = null) + } + authRepository.logout( + reason = LogoutReason.LeftOrganization, + ) + } + } + } } /** @@ -117,7 +189,12 @@ class RemovePasswordViewModel @Inject constructor( data class RemovePasswordState( val input: String, val description: Text, + val labelOrg: Text, + val orgName: Text?, + val labelDomain: Text, + val domainName: Text?, val dialogState: DialogState?, + val organizationId: String?, ) : Parcelable { /** * Represents the current state of any dialogs on the screen. @@ -139,6 +216,14 @@ data class RemovePasswordState( */ @Parcelize data class Loading(val title: Text) : DialogState() + + /** + * Displays a prompt to confirm leave organization. + */ + @Parcelize + data class LeaveConfirmationPrompt( + val message: Text, + ) : DialogState() } } @@ -151,6 +236,16 @@ sealed class RemovePasswordAction { */ data object ContinueClick : RemovePasswordAction() + /** + * Indicates that the user has clicked the leave organization button + */ + data object LeaveOrganizationClick : RemovePasswordAction() + + /** + * The user clicked confirm when prompted to leave an organization. + */ + data object ConfirmLeaveOrganizationClick : RemovePasswordAction() + /** * The user has modified the input. */ @@ -173,5 +268,12 @@ sealed class RemovePasswordAction { data class ReceiveRemovePasswordResult( val result: RemovePasswordResult, ) : Internal() + + /** + * Indicates that a remove password result has been received. + */ + data class ReceiveLeaveOrganizationResult( + val result: LeaveOrganizationResult, + ) : Internal() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt index f4b0ee85c1..8be2dd8cf1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt @@ -350,6 +350,9 @@ class TwoFactorLoginViewModel @Inject constructor( ) } } + + // NO-OP: This result should not be possible here + is LoginResult.ConfirmKeyConnectorDomain -> Unit } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 81592e5995..55454e6e1e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1248,4 +1248,9 @@ Do you want to switch to this account? Do you really want to delete this log? Delete logs Do you really want to delete all recorded logs? + Confirm Key Connector domain + Key Connector domain: + Organization: + A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator. + Please confirm the domain below with your organization administrator.\n\nKey Connector domain:\n%1$s diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 370c7a3d08..a3993b2016 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -82,6 +82,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult +import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult @@ -155,6 +156,7 @@ import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow import java.time.ZonedDateTime import javax.net.ssl.SSLHandshakeException @@ -3284,6 +3286,7 @@ class AuthRepositoryTest { environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US, ) } returns SINGLE_USER_STATE_1 + repository.rememberedOrgIdentifier = ORGANIZATION_IDENTIFIER val result = repository.login( email = EMAIL, @@ -3294,7 +3297,10 @@ class AuthRepositoryTest { organizationIdentifier = ORGANIZATION_IDENTIFIER, ) - assertEquals(LoginResult.Error(errorMessage = null, error = error), result) + assertEquals(LoginResult.ConfirmKeyConnectorDomain(keyConnectorUrl), result) + + val continueResult = repository.continueKeyConnectorLogin() + assertEquals(LoginResult.Error(errorMessage = null, error = error), continueResult) fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = null) fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = null) coVerify(exactly = 1) { @@ -3385,6 +3391,7 @@ class AuthRepositoryTest { environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US, ) } returns SINGLE_USER_STATE_1 + repository.rememberedOrgIdentifier = ORGANIZATION_IDENTIFIER val result = repository.login( email = EMAIL, ssoCode = SSO_CODE, @@ -3394,7 +3401,10 @@ class AuthRepositoryTest { organizationIdentifier = ORGANIZATION_IDENTIFIER, ) - assertEquals(LoginResult.Success, result) + assertEquals(LoginResult.ConfirmKeyConnectorDomain(keyConnectorUrl), result) + + val continueResult = repository.continueKeyConnectorLogin() + assertEquals(LoginResult.Success, continueResult) assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value) fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = PRIVATE_KEY) fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = ENCRYPTED_USER_KEY) @@ -3437,6 +3447,155 @@ class AuthRepositoryTest { } } + @Test + @Suppress("MaxLineLength") + fun `SSO login get token succeeds with key connector when key, privateKey and master password are null should return ConfirmKeyConnectorDomain`() = + runTest { + val keyConnectorUrl = "www.example.com" + val successResponse = GET_TOKEN_RESPONSE_SUCCESS.copy( + keyConnectorUrl = keyConnectorUrl, + userDecryptionOptions = USER_DECRYPTION_OPTIONS.copy( + hasMasterPassword = false, + trustedDeviceUserDecryptionOptions = null, + ), + key = null, + privateKey = null, + ) + coEvery { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + } returns successResponse.asSuccess() + every { + successResponse.toUserState( + previousUserState = null, + environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US, + ) + } returns SINGLE_USER_STATE_1 + val result = repository.login( + email = EMAIL, + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + captchaToken = null, + organizationIdentifier = ORGANIZATION_IDENTIFIER, + ) + assertEquals(LoginResult.ConfirmKeyConnectorDomain(keyConnectorUrl), result) + } + + @Test + @Suppress("MaxLineLength") + fun `ContinueKeyConnectorLogin should return Success and unlock the vault after login returns ConfirmKeyConnector`() = + runTest { + val keyConnectorUrl = "www.example.com" + val successResponse = GET_TOKEN_RESPONSE_SUCCESS.copy( + keyConnectorUrl = keyConnectorUrl, + userDecryptionOptions = USER_DECRYPTION_OPTIONS.copy( + hasMasterPassword = false, + trustedDeviceUserDecryptionOptions = null, + ), + key = null, + privateKey = null, + ) + + val masterKey = "masterKey" + val keyConnectorResponse = mockk { + every { + this@mockk.keys + } returns RsaKeyPair(public = PUBLIC_KEY, private = PRIVATE_KEY) + every { this@mockk.masterKey } returns masterKey + every { this@mockk.encryptedUserKey } returns ENCRYPTED_USER_KEY + } + + coEvery { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + } returns successResponse.asSuccess() + coEvery { vaultRepository.syncIfNecessary() } just runs + every { + successResponse.toUserState( + previousUserState = null, + environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US, + ) + } returns SINGLE_USER_STATE_1 + + coEvery { + keyConnectorManager.migrateNewUserToKeyConnector( + url = keyConnectorUrl, + accessToken = ACCESS_TOKEN, + kdfType = PROFILE_1.kdfType!!, + kdfIterations = PROFILE_1.kdfIterations, + kdfMemory = PROFILE_1.kdfMemory, + kdfParallelism = PROFILE_1.kdfParallelism, + organizationIdentifier = ORGANIZATION_IDENTIFIER, + ) + } returns keyConnectorResponse.asSuccess() + + coEvery { + vaultRepository.unlockVault( + userId = USER_ID_1, + email = EMAIL, + kdf = ACCOUNT_1.profile.toSdkParams(), + privateKey = PRIVATE_KEY, + organizationKeys = null, + initUserCryptoMethod = InitUserCryptoMethod.KeyConnector( + masterKey = masterKey, + userKey = ENCRYPTED_USER_KEY, + ), + ) + } returns VaultUnlockResult.Success + + repository.rememberedOrgIdentifier = ORGANIZATION_IDENTIFIER + + val loginResult = repository.login( + email = EMAIL, + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + captchaToken = null, + organizationIdentifier = ORGANIZATION_IDENTIFIER, + ) + assertEquals(LoginResult.ConfirmKeyConnectorDomain(keyConnectorUrl), loginResult) + + val result = repository.continueKeyConnectorLogin() + assertEquals(LoginResult.Success, result) + assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value) + fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = "privateKey") + fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = "encryptedUserKey") + coVerify(exactly = 1) { + identityService.getToken( + email = EMAIL, + authModel = IdentityTokenAuthModel.SingleSignOn( + ssoCode = SSO_CODE, + ssoCodeVerifier = SSO_CODE_VERIFIER, + ssoRedirectUri = SSO_REDIRECT_URI, + ), + captchaToken = null, + uniqueAppId = UNIQUE_APP_ID, + ) + vaultRepository.syncIfNecessary() + } + assertEquals(SINGLE_USER_STATE_1, fakeAuthDiskSource.userState) + verify(exactly = 1) { + settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) + } + } + @Test @Suppress("MaxLineLength") fun `login with device get token succeeds should return Success, update AuthState, update stored keys, and sync with UserKey`() = @@ -6598,6 +6757,60 @@ class AuthRepositoryTest { assertNull(fakeAuthDiskSource.getOnboardingStatus(USER_ID_1)) } + @Test + fun `cancelKeyConnectorLogin should clear keyConnectorResponse`() = + runTest { + assertDoesNotThrow { repository.cancelKeyConnectorLogin() } + } + + @Test + fun `continueKeyConnectorLogin returns error if keyConnectorResponse is null`() = + runTest { + val continueResult = repository.continueKeyConnectorLogin() + assertEquals( + LoginResult.Error( + errorMessage = null, + error = MissingPropertyException("Key Connector Response"), + ), + continueResult, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `leaveOrganization should return success when organizationService leaveOrganization succeeds`() = + runTest { + coEvery { + organizationService.leaveOrganization(any()) + } returns Unit.asSuccess() + + val continueResult = repository.leaveOrganization("mockId-1") + coVerify { + organizationService.leaveOrganization(any()) + } + assertEquals( + LeaveOrganizationResult.Success, continueResult, + ) + } + + @Test + fun `leaveOrganization should return error when organizationService leaveOrganization fails`() = + runTest { + val error = Throwable("Fail") + coEvery { + organizationService.leaveOrganization(any()) + } returns error.asFailure() + + val continueResult = repository.leaveOrganization("mockId-1") + coVerify { + organizationService.leaveOrganization(any()) + } + assertEquals( + LeaveOrganizationResult.Error(error = error), + continueResult, + ) + } + companion object { private const val UNIQUE_APP_ID = "testUniqueAppId" private const val NAME = "Example Name" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/AuthDiskSourceExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/AuthDiskSourceExtensionsTest.kt index f889084843..c94240b0c6 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/AuthDiskSourceExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/AuthDiskSourceExtensionsTest.kt @@ -194,6 +194,7 @@ class AuthDiskSourceExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = "mockKeyConnectorUrl-1", ), ), ), @@ -206,6 +207,7 @@ class AuthDiskSourceExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = "mockKeyConnectorUrl-2", ), ), ), @@ -218,6 +220,7 @@ class AuthDiskSourceExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = "mockKeyConnectorUrl-3", ), ), ), @@ -365,6 +368,7 @@ class AuthDiskSourceExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = "mockKeyConnectorUrl-1", ), ), ), @@ -396,6 +400,7 @@ class AuthDiskSourceExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = "mockKeyConnectorUrl-1", ), ), ), @@ -408,6 +413,7 @@ class AuthDiskSourceExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = "mockKeyConnectorUrl-2", ), ), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensionsTest.kt index 7576b035a7..7d2590388d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensionsTest.kt @@ -23,6 +23,7 @@ class SyncResponseJsonExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = "mockKeyConnectorUrl-1", ), createMockOrganization(number = 1).toOrganization(), ) @@ -38,6 +39,7 @@ class SyncResponseJsonExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = true, role = OrganizationType.ADMIN, + keyConnectorUrl = "mockKeyConnectorUrl-1", ), Organization( id = "mockId-2", @@ -45,10 +47,13 @@ class SyncResponseJsonExtensionsTest { shouldManageResetPassword = true, shouldUseKeyConnector = false, role = OrganizationType.USER, + keyConnectorUrl = "mockKeyConnectorUrl-2", ), ), listOf( - createMockOrganization(number = 1).copy(shouldUseKeyConnector = true), + createMockOrganization(number = 1).copy( + shouldUseKeyConnector = true, + ), createMockOrganization(number = 2, shouldManageResetPassword = true) .copy(type = OrganizationType.USER), ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt index e2e6c4cb05..4bc0d17a98 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt @@ -366,6 +366,7 @@ class UserStateJsonExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), isBiometricsEnabled = false, @@ -430,6 +431,7 @@ class UserStateJsonExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), ), @@ -474,6 +476,7 @@ class UserStateJsonExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), isBiometricsEnabled = true, @@ -534,6 +537,7 @@ class UserStateJsonExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), ), @@ -579,6 +583,7 @@ class UserStateJsonExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), isBiometricsEnabled = false, @@ -647,6 +652,7 @@ class UserStateJsonExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), ), @@ -692,6 +698,7 @@ class UserStateJsonExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), isBiometricsEnabled = false, @@ -760,6 +767,7 @@ class UserStateJsonExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), ), @@ -805,6 +813,7 @@ class UserStateJsonExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), isBiometricsEnabled = false, @@ -873,6 +882,7 @@ class UserStateJsonExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), ), @@ -919,6 +929,7 @@ class UserStateJsonExtensionsTest { shouldManageResetPassword = true, shouldUseKeyConnector = false, role = OrganizationType.USER, + keyConnectorUrl = null, ), ), isBiometricsEnabled = false, @@ -990,6 +1001,7 @@ class UserStateJsonExtensionsTest { shouldManageResetPassword = true, shouldUseKeyConnector = false, role = OrganizationType.USER, + keyConnectorUrl = null, ), ), ), @@ -1194,6 +1206,7 @@ class UserStateJsonExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.USER, + keyConnectorUrl = null, ), ), isBiometricsEnabled = false, @@ -1264,6 +1277,7 @@ class UserStateJsonExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.USER, + keyConnectorUrl = null, ), ), ), @@ -1309,6 +1323,7 @@ class UserStateJsonExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), isBiometricsEnabled = false, @@ -1379,6 +1394,7 @@ class UserStateJsonExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt index c7a96dbbfc..4680048b38 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt @@ -203,6 +203,79 @@ class EnterpriseSignOnScreenTest : BaseComposeTest() { verify { viewModel.trySendAction(EnterpriseSignOnAction.DialogDismiss) } } + @Test + fun `ConfirmKeyConnector dialog should be shown or hidden according to the state`() { + composeTestRule.onNode(isDialog()).assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialogState = EnterpriseSignOnState.DialogState.KeyConnectorDomain( + keyConnectorDomain = "bitwarden.com", + ), + ) + } + + composeTestRule.onNode(isDialog()).assertIsDisplayed() + + composeTestRule + .onNodeWithText("Confirm Key Connector domain") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText( + "Please confirm the domain below with your organization administrator." + + "\n\n" + + "Key Connector domain:" + + "\n" + + "bitwarden.com", + ) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("Confirm") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Cancel") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Test + fun `ConfirmKeyConnector Confirm click should send ConfirmKeyConnectorDomainClick action`() { + mutableStateFlow.update { + DEFAULT_STATE.copy( + dialogState = EnterpriseSignOnState.DialogState.KeyConnectorDomain( + keyConnectorDomain = "bitwarden.com", + ), + ) + } + + composeTestRule + .onAllNodesWithText("Confirm") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(EnterpriseSignOnAction.ConfirmKeyConnectorDomainClick) } + } + + @Test + fun `ConfirmKeyConnector Cancel click should send CancelKeyConnectorDomainClick action`() { + mutableStateFlow.update { + DEFAULT_STATE.copy( + dialogState = EnterpriseSignOnState.DialogState.KeyConnectorDomain( + keyConnectorDomain = "bitwarden.com", + ), + ) + } + + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(EnterpriseSignOnAction.CancelKeyConnectorDomainClick) } + } + companion object { private val DEFAULT_STATE = EnterpriseSignOnState( dialogState = null, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt index c36a93657f..59d11140b4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnViewModelTest.kt @@ -687,6 +687,71 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { } } + @Test + fun `ssoCallbackResultFlow returns ConfirmKeyConnectorDomain should update dialogState`() = + runTest { + val orgIdentifier = "Bitwarden" + coEvery { + authRepository.login(any(), any(), any(), any(), any(), any()) + } returns LoginResult.ConfirmKeyConnectorDomain("bitwarden.com") + + val viewModel = createViewModel( + ssoData = DEFAULT_SSO_DATA, + ) + val ssoCallbackResult = SsoCallbackResult.Success(state = "abc", code = "lmn") + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE, + awaitItem(), + ) + + viewModel.trySendAction( + EnterpriseSignOnAction.OrgIdentifierInputChange(orgIdentifier), + ) + + assertEquals( + DEFAULT_STATE.copy( + orgIdentifierInput = orgIdentifier, + ), + awaitItem(), + ) + + mutableSsoCallbackResultFlow.tryEmit(ssoCallbackResult) + + assertEquals( + DEFAULT_STATE.copy( + dialogState = EnterpriseSignOnState.DialogState.Loading( + R.string.logging_in.asText(), + ), + orgIdentifierInput = orgIdentifier, + ), + awaitItem(), + ) + + assertEquals( + DEFAULT_STATE.copy( + dialogState = EnterpriseSignOnState.DialogState.KeyConnectorDomain( + keyConnectorDomain = "bitwarden.com", + ), + orgIdentifierInput = orgIdentifier, + ), + awaitItem(), + ) + } + + coVerify(exactly = 1) { + authRepository.login( + email = "test@gmail.com", + ssoCode = "lmn", + ssoCodeVerifier = "def", + ssoRedirectUri = "bitwarden://sso-callback", + captchaToken = null, + organizationIdentifier = orgIdentifier, + ) + } + } + @Test fun `captchaTokenResultFlow MissingToken should show error dialog`() = runTest { val viewModel = createViewModel() @@ -1009,6 +1074,98 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `ConfirmKeyConnectorDomainClick with login Success should show loading dialog and hide it`() = + runTest { + coEvery { + authRepository.continueKeyConnectorLogin() + } returns LoginResult.Success + + coEvery { + authRepository.rememberedOrgIdentifier = "Bitwarden" + } just runs + + val initialState = DEFAULT_STATE.copy(orgIdentifierInput = "Bitwarden") + val viewModel = createViewModel( + initialState = initialState, + ssoData = DEFAULT_SSO_DATA, + ) + + viewModel.stateFlow.test { + assertEquals( + initialState, + awaitItem(), + ) + + viewModel.trySendAction(EnterpriseSignOnAction.ConfirmKeyConnectorDomainClick) + + assertEquals( + initialState.copy( + dialogState = EnterpriseSignOnState.DialogState.Loading( + R.string.logging_in.asText(), + ), + ), + awaitItem(), + ) + + assertEquals( + initialState, + awaitItem(), + ) + } + + coVerify(exactly = 1) { + authRepository.continueKeyConnectorLogin() + } + } + + @Suppress("MaxLineLength") + @Test + fun `CancelKeyConnectorDomainClick should hide prompt and call authRepository cancelKeyConnectorLogin`() = + runTest { + coEvery { + authRepository.cancelKeyConnectorLogin() + } just runs + + val viewModel = createViewModel(initialState = DEFAULT_STATE) + + viewModel.stateFlow.test { + + assertEquals( + DEFAULT_STATE, + awaitItem(), + ) + + viewModel.trySendAction( + EnterpriseSignOnAction.Internal.OnLoginResult( + LoginResult.ConfirmKeyConnectorDomain("bitwarden.com"), + ), + ) + + assertEquals( + DEFAULT_STATE.copy( + dialogState = EnterpriseSignOnState.DialogState.KeyConnectorDomain( + keyConnectorDomain = "bitwarden.com", + ), + ), + awaitItem(), + ) + + viewModel.trySendAction(EnterpriseSignOnAction.CancelKeyConnectorDomainClick) + assertEquals( + DEFAULT_STATE.copy( + dialogState = null, + ), + awaitItem(), + ) + } + + coVerify(exactly = 1) { + authRepository.cancelKeyConnectorLogin() + } + } + @Suppress("LongParameterList") private fun createViewModel( initialState: EnterpriseSignOnState? = null, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordScreenTest.kt index 6712b0061d..2edecea782 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordScreenTest.kt @@ -1,22 +1,28 @@ package com.x8bit.bitwarden.ui.auth.feature.removepassword import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.isPopup +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.R import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.util.assertNoDialogExists import com.x8bit.bitwarden.ui.util.assertNoPopupExists 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.flow.update @@ -28,6 +34,7 @@ class RemovePasswordScreenTest : BaseComposeTest() { val viewModel = mockk(relaxed = true) { every { eventFlow } returns bufferedMutableSharedFlow() every { stateFlow } returns mutableStateFlow + every { trySendAction(action = any()) } just runs } @Before @@ -113,10 +120,75 @@ class RemovePasswordScreenTest : BaseComposeTest() { viewModel.trySendAction(RemovePasswordAction.ContinueClick) } } + + @Test + fun `leave organization button click should emit LeaveOrganizationClick`() { + mutableStateFlow.update { it.copy(input = "a") } + composeTestRule + .onNodeWithText(text = "Leave organization") + .performScrollTo() + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction(RemovePasswordAction.LeaveOrganizationClick) + } + } + + @Test + fun `leave organization confirm press should emit LeaveOrganizationConfirm`() { + mutableStateFlow.update { + it.copy( + dialogState = + RemovePasswordState.DialogState.LeaveConfirmationPrompt( + R.string.leave_organization.asText(), + ), + ) + } + + composeTestRule + .onAllNodesWithText("Confirm") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + verify { + viewModel.trySendAction(RemovePasswordAction.ConfirmLeaveOrganizationClick) + } + } + + @Test + fun `leave organization cancel press should emil DialogDismiss`() { + mutableStateFlow.update { + it.copy( + dialogState = RemovePasswordState.DialogState.LeaveConfirmationPrompt( + R.string.leave_organization_name.asText( + "orgName", + ), + ), + ) + } + + composeTestRule.onAllNodesWithText("Leave organization") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + verify { + viewModel.trySendAction(RemovePasswordAction.DialogDismiss) + } + } } private val DEFAULT_STATE = RemovePasswordState( input = "", dialogState = null, description = "My org".asText(), + labelOrg = "Organization name".asText(), + orgName = "Org X".asText(), + labelDomain = "Confirm Key Connector domain".asText(), + domainName = "bitwarden.com".asText(), + organizationId = "org-id", ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordViewModelTest.kt index e6862209ea..93dc8e5ce7 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/removepassword/RemovePasswordViewModelTest.kt @@ -8,14 +8,18 @@ import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.R 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.RemovePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals @@ -25,6 +29,7 @@ class RemovePasswordViewModelTest : BaseViewModelTest() { private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE) private val authRepository: AuthRepository = mockk { every { userStateFlow } returns mutableUserStateFlow + every { logout(reason = any()) } just runs } @Test @@ -101,6 +106,94 @@ class RemovePasswordViewModelTest : BaseViewModelTest() { } } + @Test + fun `LeaveOrganizationClick should dialog state to LeaveConfirmationPrompt`() = runTest { + val password = "123" + val initialState = DEFAULT_STATE.copy(input = password) + val viewModel = createViewModel(state = initialState) + coEvery { + authRepository.removePassword(masterPassword = password) + } returns RemovePasswordResult.Success + + viewModel.stateFlow.test { + assertEquals(initialState, awaitItem()) + viewModel.trySendAction(RemovePasswordAction.LeaveOrganizationClick) + assertEquals( + initialState.copy( + dialogState = RemovePasswordState.DialogState.LeaveConfirmationPrompt( + R.string.leave_organization_name.asText("My org".asText()), + ), + ), + awaitItem(), + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `ConfirmLeaveOrganizationClick with LeaveOrganizationResult Success should leave organization`() = + runTest { + coEvery { + authRepository.leaveOrganization( + organizationId = "mockId-1", + ) + } returns LeaveOrganizationResult.Success + coEvery { + authRepository.logout(any()) + } returns Unit + + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction(RemovePasswordAction.ConfirmLeaveOrganizationClick) + coVerify { authRepository.leaveOrganization("mockId-1") } + + viewModel.trySendAction( + RemovePasswordAction.Internal.ReceiveLeaveOrganizationResult( + LeaveOrganizationResult.Success, + ), + ) + coVerify { authRepository.logout(any()) } + } + } + + @Test + fun `ConfirmLeaveOrganizationClick with LeaveOrganizationResult Error should show error`() = + runTest { + coEvery { + authRepository.leaveOrganization( + organizationId = "mockId-1", + ) + } returns LeaveOrganizationResult.Error(error = null) + coEvery { + authRepository.logout(any()) + } returns Unit + + val viewModel = createViewModel() + + viewModel.stateFlow.test { + viewModel.trySendAction(RemovePasswordAction.ConfirmLeaveOrganizationClick) + assertEquals(DEFAULT_STATE, awaitItem()) + assertEquals( + DEFAULT_STATE.copy( + dialogState = RemovePasswordState.DialogState.Loading( + title = R.string.loading.asText(), + ), + ), + awaitItem(), + ) + assertEquals( + DEFAULT_STATE.copy( + dialogState = RemovePasswordState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + ), + ), + awaitItem(), + ) + } + } + @Test fun `InputChanged updates the state`() { val input = "123" @@ -136,12 +229,17 @@ class RemovePasswordViewModelTest : BaseViewModelTest() { } private const val ORGANIZATION_NAME: String = "My org" +private const val KEY_CONNECTOR_URL: String = "bitwarden.com" private val DEFAULT_STATE = RemovePasswordState( input = "", dialogState = null, description = R.string - .organization_is_using_sso_with_a_self_hosted_key_server - .asText(ORGANIZATION_NAME), + .password_no_longer_required_confirm_domain.asText(), + labelOrg = R.string.key_connector_organization.asText(), + orgName = ORGANIZATION_NAME.asText(), + labelDomain = R.string.key_connector_domain.asText(), + domainName = KEY_CONNECTOR_URL.asText(), + organizationId = "mockId-1", ) private const val USER_ID: String = "user_id" @@ -158,11 +256,12 @@ private val DEFAULT_ACCOUNT = UserState.Account( isBiometricsEnabled = false, organizations = listOf( Organization( - id = "orgId", + id = "mockId-1", name = ORGANIZATION_NAME, shouldManageResetPassword = false, shouldUseKeyConnector = true, role = OrganizationType.USER, + keyConnectorUrl = KEY_CONNECTOR_URL, ), ), needsMasterPassword = false, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index 72088d2e71..0fab0a9016 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -386,6 +386,7 @@ class RootNavViewModelTest : BaseViewModelTest() { shouldManageResetPassword = false, shouldUseKeyConnector = true, role = OrganizationType.USER, + keyConnectorUrl = "bitwarden.com", ), ), needsMasterPassword = false, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt index d2c94b3bfb..8b546a721f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt @@ -997,6 +997,7 @@ private val DEFAULT_USER_STATE = UserState( shouldUseKeyConnector = false, shouldManageResetPassword = false, role = OrganizationType.USER, + keyConnectorUrl = null, ), Organization( id = "organizationAdmin", @@ -1004,6 +1005,7 @@ private val DEFAULT_USER_STATE = UserState( shouldUseKeyConnector = false, shouldManageResetPassword = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), Organization( id = "organizationOwner", @@ -1011,6 +1013,7 @@ private val DEFAULT_USER_STATE = UserState( shouldUseKeyConnector = false, shouldManageResetPassword = false, role = OrganizationType.OWNER, + keyConnectorUrl = null, ), Organization( id = "organizationCustom", @@ -1018,6 +1021,7 @@ private val DEFAULT_USER_STATE = UserState( shouldUseKeyConnector = false, shouldManageResetPassword = false, role = OrganizationType.CUSTOM, + keyConnectorUrl = null, ), ), needsMasterPassword = false, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index 7914a49061..2c402fc346 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -4618,6 +4618,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), isBiometricsEnabled = true, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt index 750f3a0da8..4bc97cc343 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt @@ -564,6 +564,7 @@ class CipherViewExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), isBiometricsEnabled = true, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index 3788aeddff..de364b0200 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -3349,6 +3349,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.OWNER, + keyConnectorUrl = null, ), ), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt index 57262c1985..21906fb624 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/VaultMoveToOrganizationViewModelTest.kt @@ -498,6 +498,7 @@ private val DEFAULT_USER_STATE = UserState( shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), Organization( id = "mockOrganizationId-2", @@ -505,6 +506,7 @@ private val DEFAULT_USER_STATE = UserState( shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), Organization( id = "mockOrganizationId-3", @@ -512,6 +514,7 @@ private val DEFAULT_USER_STATE = UserState( shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), trustedDevice = null, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationExtensionsTest.kt index a6e782ffed..c500275175 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/movetoorganization/util/VaultMoveToOrganizationExtensionsTest.kt @@ -108,6 +108,7 @@ private fun createMockUserState(hasOrganizations: Boolean = true): UserState = shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), Organization( id = "mockOrganizationId-2", @@ -115,6 +116,7 @@ private fun createMockUserState(hasOrganizations: Boolean = true): UserState = shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), Organization( id = "mockOrganizationId-3", @@ -122,6 +124,7 @@ private fun createMockUserState(hasOrganizations: Boolean = true): UserState = shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ) } else { diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 71e1fb3d8d..e0d5e9c08c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -243,6 +243,7 @@ class VaultViewModelTest : BaseViewModelTest() { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), trustedDevice = null, @@ -329,6 +330,7 @@ class VaultViewModelTest : BaseViewModelTest() { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), trustedDevice = null, @@ -570,6 +572,7 @@ class VaultViewModelTest : BaseViewModelTest() { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), ), diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensionsTest.kt index b1ffba559c..dd01998962 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensionsTest.kt @@ -82,6 +82,7 @@ class UserStateExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), trustedDevice = null, @@ -109,6 +110,7 @@ class UserStateExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), trustedDevice = null, @@ -140,6 +142,7 @@ class UserStateExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), trustedDevice = null, @@ -171,6 +174,7 @@ class UserStateExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), trustedDevice = null, @@ -217,6 +221,7 @@ class UserStateExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), trustedDevice = null, @@ -261,6 +266,7 @@ class UserStateExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), trustedDevice = null, @@ -309,6 +315,7 @@ class UserStateExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), trustedDevice = null, @@ -387,6 +394,7 @@ class UserStateExtensionsTest { shouldUseKeyConnector = false, shouldManageResetPassword = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), Organization( id = "organizationId-A", @@ -394,6 +402,7 @@ class UserStateExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), trustedDevice = null, @@ -445,6 +454,7 @@ class UserStateExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), Organization( id = "organizationId-A", @@ -452,6 +462,7 @@ class UserStateExtensionsTest { shouldManageResetPassword = false, shouldUseKeyConnector = false, role = OrganizationType.ADMIN, + keyConnectorUrl = null, ), ), trustedDevice = null, diff --git a/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedOrganizationApi.kt b/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedOrganizationApi.kt index b148fe198f..fcf86fbe9e 100644 --- a/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedOrganizationApi.kt +++ b/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedOrganizationApi.kt @@ -6,6 +6,7 @@ import com.bitwarden.network.model.OrganizationKeysResponseJson import com.bitwarden.network.model.OrganizationResetPasswordEnrollRequestJson import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Path @@ -38,4 +39,12 @@ interface AuthenticatedOrganizationApi { suspend fun getOrganizationKeys( @Path("id") organizationId: String, ): NetworkResult + + /** + * Leaves the organization + */ + @POST("/organizations/{id}/leave") + suspend fun leaveOrganization( + @Path("id") organizationId: String, + ): NetworkResult } diff --git a/network/src/main/kotlin/com/bitwarden/network/service/OrganizationService.kt b/network/src/main/kotlin/com/bitwarden/network/service/OrganizationService.kt index dec9d6d00e..269c322a1a 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/OrganizationService.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/OrganizationService.kt @@ -47,4 +47,11 @@ interface OrganizationService { suspend fun getVerifiedOrganizationDomainSsoDetails( email: String, ): Result + + /** + * Make a request to leave the organization + */ + suspend fun leaveOrganization( + organizationId: String, + ): Result } diff --git a/network/src/main/kotlin/com/bitwarden/network/service/OrganizationServiceImpl.kt b/network/src/main/kotlin/com/bitwarden/network/service/OrganizationServiceImpl.kt index 306a724a3f..8f57342bf0 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/OrganizationServiceImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/OrganizationServiceImpl.kt @@ -69,4 +69,9 @@ class OrganizationServiceImpl( ), ) .toResult() + + override suspend fun leaveOrganization(organizationId: String): Result = + authenticatedOrganizationApi + .leaveOrganization(organizationId = organizationId) + .toResult() } diff --git a/network/src/test/kotlin/com/bitwarden/network/service/OrganizationServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/OrganizationServiceTest.kt index 6394c23270..137a1dfc5f 100644 --- a/network/src/test/kotlin/com/bitwarden/network/service/OrganizationServiceTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/service/OrganizationServiceTest.kt @@ -135,6 +135,22 @@ class OrganizationServiceTest : BaseServiceTest() { organizationService.getVerifiedOrganizationDomainSsoDetails("example@bitwarden.com") assertTrue(result.isFailure) } + + @Test + fun `leaveOrganization should return success when api call is successful`() = + runTest { + server.enqueue(MockResponse().setResponseCode(200)) + val result = organizationService.leaveOrganization(organizationId = "mock-Id") + assertTrue(result.isSuccess) + } + + @Test + fun `leaveOrganization should return failure when api call fails with error`() = + runTest { + server.enqueue(MockResponse().setResponseCode(400)) + val result = organizationService.leaveOrganization(organizationId = "mock-Id") + assertTrue(result.isFailure) + } } private const val ORGANIZATION_AUTO_ENROLL_STATUS_JSON = """