[PM-18936] Show key connector domain (#5034)

This commit is contained in:
aj-rosado 2025-04-17 20:20:50 +01:00 committed by GitHub
parent 33da0d8138
commit bee09de972
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1100 additions and 17 deletions

View File

@ -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
}

View File

@ -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<String, String>()
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()

View File

@ -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()
}

View File

@ -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.
*/

View File

@ -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()
}

View File

@ -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?,
)

View File

@ -22,6 +22,7 @@ fun SyncResponseJson.Profile.Organization.toOrganization(): Organization =
shouldUseKeyConnector = this.shouldUseKeyConnector,
role = this.type,
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
keyConnectorUrl = this.keyConnectorUrl,
)
/**

View File

@ -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
}
}

View File

@ -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.
*/

View File

@ -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(

View File

@ -283,6 +283,9 @@ class LoginWithDeviceViewModel @Inject constructor(
)
}
}
// NO-OP: This result should not be possible here
is LoginResult.ConfirmKeyConnectorDomain -> Unit
}
}

View File

@ -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 = { },
)
}
}

View File

@ -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<RemovePasswordState, Unit, RemovePasswordAction>(
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()
}
}

View File

@ -350,6 +350,9 @@ class TwoFactorLoginViewModel @Inject constructor(
)
}
}
// NO-OP: This result should not be possible here
is LoginResult.ConfirmKeyConnectorDomain -> Unit
}
}

View File

@ -1248,4 +1248,9 @@ Do you want to switch to this account?</string>
<string name="do_you_really_want_to_delete_this_log">Do you really want to delete this log?</string>
<string name="delete_logs">Delete logs</string>
<string name="do_you_really_want_to_delete_all_recorded_logs">Do you really want to delete all recorded logs?</string>
<string name="confirm_key_connector_domain">Confirm Key Connector domain</string>
<string name="key_connector_domain">Key Connector domain:</string>
<string name="key_connector_organization">Organization:</string>
<string name="password_no_longer_required_confirm_domain">A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator.</string>
<string name="please_confirm_domain_with_admin">Please confirm the domain below with your organization administrator.\n\nKey Connector domain:\n%1$s</string>
</resources>

View File

@ -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<KeyConnectorResponse> {
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"

View File

@ -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",
),
),
),

View File

@ -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),
)

View File

@ -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,
),
),
),

View File

@ -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,

View File

@ -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,

View File

@ -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<RemovePasswordViewModel>(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",
)

View File

@ -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<UserState?>(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,

View File

@ -386,6 +386,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
shouldManageResetPassword = false,
shouldUseKeyConnector = true,
role = OrganizationType.USER,
keyConnectorUrl = "bitwarden.com",
),
),
needsMasterPassword = false,

View File

@ -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,

View File

@ -4618,6 +4618,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
shouldManageResetPassword = false,
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
),
),
isBiometricsEnabled = true,

View File

@ -564,6 +564,7 @@ class CipherViewExtensionsTest {
shouldManageResetPassword = false,
shouldUseKeyConnector = false,
role = OrganizationType.ADMIN,
keyConnectorUrl = null,
),
),
isBiometricsEnabled = true,

View File

@ -3349,6 +3349,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
shouldManageResetPassword = false,
shouldUseKeyConnector = false,
role = OrganizationType.OWNER,
keyConnectorUrl = null,
),
),
),

View File

@ -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,

View File

@ -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 {

View File

@ -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,
),
),
),

View File

@ -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,

View File

@ -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<OrganizationKeysResponseJson>
/**
* Leaves the organization
*/
@POST("/organizations/{id}/leave")
suspend fun leaveOrganization(
@Path("id") organizationId: String,
): NetworkResult<Unit>
}

View File

@ -47,4 +47,11 @@ interface OrganizationService {
suspend fun getVerifiedOrganizationDomainSsoDetails(
email: String,
): Result<VerifiedOrganizationDomainSsoDetailsResponse>
/**
* Make a request to leave the organization
*/
suspend fun leaveOrganization(
organizationId: String,
): Result<Unit>
}

View File

@ -69,4 +69,9 @@ class OrganizationServiceImpl(
),
)
.toResult()
override suspend fun leaveOrganization(organizationId: String): Result<Unit> =
authenticatedOrganizationApi
.leaveOrganization(organizationId = organizationId)
.toResult()
}

View File

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