[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.DeleteAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult 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.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.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
@ -243,6 +244,16 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
orgIdentifier: String?, orgIdentifier: String?,
): LoginResult ): LoginResult
/**
* Continue the previously halted login attempt.
*/
suspend fun continueKeyConnectorLogin(): LoginResult
/**
* Cancel the previously halted login attempt.
*/
fun cancelKeyConnectorLogin()
/** /**
* Log out the current user. * Log out the current user.
*/ */
@ -422,4 +433,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
* Update the value of the onboarding status for the user. * Update the value of the onboarding status for the user.
*/ */
fun setOnboardingStatus(status: OnboardingStatus) 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.DeleteAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult 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.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.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
@ -238,6 +239,8 @@ class AuthRepositoryImpl(
*/ */
private var passwordsToCheckMap = mutableMapOf<String, String>() private var passwordsToCheckMap = mutableMapOf<String, String>()
private var keyConnectorResponse: GetTokenResponseJson.Success? = null
override var twoFactorResponse: GetTokenResponseJson.TwoFactorRequired? = null override var twoFactorResponse: GetTokenResponseJson.TwoFactorRequired? = null
override val ssoOrganizationIdentifier: String? get() = organizationIdentifier override val ssoOrganizationIdentifier: String? get() = organizationIdentifier
@ -715,6 +718,25 @@ class AuthRepositoryImpl(
error = MissingPropertyException("Identity Token Auth Model"), 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( override suspend fun login(
email: String, email: String,
ssoCode: 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") @Suppress("CyclomaticComplexMethod")
private suspend fun validatePasswordAgainstPolicy( private suspend fun validatePasswordAgainstPolicy(
password: String, password: String,
@ -1552,6 +1580,7 @@ class AuthRepositoryImpl(
* A helper function to extract the common logic of logging in through * A helper function to extract the common logic of logging in through
* any of the available methods. * any of the available methods.
*/ */
@Suppress("LongMethod")
private suspend fun loginCommon( private suspend fun loginCommon(
email: String, email: String,
password: String? = null, password: String? = null,
@ -1603,6 +1632,7 @@ class AuthRepositoryImpl(
password = password, password = password,
deviceData = deviceData, deviceData = deviceData,
orgIdentifier = orgIdentifier, orgIdentifier = orgIdentifier,
userConfirmedKeyConnector = false,
) )
is GetTokenResponseJson.Invalid -> { is GetTokenResponseJson.Invalid -> {
@ -1636,6 +1666,7 @@ class AuthRepositoryImpl(
password: String?, password: String?,
deviceData: DeviceDataModel?, deviceData: DeviceDataModel?,
orgIdentifier: String?, orgIdentifier: String?,
userConfirmedKeyConnector: Boolean,
): LoginResult = userStateTransaction { ): LoginResult = userStateTransaction {
val userStateJson = loginResponse.toUserState( val userStateJson = loginResponse.toUserState(
previousUserState = authDiskSource.userState, previousUserState = authDiskSource.userState,
@ -1665,6 +1696,21 @@ class AuthRepositoryImpl(
deviceData = deviceData, deviceData = deviceData,
) )
} else if (keyConnectorUrl != null && orgIdentifier != null) { } 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( unlockVaultWithKeyConnectorOnLoginSuccess(
profile = profile, profile = profile,
keyConnectorUrl = keyConnectorUrl, keyConnectorUrl = keyConnectorUrl,
@ -1738,6 +1784,7 @@ class AuthRepositoryImpl(
resendEmailRequestJson = null resendEmailRequestJson = null
twoFactorDeviceData = null twoFactorDeviceData = null
resendNewDeviceOtpRequestJson = null resendNewDeviceOtpRequestJson = null
keyConnectorResponse = null
settingsRepository.setDefaultsIfNecessary(userId = userId) settingsRepository.setDefaultsIfNecessary(userId = userId)
settingsRepository.storeUserHasLoggedInValue(userId) settingsRepository.storeUserHasLoggedInValue(userId)
vaultRepository.syncIfNecessary() 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() data object TwoFactorRequired : LoginResult()
/**
* User should confirm KeyConnector domain
*/
data class ConfirmKeyConnectorDomain(
val domain: String,
) : LoginResult()
/** /**
* There was an error logging in. * There was an error logging in.
*/ */

View File

@ -68,4 +68,9 @@ sealed class LogoutReason {
* unsuccessfully too many times. * unsuccessfully too many times.
*/ */
data object TooManyUnlockAttempts : LogoutReason() 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. * own password.
* @property shouldUseKeyConnector Indicates that the organization uses a key connector. * @property shouldUseKeyConnector Indicates that the organization uses a key connector.
* @property role The user's role in the organization. * @property role The user's role in the organization.
* @property keyConnectorUrl The key connector domain (if applicable).
*/ */
data class Organization( data class Organization(
val id: String, val id: String,
@ -18,4 +19,5 @@ data class Organization(
val shouldManageResetPassword: Boolean, val shouldManageResetPassword: Boolean,
val shouldUseKeyConnector: Boolean, val shouldUseKeyConnector: Boolean,
val role: OrganizationType, val role: OrganizationType,
val keyConnectorUrl: String?,
) )

View File

@ -22,6 +22,7 @@ fun SyncResponseJson.Profile.Organization.toOrganization(): Organization =
shouldUseKeyConnector = this.shouldUseKeyConnector, shouldUseKeyConnector = this.shouldUseKeyConnector,
role = this.type, role = this.type,
shouldManageResetPassword = this.permissions.shouldManageResetPassword, 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.button.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog 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.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.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle import com.x8bit.bitwarden.ui.platform.components.model.CardStyle
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
@ -77,6 +78,12 @@ fun EnterpriseSignOnScreen(
EnterpriseSignOnDialogs( EnterpriseSignOnDialogs(
dialogState = state.dialogState, dialogState = state.dialogState,
onConfirmKeyConnectorDomain = remember(viewModel) {
{ viewModel.trySendAction(EnterpriseSignOnAction.ConfirmKeyConnectorDomainClick) }
},
onDismissKeyConnectorDomain = remember(viewModel) {
{ viewModel.trySendAction(EnterpriseSignOnAction.CancelKeyConnectorDomainClick) }
},
onDismissRequest = remember(viewModel) { onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(EnterpriseSignOnAction.DialogDismiss) } { viewModel.trySendAction(EnterpriseSignOnAction.DialogDismiss) }
}, },
@ -163,6 +170,8 @@ private fun EnterpriseSignOnScreenContent(
private fun EnterpriseSignOnDialogs( private fun EnterpriseSignOnDialogs(
dialogState: EnterpriseSignOnState.DialogState?, dialogState: EnterpriseSignOnState.DialogState?,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onConfirmKeyConnectorDomain: () -> Unit,
onDismissKeyConnectorDomain: () -> Unit,
) { ) {
when (dialogState) { when (dialogState) {
is EnterpriseSignOnState.DialogState.Error -> { is EnterpriseSignOnState.DialogState.Error -> {
@ -178,6 +187,21 @@ private fun EnterpriseSignOnDialogs(
BitwardenLoadingDialog(text = dialogState.message()) 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 null -> Unit
} }
} }

View File

@ -131,6 +131,14 @@ class EnterpriseSignOnViewModel @Inject constructor(
is EnterpriseSignOnAction.Internal.OnVerifiedOrganizationDomainSsoDetailsReceive -> { is EnterpriseSignOnAction.Internal.OnVerifiedOrganizationDomainSsoDetailsReceive -> {
handleOnVerifiedOrganizationDomainSsoDetailsReceive(action) handleOnVerifiedOrganizationDomainSsoDetailsReceive(action)
} }
EnterpriseSignOnAction.CancelKeyConnectorDomainClick -> {
handleCancelKeyConnectorDomainClick()
}
EnterpriseSignOnAction.ConfirmKeyConnectorDomainClick -> {
handleConfirmKeyConnectorDomainClick()
}
} }
} }
@ -199,6 +207,12 @@ class EnterpriseSignOnViewModel @Inject constructor(
?: R.string.login_sso_error.asText(), ?: 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( data class Loading(
val message: Text, val message: Text,
) : DialogState() ) : 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() 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. * 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 -> { is LoginResult.Error -> {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( 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.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar 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.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.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog 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.field.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle import com.x8bit.bitwarden.ui.platform.components.model.CardStyle
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
@ -49,6 +51,11 @@ fun RemovePasswordScreen(
onDismissRequest = remember(viewModel) { onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(RemovePasswordAction.DialogDismiss) } { viewModel.trySendAction(RemovePasswordAction.DialogDismiss) }
}, },
onConfirmLeaveClick = remember(viewModel) {
{
viewModel.trySendAction(RemovePasswordAction.ConfirmLeaveOrganizationClick)
}
},
) )
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@ -72,16 +79,21 @@ fun RemovePasswordScreen(
onInputChanged = remember(viewModel) { onInputChanged = remember(viewModel) {
{ viewModel.trySendAction(RemovePasswordAction.InputChanged(it)) } { viewModel.trySendAction(RemovePasswordAction.InputChanged(it)) }
}, },
onLeaveOrganizationClick = remember(viewModel) {
{ viewModel.trySendAction(RemovePasswordAction.LeaveOrganizationClick) }
},
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
} }
@Composable @Composable
@Suppress("LongMethod")
private fun RemovePasswordScreenContent( private fun RemovePasswordScreenContent(
state: RemovePasswordState, state: RemovePasswordState,
onContinueClick: () -> Unit, onContinueClick: () -> Unit,
onInputChanged: (String) -> Unit, onInputChanged: (String) -> Unit,
onLeaveOrganizationClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@ -98,6 +110,42 @@ private fun RemovePasswordScreenContent(
.standardHorizontalMargin() .standardHorizontalMargin()
.fillMaxWidth(), .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)) Spacer(modifier = Modifier.height(16.dp))
BitwardenPasswordField( BitwardenPasswordField(
@ -124,6 +172,17 @@ private fun RemovePasswordScreenContent(
.fillMaxWidth(), .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.height(16.dp))
Spacer(modifier = Modifier.navigationBarsPadding()) Spacer(modifier = Modifier.navigationBarsPadding())
} }
@ -133,6 +192,7 @@ private fun RemovePasswordScreenContent(
private fun RemovePasswordDialogs( private fun RemovePasswordDialogs(
dialogState: RemovePasswordState.DialogState?, dialogState: RemovePasswordState.DialogState?,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onConfirmLeaveClick: () -> Unit,
) { ) {
when (dialogState) { when (dialogState) {
is RemovePasswordState.DialogState.Error -> { is RemovePasswordState.DialogState.Error -> {
@ -147,6 +207,18 @@ private fun RemovePasswordDialogs(
BitwardenLoadingDialog(text = dialogState.title()) 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 null -> Unit
} }
} }
@ -158,11 +230,21 @@ private fun RemovePasswordScreen_preview() {
RemovePasswordScreenContent( RemovePasswordScreenContent(
state = RemovePasswordState( state = RemovePasswordState(
input = "", 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, dialogState = null,
organizationId = null,
), ),
onContinueClick = { }, onContinueClick = { },
onInputChanged = { }, onInputChanged = { },
onLeaveOrganizationClick = { },
) )
} }
} }

View File

@ -3,12 +3,15 @@ package com.x8bit.bitwarden.ui.auth.feature.removepassword
import android.os.Parcelable import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope 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.Text
import com.bitwarden.ui.util.asText 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -26,18 +29,20 @@ class RemovePasswordViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
) : BaseViewModel<RemovePasswordState, Unit, RemovePasswordAction>( ) : BaseViewModel<RemovePasswordState, Unit, RemovePasswordAction>(
initialState = savedStateHandle[KEY_STATE] ?: run { initialState = savedStateHandle[KEY_STATE] ?: run {
val orgName = authRepository.userStateFlow.value val org = authRepository.userStateFlow.value
?.activeAccount ?.activeAccount
?.organizations ?.organizations
?.firstOrNull { it.shouldUseKeyConnector } ?.firstOrNull { it.shouldUseKeyConnector }
?.name
.orEmpty()
RemovePasswordState( RemovePasswordState(
input = "", input = "",
description = R.string description = R.string.password_no_longer_required_confirm_domain.asText(),
.organization_is_using_sso_with_a_self_hosted_key_server labelOrg = R.string.key_connector_organization.asText(),
.asText(orgName), orgName = org?.name?.asText(),
labelDomain = R.string.key_connector_domain.asText(),
domainName = org?.keyConnectorUrl?.asText(),
dialogState = null, dialogState = null,
organizationId = org?.id.orNullIfBlank(),
) )
}, },
) { ) {
@ -46,9 +51,29 @@ class RemovePasswordViewModel @Inject constructor(
RemovePasswordAction.ContinueClick -> handleContinueClick() RemovePasswordAction.ContinueClick -> handleContinueClick()
is RemovePasswordAction.InputChanged -> handleInputChanged(action) is RemovePasswordAction.InputChanged -> handleInputChanged(action)
RemovePasswordAction.DialogDismiss -> handleDialogDismiss() RemovePasswordAction.DialogDismiss -> handleDialogDismiss()
RemovePasswordAction.LeaveOrganizationClick -> handleLeaveOrganizationClick()
is RemovePasswordAction.ConfirmLeaveOrganizationClick -> {
handleConfirmLeaveOrganizationResult()
}
is RemovePasswordAction.Internal.ReceiveRemovePasswordResult -> { is RemovePasswordAction.Internal.ReceiveRemovePasswordResult -> {
handleReceiveRemovePasswordResult(action) 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( data class RemovePasswordState(
val input: String, val input: String,
val description: Text, val description: Text,
val labelOrg: Text,
val orgName: Text?,
val labelDomain: Text,
val domainName: Text?,
val dialogState: DialogState?, val dialogState: DialogState?,
val organizationId: String?,
) : Parcelable { ) : Parcelable {
/** /**
* Represents the current state of any dialogs on the screen. * Represents the current state of any dialogs on the screen.
@ -139,6 +216,14 @@ data class RemovePasswordState(
*/ */
@Parcelize @Parcelize
data class Loading(val title: Text) : DialogState() 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() 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. * The user has modified the input.
*/ */
@ -173,5 +268,12 @@ sealed class RemovePasswordAction {
data class ReceiveRemovePasswordResult( data class ReceiveRemovePasswordResult(
val result: RemovePasswordResult, val result: RemovePasswordResult,
) : Internal() ) : 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="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="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="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> </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.DeleteAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult 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.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.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult 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.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertDoesNotThrow
import java.time.ZonedDateTime import java.time.ZonedDateTime
import javax.net.ssl.SSLHandshakeException import javax.net.ssl.SSLHandshakeException
@ -3284,6 +3286,7 @@ class AuthRepositoryTest {
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US, environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
) )
} returns SINGLE_USER_STATE_1 } returns SINGLE_USER_STATE_1
repository.rememberedOrgIdentifier = ORGANIZATION_IDENTIFIER
val result = repository.login( val result = repository.login(
email = EMAIL, email = EMAIL,
@ -3294,7 +3297,10 @@ class AuthRepositoryTest {
organizationIdentifier = ORGANIZATION_IDENTIFIER, 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.assertPrivateKey(userId = USER_ID_1, privateKey = null)
fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = null) fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = null)
coVerify(exactly = 1) { coVerify(exactly = 1) {
@ -3385,6 +3391,7 @@ class AuthRepositoryTest {
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US, environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
) )
} returns SINGLE_USER_STATE_1 } returns SINGLE_USER_STATE_1
repository.rememberedOrgIdentifier = ORGANIZATION_IDENTIFIER
val result = repository.login( val result = repository.login(
email = EMAIL, email = EMAIL,
ssoCode = SSO_CODE, ssoCode = SSO_CODE,
@ -3394,7 +3401,10 @@ class AuthRepositoryTest {
organizationIdentifier = ORGANIZATION_IDENTIFIER, 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) assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = PRIVATE_KEY) fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = PRIVATE_KEY)
fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = ENCRYPTED_USER_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 @Test
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
fun `login with device get token succeeds should return Success, update AuthState, update stored keys, and sync with UserKey`() = 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)) 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 { companion object {
private const val UNIQUE_APP_ID = "testUniqueAppId" private const val UNIQUE_APP_ID = "testUniqueAppId"
private const val NAME = "Example Name" private const val NAME = "Example Name"

View File

@ -194,6 +194,7 @@ class AuthDiskSourceExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = "mockKeyConnectorUrl-1",
), ),
), ),
), ),
@ -206,6 +207,7 @@ class AuthDiskSourceExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = "mockKeyConnectorUrl-2",
), ),
), ),
), ),
@ -218,6 +220,7 @@ class AuthDiskSourceExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = "mockKeyConnectorUrl-3",
), ),
), ),
), ),
@ -365,6 +368,7 @@ class AuthDiskSourceExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = "mockKeyConnectorUrl-1",
), ),
), ),
), ),
@ -396,6 +400,7 @@ class AuthDiskSourceExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = "mockKeyConnectorUrl-1",
), ),
), ),
), ),
@ -408,6 +413,7 @@ class AuthDiskSourceExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = "mockKeyConnectorUrl-2",
), ),
), ),
), ),

View File

@ -23,6 +23,7 @@ class SyncResponseJsonExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = "mockKeyConnectorUrl-1",
), ),
createMockOrganization(number = 1).toOrganization(), createMockOrganization(number = 1).toOrganization(),
) )
@ -38,6 +39,7 @@ class SyncResponseJsonExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = true, shouldUseKeyConnector = true,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = "mockKeyConnectorUrl-1",
), ),
Organization( Organization(
id = "mockId-2", id = "mockId-2",
@ -45,10 +47,13 @@ class SyncResponseJsonExtensionsTest {
shouldManageResetPassword = true, shouldManageResetPassword = true,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.USER, role = OrganizationType.USER,
keyConnectorUrl = "mockKeyConnectorUrl-2",
), ),
), ),
listOf( listOf(
createMockOrganization(number = 1).copy(shouldUseKeyConnector = true), createMockOrganization(number = 1).copy(
shouldUseKeyConnector = true,
),
createMockOrganization(number = 2, shouldManageResetPassword = true) createMockOrganization(number = 2, shouldManageResetPassword = true)
.copy(type = OrganizationType.USER), .copy(type = OrganizationType.USER),
) )

View File

@ -366,6 +366,7 @@ class UserStateJsonExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
isBiometricsEnabled = false, isBiometricsEnabled = false,
@ -430,6 +431,7 @@ class UserStateJsonExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
), ),
@ -474,6 +476,7 @@ class UserStateJsonExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
isBiometricsEnabled = true, isBiometricsEnabled = true,
@ -534,6 +537,7 @@ class UserStateJsonExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
), ),
@ -579,6 +583,7 @@ class UserStateJsonExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
isBiometricsEnabled = false, isBiometricsEnabled = false,
@ -647,6 +652,7 @@ class UserStateJsonExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
), ),
@ -692,6 +698,7 @@ class UserStateJsonExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
isBiometricsEnabled = false, isBiometricsEnabled = false,
@ -760,6 +767,7 @@ class UserStateJsonExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
), ),
@ -805,6 +813,7 @@ class UserStateJsonExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
isBiometricsEnabled = false, isBiometricsEnabled = false,
@ -873,6 +882,7 @@ class UserStateJsonExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
), ),
@ -919,6 +929,7 @@ class UserStateJsonExtensionsTest {
shouldManageResetPassword = true, shouldManageResetPassword = true,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.USER, role = OrganizationType.USER,
keyConnectorUrl = null,
), ),
), ),
isBiometricsEnabled = false, isBiometricsEnabled = false,
@ -990,6 +1001,7 @@ class UserStateJsonExtensionsTest {
shouldManageResetPassword = true, shouldManageResetPassword = true,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.USER, role = OrganizationType.USER,
keyConnectorUrl = null,
), ),
), ),
), ),
@ -1194,6 +1206,7 @@ class UserStateJsonExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.USER, role = OrganizationType.USER,
keyConnectorUrl = null,
), ),
), ),
isBiometricsEnabled = false, isBiometricsEnabled = false,
@ -1264,6 +1277,7 @@ class UserStateJsonExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.USER, role = OrganizationType.USER,
keyConnectorUrl = null,
), ),
), ),
), ),
@ -1309,6 +1323,7 @@ class UserStateJsonExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
isBiometricsEnabled = false, isBiometricsEnabled = false,
@ -1379,6 +1394,7 @@ class UserStateJsonExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
), ),

View File

@ -203,6 +203,79 @@ class EnterpriseSignOnScreenTest : BaseComposeTest() {
verify { viewModel.trySendAction(EnterpriseSignOnAction.DialogDismiss) } 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 { companion object {
private val DEFAULT_STATE = EnterpriseSignOnState( private val DEFAULT_STATE = EnterpriseSignOnState(
dialogState = null, 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 @Test
fun `captchaTokenResultFlow MissingToken should show error dialog`() = runTest { fun `captchaTokenResultFlow MissingToken should show error dialog`() = runTest {
val viewModel = createViewModel() 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") @Suppress("LongParameterList")
private fun createViewModel( private fun createViewModel(
initialState: EnterpriseSignOnState? = null, initialState: EnterpriseSignOnState? = null,

View File

@ -1,22 +1,28 @@
package com.x8bit.bitwarden.ui.auth.feature.removepassword package com.x8bit.bitwarden.ui.auth.feature.removepassword
import androidx.compose.ui.test.assert import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.isPopup import androidx.compose.ui.test.isPopup
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performScrollTo
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.util.asText import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.util.assertNoDialogExists import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.ui.util.assertNoPopupExists import com.x8bit.bitwarden.ui.util.assertNoPopupExists
import io.mockk.every import io.mockk.every
import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -28,6 +34,7 @@ class RemovePasswordScreenTest : BaseComposeTest() {
val viewModel = mockk<RemovePasswordViewModel>(relaxed = true) { val viewModel = mockk<RemovePasswordViewModel>(relaxed = true) {
every { eventFlow } returns bufferedMutableSharedFlow() every { eventFlow } returns bufferedMutableSharedFlow()
every { stateFlow } returns mutableStateFlow every { stateFlow } returns mutableStateFlow
every { trySendAction(action = any()) } just runs
} }
@Before @Before
@ -113,10 +120,75 @@ class RemovePasswordScreenTest : BaseComposeTest() {
viewModel.trySendAction(RemovePasswordAction.ContinueClick) 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( private val DEFAULT_STATE = RemovePasswordState(
input = "", input = "",
dialogState = null, dialogState = null,
description = "My org".asText(), 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.R
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus 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.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.Organization
import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.runs
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
@ -25,6 +29,7 @@ class RemovePasswordViewModelTest : BaseViewModelTest() {
private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE) private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
private val authRepository: AuthRepository = mockk { private val authRepository: AuthRepository = mockk {
every { userStateFlow } returns mutableUserStateFlow every { userStateFlow } returns mutableUserStateFlow
every { logout(reason = any()) } just runs
} }
@Test @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 @Test
fun `InputChanged updates the state`() { fun `InputChanged updates the state`() {
val input = "123" val input = "123"
@ -136,12 +229,17 @@ class RemovePasswordViewModelTest : BaseViewModelTest() {
} }
private const val ORGANIZATION_NAME: String = "My org" private const val ORGANIZATION_NAME: String = "My org"
private const val KEY_CONNECTOR_URL: String = "bitwarden.com"
private val DEFAULT_STATE = RemovePasswordState( private val DEFAULT_STATE = RemovePasswordState(
input = "", input = "",
dialogState = null, dialogState = null,
description = R.string description = R.string
.organization_is_using_sso_with_a_self_hosted_key_server .password_no_longer_required_confirm_domain.asText(),
.asText(ORGANIZATION_NAME), 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" private const val USER_ID: String = "user_id"
@ -158,11 +256,12 @@ private val DEFAULT_ACCOUNT = UserState.Account(
isBiometricsEnabled = false, isBiometricsEnabled = false,
organizations = listOf( organizations = listOf(
Organization( Organization(
id = "orgId", id = "mockId-1",
name = ORGANIZATION_NAME, name = ORGANIZATION_NAME,
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = true, shouldUseKeyConnector = true,
role = OrganizationType.USER, role = OrganizationType.USER,
keyConnectorUrl = KEY_CONNECTOR_URL,
), ),
), ),
needsMasterPassword = false, needsMasterPassword = false,

View File

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

View File

@ -997,6 +997,7 @@ private val DEFAULT_USER_STATE = UserState(
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
shouldManageResetPassword = false, shouldManageResetPassword = false,
role = OrganizationType.USER, role = OrganizationType.USER,
keyConnectorUrl = null,
), ),
Organization( Organization(
id = "organizationAdmin", id = "organizationAdmin",
@ -1004,6 +1005,7 @@ private val DEFAULT_USER_STATE = UserState(
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
shouldManageResetPassword = false, shouldManageResetPassword = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
Organization( Organization(
id = "organizationOwner", id = "organizationOwner",
@ -1011,6 +1013,7 @@ private val DEFAULT_USER_STATE = UserState(
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
shouldManageResetPassword = false, shouldManageResetPassword = false,
role = OrganizationType.OWNER, role = OrganizationType.OWNER,
keyConnectorUrl = null,
), ),
Organization( Organization(
id = "organizationCustom", id = "organizationCustom",
@ -1018,6 +1021,7 @@ private val DEFAULT_USER_STATE = UserState(
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
shouldManageResetPassword = false, shouldManageResetPassword = false,
role = OrganizationType.CUSTOM, role = OrganizationType.CUSTOM,
keyConnectorUrl = null,
), ),
), ),
needsMasterPassword = false, needsMasterPassword = false,

View File

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

View File

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

View File

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

View File

@ -498,6 +498,7 @@ private val DEFAULT_USER_STATE = UserState(
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
Organization( Organization(
id = "mockOrganizationId-2", id = "mockOrganizationId-2",
@ -505,6 +506,7 @@ private val DEFAULT_USER_STATE = UserState(
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
Organization( Organization(
id = "mockOrganizationId-3", id = "mockOrganizationId-3",
@ -512,6 +514,7 @@ private val DEFAULT_USER_STATE = UserState(
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
trustedDevice = null, trustedDevice = null,

View File

@ -108,6 +108,7 @@ private fun createMockUserState(hasOrganizations: Boolean = true): UserState =
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
Organization( Organization(
id = "mockOrganizationId-2", id = "mockOrganizationId-2",
@ -115,6 +116,7 @@ private fun createMockUserState(hasOrganizations: Boolean = true): UserState =
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
Organization( Organization(
id = "mockOrganizationId-3", id = "mockOrganizationId-3",
@ -122,6 +124,7 @@ private fun createMockUserState(hasOrganizations: Boolean = true): UserState =
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
) )
} else { } else {

View File

@ -243,6 +243,7 @@ class VaultViewModelTest : BaseViewModelTest() {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -329,6 +330,7 @@ class VaultViewModelTest : BaseViewModelTest() {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -570,6 +572,7 @@ class VaultViewModelTest : BaseViewModelTest() {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
), ),

View File

@ -82,6 +82,7 @@ class UserStateExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -109,6 +110,7 @@ class UserStateExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -140,6 +142,7 @@ class UserStateExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -171,6 +174,7 @@ class UserStateExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -217,6 +221,7 @@ class UserStateExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -261,6 +266,7 @@ class UserStateExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -309,6 +315,7 @@ class UserStateExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -387,6 +394,7 @@ class UserStateExtensionsTest {
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
shouldManageResetPassword = false, shouldManageResetPassword = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
Organization( Organization(
id = "organizationId-A", id = "organizationId-A",
@ -394,6 +402,7 @@ class UserStateExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
trustedDevice = null, trustedDevice = null,
@ -445,6 +454,7 @@ class UserStateExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
Organization( Organization(
id = "organizationId-A", id = "organizationId-A",
@ -452,6 +462,7 @@ class UserStateExtensionsTest {
shouldManageResetPassword = false, shouldManageResetPassword = false,
shouldUseKeyConnector = false, shouldUseKeyConnector = false,
role = OrganizationType.ADMIN, role = OrganizationType.ADMIN,
keyConnectorUrl = null,
), ),
), ),
trustedDevice = null, trustedDevice = null,

View File

@ -6,6 +6,7 @@ import com.bitwarden.network.model.OrganizationKeysResponseJson
import com.bitwarden.network.model.OrganizationResetPasswordEnrollRequestJson import com.bitwarden.network.model.OrganizationResetPasswordEnrollRequestJson
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT import retrofit2.http.PUT
import retrofit2.http.Path import retrofit2.http.Path
@ -38,4 +39,12 @@ interface AuthenticatedOrganizationApi {
suspend fun getOrganizationKeys( suspend fun getOrganizationKeys(
@Path("id") organizationId: String, @Path("id") organizationId: String,
): NetworkResult<OrganizationKeysResponseJson> ): 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( suspend fun getVerifiedOrganizationDomainSsoDetails(
email: String, email: String,
): Result<VerifiedOrganizationDomainSsoDetailsResponse> ): 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() .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") organizationService.getVerifiedOrganizationDomainSsoDetails("example@bitwarden.com")
assertTrue(result.isFailure) 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 = """ private const val ORGANIZATION_AUTO_ENROLL_STATUS_JSON = """