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 = """