[PM-6702] 6# Complete registration screen (#3622)

This commit is contained in:
André Bispo 2024-08-16 15:16:36 +01:00 committed by GitHub
parent 72e5aedccd
commit acb125b2b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1660 additions and 11 deletions

View file

@ -757,6 +757,7 @@ class AuthRepositoryImpl(
)
.flatMap { registerKeyResponse ->
if (emailVerificationToken == null) {
// TODO PM-6675: Remove register call and service implementation
identityService.register(
body = RegisterRequestJson(
email = email,

View file

@ -8,6 +8,8 @@ import androidx.navigation.navOptions
import androidx.navigation.navigation
import com.x8bit.bitwarden.ui.auth.feature.checkemail.checkEmailDestination
import com.x8bit.bitwarden.ui.auth.feature.checkemail.navigateToCheckEmail
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.completeRegistrationDestination
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.navigateToCompleteRegistration
import com.x8bit.bitwarden.ui.auth.feature.createaccount.createAccountDestination
import com.x8bit.bitwarden.ui.auth.feature.createaccount.navigateToCreateAccount
import com.x8bit.bitwarden.ui.auth.feature.enterprisesignon.enterpriseSignOnDestination
@ -65,7 +67,11 @@ fun NavGraphBuilder.authGraph(
startRegistrationDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToCompleteRegistration = { emailAddress, verificationToken ->
// TODO PR-3622 ADD NAVIGATION TO COMPLETE REGISTRATION
navController.navigateToCompleteRegistration(
emailAddress = emailAddress,
verificationToken = verificationToken,
fromEmail = false,
)
},
onNavigateToCheckEmail = { emailAddress ->
navController.navigateToCheckEmail(emailAddress)
@ -77,6 +83,12 @@ fun NavGraphBuilder.authGraph(
onNavigateBackToLanding = {
navController.popBackStack(route = LANDING_ROUTE, inclusive = false)
},)
completeRegistrationDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToLanding = {
navController.popBackStack(route = LANDING_ROUTE, inclusive = false)
},
)
enterpriseSignOnDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToSetPassword = { navController.navigateToSetPassword() },

View file

@ -0,0 +1,70 @@
package com.x8bit.bitwarden.ui.auth.feature.completeregistration
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.navArgument
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val EMAIL_ADDRESS: String = "email_address"
private const val VERIFICATION_TOKEN: String = "verification_token"
private const val FROM_EMAIL: String = "from_email"
private const val COMPLETE_REGISTRATION_PREFIX = "complete_registration"
private const val COMPLETE_REGISTRATION_ROUTE =
"$COMPLETE_REGISTRATION_PREFIX/{$EMAIL_ADDRESS}/{$VERIFICATION_TOKEN}/{$FROM_EMAIL}"
/**
* Class to retrieve complete registration arguments from the [SavedStateHandle].
*/
@OmitFromCoverage
data class CompleteRegistrationArgs(
val emailAddress: String,
val verificationToken: String,
val fromEmail: Boolean,
) {
constructor(savedStateHandle: SavedStateHandle) : this(
emailAddress = checkNotNull(savedStateHandle.get<String>(EMAIL_ADDRESS)),
verificationToken = checkNotNull(savedStateHandle.get<String>(VERIFICATION_TOKEN)),
fromEmail = checkNotNull(savedStateHandle.get<Boolean>(FROM_EMAIL)),
)
}
/**
* Navigate to the complete registration screen.
*/
fun NavController.navigateToCompleteRegistration(
emailAddress: String,
verificationToken: String,
fromEmail: Boolean,
navOptions: NavOptions? = null,
) {
this.navigate(
"$COMPLETE_REGISTRATION_PREFIX/$emailAddress/$verificationToken/$fromEmail",
navOptions,
)
}
/**
* Add the complete registration screen to the nav graph.
*/
fun NavGraphBuilder.completeRegistrationDestination(
onNavigateBack: () -> Unit,
onNavigateToLanding: () -> Unit,
) {
composableWithSlideTransitions(
route = COMPLETE_REGISTRATION_ROUTE,
arguments = listOf(
navArgument(EMAIL_ADDRESS) { type = NavType.StringType },
navArgument(VERIFICATION_TOKEN) { type = NavType.StringType },
navArgument(FROM_EMAIL) { type = NavType.BoolType },
),
) {
CompleteRegistrationScreen(
onNavigateBack = onNavigateBack,
onNavigateToLanding = onNavigateToLanding,
)
}
}

View file

@ -0,0 +1,236 @@
package com.x8bit.bitwarden.ui.auth.feature.completeregistration
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ContinueWithBreachedPasswordClick
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CreateAccountClick
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ErrorDialogDismiss
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordHintChange
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordInputChange
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.asText
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.dialog.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenSwitch
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
/**
* Top level composable for the complete registration screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun CompleteRegistrationScreen(
onNavigateBack: () -> Unit,
onNavigateToLanding: () -> Unit,
intentManager: IntentManager = LocalIntentManager.current,
viewModel: CompleteRegistrationViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
EventsEffect(viewModel) { event ->
when (event) {
is CompleteRegistrationEvent.NavigateBack -> onNavigateBack.invoke()
is CompleteRegistrationEvent.ShowToast -> {
Toast.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT).show()
}
is CompleteRegistrationEvent.NavigateToLanding -> {
onNavigateToLanding()
}
}
}
// Show dialog if needed:
when (val dialog = state.dialog) {
is CompleteRegistrationDialog.Error -> {
BitwardenBasicDialog(
visibilityState = dialog.state,
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(ErrorDialogDismiss) }
},
)
}
is CompleteRegistrationDialog.HaveIBeenPwned -> {
BitwardenTwoButtonDialog(
title = dialog.title(),
message = dialog.message(),
confirmButtonText = stringResource(id = R.string.yes),
dismissButtonText = stringResource(id = R.string.no),
onConfirmClick = remember(viewModel) {
{ viewModel.trySendAction(ContinueWithBreachedPasswordClick) }
},
onDismissClick = remember(viewModel) {
{ viewModel.trySendAction(ErrorDialogDismiss) }
},
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(ErrorDialogDismiss) }
},
)
}
CompleteRegistrationDialog.Loading -> {
BitwardenLoadingDialog(
visibilityState = LoadingDialogState.Shown(R.string.create_account.asText()),
)
}
null -> Unit
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.set_password),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(CloseClick) }
},
actions = {
BitwardenTextButton(
label = stringResource(id = R.string.create_account),
onClick = remember(viewModel) {
{ viewModel.trySendAction(CreateAccountClick) }
},
modifier = Modifier.testTag("CreateAccountButton"),
)
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.imePadding()
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(16.dp))
@Suppress("MaxLineLength")
Text(
text = stringResource(
id = R.string.follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account,
state.userEmail,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
var showPassword by rememberSaveable { mutableStateOf(false) }
BitwardenPasswordField(
label = stringResource(id = R.string.master_password),
showPassword = showPassword,
showPasswordChange = { showPassword = it },
value = state.passwordInput,
hint = state.passwordLengthLabel(),
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(PasswordInputChange(it)) }
},
modifier = Modifier
.testTag("MasterPasswordEntry")
.fillMaxWidth()
.padding(horizontal = 16.dp),
showPasswordTestTag = "PasswordVisibilityToggle",
)
Spacer(modifier = Modifier.height(8.dp))
PasswordStrengthIndicator(
modifier = Modifier.padding(horizontal = 16.dp),
state = state.passwordStrengthState,
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenPasswordField(
label = stringResource(id = R.string.retype_master_password),
value = state.confirmPasswordInput,
showPassword = showPassword,
showPasswordChange = { showPassword = it },
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(ConfirmPasswordInputChange(it)) }
},
modifier = Modifier
.testTag("ConfirmMasterPasswordEntry")
.fillMaxWidth()
.padding(horizontal = 16.dp),
showPasswordTestTag = "ConfirmPasswordVisibilityToggle",
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextField(
label = stringResource(id = R.string.master_password_hint),
value = state.passwordHintInput,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(PasswordHintChange(it)) }
},
hint = stringResource(id = R.string.master_password_hint_description),
modifier = Modifier
.testTag("MasterPasswordHintLabel")
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(24.dp))
BitwardenSwitch(
label = stringResource(id = R.string.check_known_data_breaches_for_this_password),
isChecked = state.isCheckDataBreachesToggled,
onCheckedChange = remember(viewModel) {
{ newState ->
viewModel.trySendAction(CheckDataBreachesToggle(newState = newState))
}
},
modifier = Modifier
.testTag("CheckExposedMasterPasswordToggle")
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}

View file

@ -0,0 +1,493 @@
package com.x8bit.bitwarden.ui.auth.feature.completeregistration
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ContinueWithBreachedPasswordClick
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CreateAccountClick
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ErrorDialogDismiss
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.Internal
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.Internal.ReceivePasswordStrengthResult
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordHintChange
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordInputChange
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.platform.base.util.isValidEmail
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import org.jetbrains.annotations.VisibleForTesting
import javax.inject.Inject
private const val KEY_STATE = "state"
private const val MIN_PASSWORD_LENGTH = 12
/**
* Models logic for the create account screen.
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class CompleteRegistrationViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val authRepository: AuthRepository,
private val environmentRepository: EnvironmentRepository,
private val specialCircumstanceManager: SpecialCircumstanceManager,
) : BaseViewModel<CompleteRegistrationState, CompleteRegistrationEvent, CompleteRegistrationAction>(
initialState = savedStateHandle[KEY_STATE]
?: run {
val args = CompleteRegistrationArgs(savedStateHandle)
CompleteRegistrationState(
userEmail = args.emailAddress,
emailVerificationToken = args.verificationToken,
fromEmail = args.fromEmail,
passwordInput = "",
confirmPasswordInput = "",
passwordHintInput = "",
isCheckDataBreachesToggled = true,
dialog = null,
passwordStrengthState = PasswordStrengthState.NONE,
)
},
) {
/**
* Keeps track of async request to get password strength. Should be cancelled
* when user input changes.
*/
private var passwordStrengthJob: Job = Job().apply { complete() }
init {
verifyEmailAddress()
// As state updates, write to saved state handle:
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
@VisibleForTesting
public override fun onCleared() {
// clean the specialCircumstance after being handled
specialCircumstanceManager.specialCircumstance = null
super.onCleared()
}
override fun handleAction(action: CompleteRegistrationAction) {
when (action) {
is CreateAccountClick -> handleCreateAccountClick()
is ConfirmPasswordInputChange -> handleConfirmPasswordInputChanged(action)
is PasswordHintChange -> handlePasswordHintChanged(action)
is PasswordInputChange -> handlePasswordInputChanged(action)
is CloseClick -> handleCloseClick()
is ErrorDialogDismiss -> handleDialogDismiss()
is CheckDataBreachesToggle -> handleCheckDataBreachesToggle(action)
is Internal.ReceiveRegisterResult -> {
handleReceiveRegisterAccountResult(action)
}
ContinueWithBreachedPasswordClick -> handleContinueWithBreachedPasswordClick()
is ReceivePasswordStrengthResult -> handlePasswordStrengthResult(action)
}
}
private fun verifyEmailAddress() {
if (!state.fromEmail) {
return
}
viewModelScope.launch {
sendEvent(
CompleteRegistrationEvent.ShowToast(
message = R.string.email_verified.asText(),
),
)
}
}
private fun handlePasswordStrengthResult(action: ReceivePasswordStrengthResult) {
when (val result = action.result) {
is PasswordStrengthResult.Success -> {
val updatedState = when (result.passwordStrength) {
PasswordStrength.LEVEL_0 -> PasswordStrengthState.WEAK_1
PasswordStrength.LEVEL_1 -> PasswordStrengthState.WEAK_2
PasswordStrength.LEVEL_2 -> PasswordStrengthState.WEAK_3
PasswordStrength.LEVEL_3 -> PasswordStrengthState.GOOD
PasswordStrength.LEVEL_4 -> PasswordStrengthState.STRONG
}
mutableStateFlow.update { oldState ->
oldState.copy(
passwordStrengthState = updatedState,
)
}
}
PasswordStrengthResult.Error -> Unit
}
}
@Suppress("LongMethod", "MaxLineLength")
private fun handleReceiveRegisterAccountResult(
action: Internal.ReceiveRegisterResult,
) {
when (val registerAccountResult = action.registerResult) {
// TODO PM-6675: Remove captcha from RegisterResult when old flow gets removed
is RegisterResult.CaptchaRequired -> {
throw IllegalStateException(
"Captcha should not be required for the new registration flow",
)
}
is RegisterResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = CompleteRegistrationDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = registerAccountResult.errorMessage?.asText()
?: R.string.generic_error_message.asText(),
),
),
)
}
}
is RegisterResult.Success -> {
mutableStateFlow.update { it.copy(dialog = null) }
sendEvent(
CompleteRegistrationEvent.NavigateToLanding,
)
}
RegisterResult.DataBreachFound -> {
mutableStateFlow.update {
it.copy(
dialog = CompleteRegistrationDialog.HaveIBeenPwned(
title = R.string.exposed_master_password.asText(),
message = R.string.password_found_in_a_data_breach_alert_description.asText(),
),
)
}
}
RegisterResult.DataBreachAndWeakPassword -> {
mutableStateFlow.update {
it.copy(
dialog = CompleteRegistrationDialog.HaveIBeenPwned(
title = R.string.weak_and_exposed_master_password.asText(),
message = R.string.weak_password_identified_and_found_in_a_data_breach_alert_description.asText(),
),
)
}
}
RegisterResult.WeakPassword -> {
mutableStateFlow.update {
it.copy(
dialog = CompleteRegistrationDialog.HaveIBeenPwned(
title = R.string.weak_master_password.asText(),
message = R.string.weak_password_identified_use_a_strong_password_to_protect_your_account.asText(),
),
)
}
}
}
}
private fun handleCheckDataBreachesToggle(action: CheckDataBreachesToggle) {
mutableStateFlow.update {
it.copy(isCheckDataBreachesToggled = action.newState)
}
}
private fun handleDialogDismiss() {
mutableStateFlow.update {
it.copy(dialog = null)
}
}
private fun handleCloseClick() {
sendEvent(CompleteRegistrationEvent.NavigateBack)
}
private fun handlePasswordHintChanged(action: PasswordHintChange) {
mutableStateFlow.update { it.copy(passwordHintInput = action.input) }
}
private fun handlePasswordInputChanged(action: PasswordInputChange) {
// Update input:
mutableStateFlow.update { it.copy(passwordInput = action.input) }
// Update password strength:
passwordStrengthJob.cancel()
if (action.input.isEmpty()) {
mutableStateFlow.update {
it.copy(passwordStrengthState = PasswordStrengthState.NONE)
}
} else {
passwordStrengthJob = viewModelScope.launch {
val result = authRepository.getPasswordStrength(
email = state.userEmail,
password = action.input,
)
trySendAction(ReceivePasswordStrengthResult(result))
}
}
}
private fun handleConfirmPasswordInputChanged(action: ConfirmPasswordInputChange) {
mutableStateFlow.update { it.copy(confirmPasswordInput = action.input) }
}
private fun handleCreateAccountClick() = when {
state.userEmail.isBlank() -> {
val dialog = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.validation_field_required
.asText(R.string.email_address.asText()),
)
mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) }
}
!state.userEmail.isValidEmail() -> {
val dialog = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.invalid_email.asText(),
)
mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) }
}
state.passwordInput.length < MIN_PASSWORD_LENGTH -> {
val dialog = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_length_val_message_x.asText(MIN_PASSWORD_LENGTH),
)
mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) }
}
state.passwordInput != state.confirmPasswordInput -> {
val dialog = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_confirmation_val_message.asText(),
)
mutableStateFlow.update { it.copy(dialog = CompleteRegistrationDialog.Error(dialog)) }
}
else -> {
submitRegisterAccountRequest(
shouldCheckForDataBreaches = state.isCheckDataBreachesToggled,
shouldIgnorePasswordStrength = false,
)
}
}
private fun handleContinueWithBreachedPasswordClick() {
submitRegisterAccountRequest(
shouldCheckForDataBreaches = false,
shouldIgnorePasswordStrength = true,
)
}
private fun submitRegisterAccountRequest(
shouldCheckForDataBreaches: Boolean,
shouldIgnorePasswordStrength: Boolean,
) {
mutableStateFlow.update {
it.copy(dialog = CompleteRegistrationDialog.Loading)
}
viewModelScope.launch {
// Update region accordingly to a user email
environmentRepository.loadEnvironmentForEmail(state.userEmail)
val result = authRepository.register(
shouldCheckDataBreaches = shouldCheckForDataBreaches,
isMasterPasswordStrong = shouldIgnorePasswordStrength ||
state.isMasterPasswordStrong,
emailVerificationToken = state.emailVerificationToken,
email = state.userEmail,
masterPassword = state.passwordInput,
masterPasswordHint = state.passwordHintInput.ifBlank { null },
captchaToken = null,
)
sendAction(
Internal.ReceiveRegisterResult(
registerResult = result,
),
)
}
}
}
/**
* UI state for the complete registration screen.
*/
@Parcelize
data class CompleteRegistrationState(
val userEmail: String,
val emailVerificationToken: String,
val fromEmail: Boolean,
val passwordInput: String,
val confirmPasswordInput: String,
val passwordHintInput: String,
val isCheckDataBreachesToggled: Boolean,
val dialog: CompleteRegistrationDialog?,
val passwordStrengthState: PasswordStrengthState,
) : Parcelable {
val passwordLengthLabel: Text
// Have to concat a few strings here, resulting string is:
// Important: Your master password cannot be recovered if you forget it! 12
// characters minimum
@Suppress("MaxLineLength")
get() = R.string.important.asText()
.concat(
": ".asText(),
R.string.your_master_password_cannot_be_recovered_if_you_forget_it_x_characters_minimum
.asText(MIN_PASSWORD_LENGTH),
)
/**
* Whether or not the provided master password is considered strong.
*/
val isMasterPasswordStrong: Boolean
get() = when (passwordStrengthState) {
PasswordStrengthState.NONE,
PasswordStrengthState.WEAK_1,
PasswordStrengthState.WEAK_2,
PasswordStrengthState.WEAK_3,
-> false
PasswordStrengthState.GOOD,
PasswordStrengthState.STRONG,
-> true
}
}
/**
* Models dialogs that can be displayed on the complete registration screen.
*/
sealed class CompleteRegistrationDialog : Parcelable {
/**
* Loading dialog.
*/
@Parcelize
data object Loading : CompleteRegistrationDialog()
/**
* Confirm the user wants to continue with potentially breached password.
*
* @param title The title for the HaveIBeenPwned dialog.
* @param message The message for the HaveIBeenPwned dialog.
*/
@Parcelize
data class HaveIBeenPwned(
val title: Text,
val message: Text,
) : CompleteRegistrationDialog()
/**
* General error dialog with an OK button.
*/
@Parcelize
data class Error(val state: BasicDialogState.Shown) : CompleteRegistrationDialog()
}
/**
* Models events for the complete registration screen.
*/
sealed class CompleteRegistrationEvent {
/**
* Navigate back to previous screen.
*/
data object NavigateBack : CompleteRegistrationEvent()
/**
* Show a toast with the given message.
*/
data class ShowToast(
val message: Text,
) : CompleteRegistrationEvent()
/**
* Navigates to the landing screen.
*/
data object NavigateToLanding : CompleteRegistrationEvent()
}
/**
* Models actions for the complete registration screen.
*/
sealed class CompleteRegistrationAction {
/**
* User clicked create account.
*/
data object CreateAccountClick : CompleteRegistrationAction()
/**
* User clicked close.
*/
data object CloseClick : CompleteRegistrationAction()
/**
* User clicked "Yes" when being asked if they are sure they want to use a breached password.
*/
data object ContinueWithBreachedPasswordClick : CompleteRegistrationAction()
/**
* Password input changed.
*/
data class PasswordInputChange(val input: String) : CompleteRegistrationAction()
/**
* Confirm password input changed.
*/
data class ConfirmPasswordInputChange(val input: String) : CompleteRegistrationAction()
/**
* Password hint input changed.
*/
data class PasswordHintChange(val input: String) : CompleteRegistrationAction()
/**
* User dismissed the error dialog.
*/
data object ErrorDialogDismiss : CompleteRegistrationAction()
/**
* User tapped check data breaches toggle.
*/
data class CheckDataBreachesToggle(val newState: Boolean) : CompleteRegistrationAction()
/**
* Models actions that the [CompleteRegistrationViewModel] itself might send.
*/
sealed class Internal : CompleteRegistrationAction() {
/**
* Indicates a [RegisterResult] has been received.
*/
data class ReceiveRegisterResult(
val registerResult: RegisterResult,
) : Internal()
/**
* Indicates a password strength result has been received.
*/
data class ReceivePasswordStrengthResult(
val result: PasswordStrengthResult,
) : Internal()
}
}

View file

@ -1,4 +1,4 @@
package com.x8bit.bitwarden.ui.auth.feature.createaccount
package com.x8bit.bitwarden.ui.auth.feature.completeregistration
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState

View file

@ -49,6 +49,7 @@ import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthIndicator
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick

View file

@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange

View file

@ -18,6 +18,7 @@ import androidx.navigation.navOptions
import com.x8bit.bitwarden.ui.auth.feature.auth.AUTH_GRAPH_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.auth.authGraph
import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuthGraph
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.navigateToCompleteRegistration
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.RESET_PASSWORD_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.navigateToResetPasswordGraph
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.resetPasswordDestination
@ -138,6 +139,15 @@ fun RootNavScreen(
when (val currentState = state) {
RootNavState.Auth -> navController.navigateToAuthGraph(rootNavOptions)
RootNavState.AuthWithWelcome -> navController.navigateToWelcome(rootNavOptions)
is RootNavState.CompleteOngoingRegistration -> {
navController.navigateToAuthGraph(rootNavOptions)
navController.navigateToCompleteRegistration(
emailAddress = currentState.email,
verificationToken = currentState.verificationToken,
fromEmail = currentState.fromEmail,
)
}
RootNavState.ResetPassword -> navController.navigateToResetPasswordGraph(rootNavOptions)
RootNavState.SetPassword -> navController.navigateToSetPassword(rootNavOptions)
RootNavState.Splash -> navController.navigateToSplash(rootNavOptions)
@ -191,11 +201,6 @@ fun RootNavScreen(
navOptions = rootNavOptions,
)
}
is RootNavState.CompleteOngoingRegistration -> {
navController.navigateToAuthGraph(rootNavOptions)
// TODO PR-3622: add navigation to complete registration
}
}
}
}

View file

@ -34,7 +34,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthIndicator
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthIndicator
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledTonalButton

View file

@ -15,7 +15,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthState
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText

View file

@ -0,0 +1,249 @@
package com.x8bit.bitwarden.ui.auth.feature.completeregistration
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ContinueWithBreachedPasswordClick
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CreateAccountClick
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ErrorDialogDismiss
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordHintChange
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordInputChange
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
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
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class CompleteRegistrationScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private var onNavigateToLandingCalled = false
private val intentManager = mockk<IntentManager>(relaxed = true) {
every { startCustomTabsActivity(any()) } just runs
every { startActivity(any()) } just runs
}
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mutableEventFlow = bufferedMutableSharedFlow<CompleteRegistrationEvent>()
private val viewModel = mockk<CompleteRegistrationViewModel>(relaxed = true) {
every { stateFlow } returns mutableStateFlow
every { eventFlow } returns mutableEventFlow
every { trySendAction(any()) } just runs
}
@Before
fun setup() {
composeTestRule.setContent {
CompleteRegistrationScreen(
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToLanding = { onNavigateToLandingCalled = true },
intentManager = intentManager,
viewModel = viewModel,
)
}
}
@Test
fun `app bar submit click should send CreateAccountClick action`() {
composeTestRule.onNodeWithText("Create account").performClick()
verify { viewModel.trySendAction(CreateAccountClick) }
}
@Test
fun `close click should send CloseClick action`() {
composeTestRule.onNodeWithContentDescription("Close").performClick()
verify { viewModel.trySendAction(CloseClick) }
}
@Test
fun `check data breaches click should send CheckDataBreachesToggle action`() {
composeTestRule
.onNodeWithText("Check known data breaches for this password")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(CheckDataBreachesToggle(false)) }
}
@Test
fun `NavigateBack event should invoke navigate back lambda`() {
mutableEventFlow.tryEmit(CompleteRegistrationEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `NavigateToLogin event should invoke navigate login lambda`() {
mutableEventFlow.tryEmit(CompleteRegistrationEvent.NavigateToLanding)
assertTrue(onNavigateToLandingCalled)
}
@Test
fun `password input change should send PasswordInputChange action`() {
composeTestRule.onNodeWithText("Master password").performTextInput(TEST_INPUT)
verify { viewModel.trySendAction(PasswordInputChange(TEST_INPUT)) }
}
@Test
fun `confirm password input change should send ConfirmPasswordInputChange action`() {
composeTestRule.onNodeWithText("Re-type master password").performTextInput(TEST_INPUT)
verify { viewModel.trySendAction(ConfirmPasswordInputChange(TEST_INPUT)) }
}
@Test
fun `password hint input change should send PasswordHintChange action`() {
composeTestRule
.onNodeWithText("Master password hint (optional)")
.performTextInput(TEST_INPUT)
verify { viewModel.trySendAction(PasswordHintChange(TEST_INPUT)) }
}
@Test
fun `clicking OK on the error dialog should send ErrorDialogDismiss action`() {
mutableStateFlow.update {
it.copy(
dialog = CompleteRegistrationDialog.Error(
BasicDialogState.Shown(
title = "title".asText(),
message = "message".asText(),
),
),
)
}
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(ErrorDialogDismiss) }
}
@Test
fun `clicking No on the HIBP dialog should send ErrorDialogDismiss action`() {
mutableStateFlow.update {
it.copy(dialog = createHaveIBeenPwned())
}
composeTestRule
.onAllNodesWithText("No")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(ErrorDialogDismiss) }
}
@Test
fun `clicking Yes on the HIBP dialog should send ContinueWithBreachedPasswordClick action`() {
mutableStateFlow.update {
it.copy(dialog = createHaveIBeenPwned())
}
composeTestRule
.onAllNodesWithText("Yes")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(ContinueWithBreachedPasswordClick) }
}
@Test
fun `when BasicDialogState is Shown should show dialog`() {
mutableStateFlow.update {
it.copy(
dialog = CompleteRegistrationDialog.Error(
BasicDialogState.Shown(
title = "title".asText(),
message = "message".asText(),
),
),
)
}
composeTestRule.onNode(isDialog()).assertIsDisplayed()
}
@Test
fun `password strength should change as state changes`() {
mutableStateFlow.update {
DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.WEAK_1)
}
composeTestRule.onNodeWithText("Weak").assertIsDisplayed()
mutableStateFlow.update {
DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.WEAK_2)
}
composeTestRule.onNodeWithText("Weak").assertIsDisplayed()
mutableStateFlow.update {
DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.WEAK_3)
}
composeTestRule.onNodeWithText("Weak").assertIsDisplayed()
mutableStateFlow.update {
DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.GOOD)
}
composeTestRule.onNodeWithText("Good").assertIsDisplayed()
mutableStateFlow.update {
DEFAULT_STATE.copy(passwordStrengthState = PasswordStrengthState.STRONG)
}
composeTestRule.onNodeWithText("Strong").assertIsDisplayed()
}
@Test
fun `toggling one password field visibility should toggle the other`() {
// should start with 2 Show buttons:
composeTestRule
.onAllNodesWithContentDescription("Show")
.assertCountEquals(2)[0]
.performClick()
// after clicking there should be no Show buttons:
composeTestRule
.onAllNodesWithContentDescription("Show")
.assertCountEquals(0)
// and there should be 2 hide buttons now, and we'll click the second one:
composeTestRule
.onAllNodesWithContentDescription("Hide")
.assertCountEquals(2)[1]
.performClick()
// then there should be two show buttons again
composeTestRule
.onAllNodesWithContentDescription("Show")
.assertCountEquals(2)
}
companion object {
private const val EMAIL = "test@test.com"
private const val TOKEN = "token"
private const val TEST_INPUT = "input"
private val DEFAULT_STATE = CompleteRegistrationState(
userEmail = EMAIL,
emailVerificationToken = TOKEN,
fromEmail = true,
passwordInput = "",
confirmPasswordInput = "",
passwordHintInput = "",
isCheckDataBreachesToggled = true,
dialog = null,
passwordStrengthState = PasswordStrengthState.NONE,
)
}
}

View file

@ -0,0 +1,18 @@
package com.x8bit.bitwarden.ui.auth.feature.completeregistration
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
/**
* Creates a mock [CompleteRegistrationDialog.HaveIBeenPwned].
*/
fun createHaveIBeenPwned(
title: Text = R.string.weak_and_exposed_master_password.asText(),
message: Text = R.string.weak_password_identified_and_found_in_a_data_breach_alert_description
.asText(),
): CompleteRegistrationDialog.HaveIBeenPwned =
CompleteRegistrationDialog.HaveIBeenPwned(
title = title,
message = message,
)

View file

@ -0,0 +1,540 @@
package com.x8bit.bitwarden.ui.auth.feature.completeregistration
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import app.cash.turbine.turbineScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_0
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_1
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_2
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_3
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_4
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.ConfirmPasswordInputChange
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.Internal.ReceivePasswordStrengthResult
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordHintChange
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.PasswordInputChange
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@Suppress("LargeClass")
class CompleteRegistrationViewModelTest : BaseViewModelTest() {
/**
* Saved state handle that has valid inputs. Useful for tests that want to test things
* after the user has entered all valid inputs.
*/
private val mockAuthRepository = mockk<AuthRepository>()
private val fakeEnvironmentRepository = FakeEnvironmentRepository()
private val specialCircumstanceManager: SpecialCircumstanceManager =
SpecialCircumstanceManagerImpl()
private var viewmodelVerifyEmailCalled = false
@BeforeEach
fun setUp() {
mockkStatic(::generateUriForCaptcha)
}
@AfterEach
fun tearDown() {
unmockkStatic(::generateUriForCaptcha)
}
@Test
fun `initial state should be correct`() {
val viewModel = createCompleteRegistrationViewModel(DEFAULT_STATE)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `onCleared should erase specialCircumstance`() = runTest {
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.CompleteRegistration(
completeRegistrationData = CompleteRegistrationData(
email = EMAIL,
verificationToken = TOKEN,
fromEmail = true,
),
System.currentTimeMillis(),
)
val viewModel = CompleteRegistrationViewModel(
savedStateHandle = SavedStateHandle(mapOf("state" to DEFAULT_STATE)),
authRepository = mockAuthRepository,
environmentRepository = fakeEnvironmentRepository,
specialCircumstanceManager = specialCircumstanceManager,
)
viewModel.onCleared()
assertTrue(specialCircumstanceManager.specialCircumstance == null)
}
@Test
fun `CreateAccountClick with password below 12 chars should show password length dialog`() =
runTest {
val input = "abcdefghikl"
coEvery {
mockAuthRepository.getPasswordStrength(EMAIL, input)
} returns PasswordStrengthResult.Error
val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(PasswordInputChange(input))
val expectedState = DEFAULT_STATE.copy(
passwordInput = input,
dialog = CompleteRegistrationDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_length_val_message_x.asText(12),
),
),
)
viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
@Test
fun `CreateAccountClick with passwords not matching should show password match dialog`() =
runTest {
coEvery {
mockAuthRepository.getPasswordStrength(EMAIL, PASSWORD)
} returns PasswordStrengthResult.Error
val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(PasswordInputChange(PASSWORD))
val expectedState = DEFAULT_STATE.copy(
userEmail = EMAIL,
passwordInput = PASSWORD,
dialog = CompleteRegistrationDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.master_password_confirmation_val_message.asText(),
),
),
)
viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick)
viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem())
}
}
@Test
fun `CreateAccountClick with all inputs valid should show and hide loading dialog`() = runTest {
val repo = mockk<AuthRepository> {
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
emailVerificationToken = TOKEN,
captchaToken = null,
shouldCheckDataBreaches = false,
isMasterPasswordStrong = true,
)
} returns RegisterResult.Success(captchaToken = CAPTCHA_BYPASS_TOKEN)
}
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE, repo)
turbineScope {
val stateFlow = viewModel.stateFlow.testIn(backgroundScope)
val eventFlow = viewModel.eventFlow.testIn(backgroundScope)
assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem())
viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick)
assertEquals(
VALID_INPUT_STATE.copy(dialog = CompleteRegistrationDialog.Loading),
stateFlow.awaitItem(),
)
assertEquals(
CompleteRegistrationEvent.NavigateToLanding,
eventFlow.awaitItem(),
)
// Make sure loading dialog is hidden:
assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem())
}
}
@Test
fun `CreateAccountClick register returns error should update errorDialogState`() = runTest {
val repo = mockk<AuthRepository> {
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
emailVerificationToken = TOKEN,
captchaToken = null,
shouldCheckDataBreaches = false,
isMasterPasswordStrong = true,
)
} returns RegisterResult.Error(errorMessage = "mock_error")
}
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE, repo)
viewModel.stateFlow.test {
assertEquals(VALID_INPUT_STATE, awaitItem())
viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick)
assertEquals(
VALID_INPUT_STATE.copy(dialog = CompleteRegistrationDialog.Loading),
awaitItem(),
)
assertEquals(
VALID_INPUT_STATE.copy(
dialog = CompleteRegistrationDialog.Error(
BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = "mock_error".asText(),
),
),
),
awaitItem(),
)
}
}
@Test
fun `CreateAccountClick register returns Success should emit NavigateToLogin`() = runTest {
val repo = mockk<AuthRepository> {
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
emailVerificationToken = TOKEN,
captchaToken = null,
shouldCheckDataBreaches = false,
isMasterPasswordStrong = true,
)
} returns RegisterResult.Success(captchaToken = CAPTCHA_BYPASS_TOKEN)
}
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE, repo)
viewModel.eventFlow.test {
viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick)
assertEquals(
CompleteRegistrationEvent.NavigateToLanding,
awaitItem(),
)
}
}
@Test
fun `ContinueWithBreachedPasswordClick should call repository with checkDataBreaches false`() {
val repo = mockk<AuthRepository> {
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
emailVerificationToken = TOKEN,
captchaToken = null,
shouldCheckDataBreaches = false,
isMasterPasswordStrong = true,
)
} returns RegisterResult.Error(null)
}
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE, repo)
viewModel.trySendAction(CompleteRegistrationAction.ContinueWithBreachedPasswordClick)
coVerify {
repo.register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
emailVerificationToken = TOKEN,
captchaToken = null,
shouldCheckDataBreaches = false,
isMasterPasswordStrong = true,
)
}
}
@Test
fun `CreateAccountClick register returns ShowDataBreaches should show HaveIBeenPwned dialog`() =
runTest {
mockAuthRepository.apply {
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
emailVerificationToken = TOKEN,
captchaToken = null,
shouldCheckDataBreaches = true,
isMasterPasswordStrong = true,
)
} returns RegisterResult.DataBreachFound
}
val initialState = VALID_INPUT_STATE.copy(
isCheckDataBreachesToggled = true,
)
val viewModel = createCompleteRegistrationViewModel(
completeRegistrationState = initialState,
)
viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick)
viewModel.stateFlow.test {
assertEquals(
initialState.copy(
dialog = createHaveIBeenPwned(
title = R.string.exposed_master_password.asText(),
message = R.string.password_found_in_a_data_breach_alert_description
.asText(),
),
),
awaitItem(),
)
}
}
@Test
@Suppress("MaxLineLength")
fun `CreateAccountClick register returns DataBreachAndWeakPassword should show HaveIBeenPwned dialog`() =
runTest {
mockAuthRepository.apply {
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
emailVerificationToken = TOKEN,
captchaToken = null,
shouldCheckDataBreaches = true,
isMasterPasswordStrong = false,
)
} returns RegisterResult.DataBreachAndWeakPassword
}
val initialState = VALID_INPUT_STATE.copy(
passwordStrengthState = PasswordStrengthState.WEAK_1,
isCheckDataBreachesToggled = true,
)
val viewModel =
createCompleteRegistrationViewModel(completeRegistrationState = initialState)
viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick)
viewModel.stateFlow.test {
assertEquals(
initialState.copy(dialog = createHaveIBeenPwned()),
awaitItem(),
)
}
}
@Test
@Suppress("MaxLineLength")
fun `CreateAccountClick register returns WeakPassword should show HaveIBeenPwned dialog`() =
runTest {
mockAuthRepository.apply {
coEvery {
register(
email = EMAIL,
masterPassword = PASSWORD,
masterPasswordHint = null,
emailVerificationToken = TOKEN,
captchaToken = null,
shouldCheckDataBreaches = true,
isMasterPasswordStrong = false,
)
} returns RegisterResult.WeakPassword
}
val initialState = VALID_INPUT_STATE
.copy(
passwordStrengthState = PasswordStrengthState.WEAK_1,
isCheckDataBreachesToggled = true,
)
val viewModel =
createCompleteRegistrationViewModel(completeRegistrationState = initialState)
viewModel.trySendAction(CompleteRegistrationAction.CreateAccountClick)
viewModel.stateFlow.test {
assertEquals(
initialState.copy(
dialog = createHaveIBeenPwned(
title = R.string.weak_master_password.asText(),
message = R.string.weak_password_identified_use_a_strong_password_to_protect_your_account.asText(),
),
),
awaitItem(),
)
}
}
@Test
fun `CloseClick should emit NavigateBack`() = runTest {
val viewModel = createCompleteRegistrationViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(CloseClick)
assertEquals(CompleteRegistrationEvent.NavigateBack, awaitItem())
}
}
@Test
fun `On init should show toast if from email is true`() = runTest {
val viewModel = createCompleteRegistrationViewModel(
DEFAULT_STATE.copy(fromEmail = true),
)
viewModel.eventFlow.test {
assertEquals(
CompleteRegistrationEvent.ShowToast(R.string.email_verified.asText()),
awaitItem(),
)
}
}
@Test
fun `ConfirmPasswordInputChange update passwordInput`() = runTest {
val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(ConfirmPasswordInputChange(PASSWORD))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(confirmPasswordInput = PASSWORD), awaitItem())
}
}
@Test
fun `PasswordHintChange update passwordInput`() = runTest {
val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(PasswordHintChange(PASSWORD))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(passwordHintInput = PASSWORD), awaitItem())
}
}
@Test
fun `PasswordInputChange update passwordInput and call getPasswordStrength`() = runTest {
coEvery {
mockAuthRepository.getPasswordStrength(EMAIL, PASSWORD)
} returns PasswordStrengthResult.Error
val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(PasswordInputChange(PASSWORD))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(passwordInput = PASSWORD), awaitItem())
}
coVerify { mockAuthRepository.getPasswordStrength(EMAIL, PASSWORD) }
}
@Test
fun `CheckDataBreachesToggle should change isCheckDataBreachesToggled`() = runTest {
val viewModel = createCompleteRegistrationViewModel()
viewModel.trySendAction(CompleteRegistrationAction.CheckDataBreachesToggle(true))
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE.copy(isCheckDataBreachesToggled = true), awaitItem())
}
}
@Test
fun `ReceivePasswordStrengthResult should update password strength state`() = runTest {
val viewModel = createCompleteRegistrationViewModel()
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.NONE,
),
awaitItem(),
)
viewModel.trySendAction(
ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_0)),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.WEAK_1,
),
awaitItem(),
)
viewModel.trySendAction(
ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_1)),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.WEAK_2,
),
awaitItem(),
)
viewModel.trySendAction(
ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_2)),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.WEAK_3,
),
awaitItem(),
)
viewModel.trySendAction(
ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_3)),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.GOOD,
),
awaitItem(),
)
viewModel.trySendAction(
ReceivePasswordStrengthResult(PasswordStrengthResult.Success(LEVEL_4)),
)
assertEquals(
DEFAULT_STATE.copy(
passwordStrengthState = PasswordStrengthState.STRONG,
),
awaitItem(),
)
}
}
private fun createCompleteRegistrationViewModel(
completeRegistrationState: CompleteRegistrationState? = DEFAULT_STATE,
authRepository: AuthRepository = mockAuthRepository,
): CompleteRegistrationViewModel =
CompleteRegistrationViewModel(
savedStateHandle = SavedStateHandle(mapOf("state" to completeRegistrationState)),
authRepository = authRepository,
environmentRepository = fakeEnvironmentRepository,
specialCircumstanceManager = specialCircumstanceManager,
)
companion object {
private const val PASSWORD = "longenoughtpassword"
private const val EMAIL = "test@test.com"
private const val TOKEN = "token"
private const val CAPTCHA_BYPASS_TOKEN = "captcha_bypass"
private val DEFAULT_STATE = CompleteRegistrationState(
userEmail = EMAIL,
emailVerificationToken = TOKEN,
fromEmail = false,
passwordInput = "",
confirmPasswordInput = "",
passwordHintInput = "",
isCheckDataBreachesToggled = true,
dialog = null,
passwordStrengthState = PasswordStrengthState.NONE,
)
private val VALID_INPUT_STATE = CompleteRegistrationState(
userEmail = EMAIL,
emailVerificationToken = TOKEN,
fromEmail = false,
passwordInput = PASSWORD,
confirmPasswordInput = PASSWORD,
passwordHintInput = "",
isCheckDataBreachesToggled = false,
dialog = null,
passwordStrengthState = PasswordStrengthState.GOOD,
)
}
}

View file

@ -17,6 +17,7 @@ import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.core.net.toUri
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick

View file

@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CloseClick
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange

View file

@ -12,6 +12,9 @@ import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
class RootNavScreenTest : BaseComposeTest() {
private val fakeNavHostController = FakeNavHostController()
@ -82,6 +85,19 @@ class RootNavScreenTest : BaseComposeTest() {
)
}
// Make sure navigating to complete registration route works as expected:
rootNavStateFlow.value = RootNavState.CompleteOngoingRegistration(
email = "example@email.com",
verificationToken = "verificationToken",
fromEmail = true,
timestamp = FIXED_CLOCK.millis(),
)
composeTestRule.runOnIdle {
fakeNavHostController.assertLastNavigation(
route = "complete_registration/example@email.com/verificationToken/true",
)
}
// Make sure navigating to vault locked works as expected:
rootNavStateFlow.value = RootNavState.VaultLocked
composeTestRule.runOnIdle {
@ -203,3 +219,8 @@ class RootNavScreenTest : BaseComposeTest() {
}
}
}
private val FIXED_CLOCK: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)

View file

@ -15,7 +15,7 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthState
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat

View file

@ -19,7 +19,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
import com.x8bit.bitwarden.ui.auth.feature.createaccount.PasswordStrengthState
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.PasswordStrengthState
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat