mirror of
https://github.com/bitwarden/android.git
synced 2025-12-10 09:56:45 -06:00
[PM-18936] Show key connector domain (#5034)
This commit is contained in:
parent
33da0d8138
commit
bee09de972
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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?,
|
||||
)
|
||||
|
||||
@ -22,6 +22,7 @@ fun SyncResponseJson.Profile.Organization.toOrganization(): Organization =
|
||||
shouldUseKeyConnector = this.shouldUseKeyConnector,
|
||||
role = this.type,
|
||||
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
|
||||
keyConnectorUrl = this.keyConnectorUrl,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -283,6 +283,9 @@ class LoginWithDeviceViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// NO-OP: This result should not be possible here
|
||||
is LoginResult.ConfirmKeyConnectorDomain -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -350,6 +350,9 @@ class TwoFactorLoginViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// NO-OP: This result should not be possible here
|
||||
is LoginResult.ConfirmKeyConnectorDomain -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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),
|
||||
)
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -386,6 +386,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
||||
shouldManageResetPassword = false,
|
||||
shouldUseKeyConnector = true,
|
||||
role = OrganizationType.USER,
|
||||
keyConnectorUrl = "bitwarden.com",
|
||||
),
|
||||
),
|
||||
needsMasterPassword = false,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -4618,6 +4618,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
|
||||
shouldManageResetPassword = false,
|
||||
shouldUseKeyConnector = false,
|
||||
role = OrganizationType.ADMIN,
|
||||
keyConnectorUrl = null,
|
||||
),
|
||||
),
|
||||
isBiometricsEnabled = true,
|
||||
|
||||
@ -564,6 +564,7 @@ class CipherViewExtensionsTest {
|
||||
shouldManageResetPassword = false,
|
||||
shouldUseKeyConnector = false,
|
||||
role = OrganizationType.ADMIN,
|
||||
keyConnectorUrl = null,
|
||||
),
|
||||
),
|
||||
isBiometricsEnabled = true,
|
||||
|
||||
@ -3349,6 +3349,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
|
||||
shouldManageResetPassword = false,
|
||||
shouldUseKeyConnector = false,
|
||||
role = OrganizationType.OWNER,
|
||||
keyConnectorUrl = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -69,4 +69,9 @@ class OrganizationServiceImpl(
|
||||
),
|
||||
)
|
||||
.toResult()
|
||||
|
||||
override suspend fun leaveOrganization(organizationId: String): Result<Unit> =
|
||||
authenticatedOrganizationApi
|
||||
.leaveOrganization(organizationId = organizationId)
|
||||
.toResult()
|
||||
}
|
||||
|
||||
@ -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 = """
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user