From 9094f6f1d0ced09416a945a014d1389304e8a625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Mon, 17 Jun 2024 21:23:22 +0100 Subject: [PATCH] [PM-6701] Add Complete Registration screen (cherry picked from commit da271159069b4c2237451936397d05f134225e03) --- .../ui/auth/feature/auth/AuthNavigation.kt | 10 +- .../CompleteRegistrationNavigation.kt | 32 ++ .../CompleteRegistrationScreen.kt | 242 ++++++++ .../CompleteRegistrationViewModel.kt | 523 ++++++++++++++++++ app/src/main/res/values/strings.xml | 7 +- .../auth/feature/landing/LandingScreenTest.kt | 2 + 6 files changed, 814 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationNavigation.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreen.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModel.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt index 8251bf6a38..d4f715da2a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt @@ -6,6 +6,8 @@ import androidx.navigation.NavHostController import androidx.navigation.NavOptions import androidx.navigation.navOptions import androidx.navigation.navigation +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 @@ -55,10 +57,16 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) { onNavigateBack = { navController.popBackStack() }, // TODO check necessary parameters onNavigateToCompleteRegistration = { emailAddress, verificationToken, captchaToken -> - navController.popBackStack() + navController.navigateToCompleteRegistration() }, onNavigateToEnvironment = { navController.navigateToEnvironment() } ) + completeRegistrationDestination( + onNavigateBack = { navController.popBackStack() }, + onNavigateToLogin = { emailAddress, captchaToken -> + navController.navigateToLogin(emailAddress, captchaToken) + }, + ) enterpriseSignOnDestination( onNavigateBack = { navController.popBackStack() }, onNavigateToSetPassword = { navController.navigateToSetPassword() }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationNavigation.kt new file mode 100644 index 0000000000..604a37c164 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationNavigation.kt @@ -0,0 +1,32 @@ +package com.x8bit.bitwarden.ui.auth.feature.completeregistration + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions + +private const val COMPLETE_REGISTRATION_ROUTE = "complete_registration" + +/** + * Navigate to the complete registration screen. + */ +fun NavController.navigateToCompleteRegistration(navOptions: NavOptions? = null) { + this.navigate(COMPLETE_REGISTRATION_ROUTE, navOptions) +} + +/** + * Add the complete registration screen to the nav graph. + */ +fun NavGraphBuilder.completeRegistrationDestination( + onNavigateBack: () -> Unit, + onNavigateToLogin: (emailAddress: String, captchaToken: String) -> Unit, +) { + composableWithSlideTransitions( + route = COMPLETE_REGISTRATION_ROUTE, + ) { + CompleteRegistrationScreen( + onNavigateBack = onNavigateBack, + onNavigateToLogin = onNavigateToLogin + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreen.kt new file mode 100644 index 0000000000..16313c8253 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreen.kt @@ -0,0 +1,242 @@ +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.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.auth.feature.createaccount.PasswordStrengthIndicator +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 create account screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongMethod") +@Composable +fun CompleteRegistrationScreen( + onNavigateBack: () -> Unit, + onNavigateToLogin: (emailAddress: String, captchaToken: String) -> 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.text, Toast.LENGTH_SHORT).show() + } + + is CompleteRegistrationEvent.NavigateToCaptcha -> { + intentManager.startCustomTabsActivity(uri = event.uri) + } + + is CompleteRegistrationEvent.NavigateToLogin -> { + onNavigateToLogin( + event.email, + event.captchaToken, + ) + } + } + } + + // 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(CompleteRegistrationAction.CreateAccountClick) } + }, + modifier = Modifier.testTag("CreateAccountButton"), + ) + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .imePadding() + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier.height(16.dp)) + 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()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModel.kt new file mode 100644 index 0000000000..8feb2579cc --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModel.kt @@ -0,0 +1,523 @@ +package com.x8bit.bitwarden.ui.auth.feature.completeregistration + + +import android.net.Uri +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.auth.repository.util.CaptchaCallbackTokenResult +import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha +import com.x8bit.bitwarden.ui.auth.feature.completeregistration.CompleteRegistrationAction.CheckDataBreachesToggle +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.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.auth.feature.createaccount.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 +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 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, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] + ?: CompleteRegistrationState( + userEmail = "", + emailVerificationToken = "", + 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 { + // As state updates, write to saved state handle: + stateFlow + .onEach { savedStateHandle[KEY_STATE] = it } + .launchIn(viewModelScope) + authRepository + .captchaTokenResultFlow + .onEach { + sendAction( + CompleteRegistrationAction.Internal.ReceiveCaptchaToken( + tokenResult = it, + ), + ) + } + .launchIn(viewModelScope) + } + + override fun handleAction(action: CompleteRegistrationAction) { + when (action) { + is CompleteRegistrationAction.CreateAccountClick -> handleCreateAccountClick() + is ConfirmPasswordInputChange -> handleConfirmPasswordInputChanged(action) + is PasswordHintChange -> handlePasswordHintChanged(action) + is PasswordInputChange -> handlePasswordInputChanged(action) + is CompleteRegistrationAction.CloseClick -> handleCloseClick() + is CompleteRegistrationAction.ErrorDialogDismiss -> handleDialogDismiss() + is CheckDataBreachesToggle -> handleCheckDataBreachesToggle(action) + is CompleteRegistrationAction.Internal.ReceiveRegisterResult -> { + handleReceiveRegisterAccountResult(action) + } + + is CompleteRegistrationAction.Internal.ReceiveCaptchaToken -> { + handleReceiveCaptchaToken(action) + } + + ContinueWithBreachedPasswordClick -> handleContinueWithBreachedPasswordClick() + is ReceivePasswordStrengthResult -> handlePasswordStrengthResult(action) + } + } + + 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 -> { + // Leave UI the same + } + } + } + + private fun handleReceiveCaptchaToken( + action: CompleteRegistrationAction.Internal.ReceiveCaptchaToken, + ) { + when (val result = action.tokenResult) { + is CaptchaCallbackTokenResult.MissingToken -> { + mutableStateFlow.update { + it.copy( + dialog = CompleteRegistrationDialog.Error( + BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.captcha_failed.asText(), + ), + ), + ) + } + } + + is CaptchaCallbackTokenResult.Success -> { + submitRegisterAccountRequest( + shouldCheckForDataBreaches = false, + shouldIgnorePasswordStrength = true, + captchaToken = result.token, + ) + } + } + } + + @Suppress("LongMethod", "MaxLineLength") + private fun handleReceiveRegisterAccountResult( + action: CompleteRegistrationAction.Internal.ReceiveRegisterResult, + ) { + when (val registerAccountResult = action.registerResult) { + is RegisterResult.CaptchaRequired -> { + mutableStateFlow.update { it.copy(dialog = null) } + sendEvent( + CompleteRegistrationEvent.NavigateToCaptcha( + uri = generateUriForCaptcha(captchaId = registerAccountResult.captchaId), + ), + ) + } + + 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.NavigateToLogin( + email = state.userEmail, + captchaToken = registerAccountResult.captchaToken, + ), + ) + } + + 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, + captchaToken = null, + ) + } + } + + private fun handleContinueWithBreachedPasswordClick() { + submitRegisterAccountRequest( + shouldCheckForDataBreaches = false, + shouldIgnorePasswordStrength = true, + captchaToken = null, + ) + } + + private fun submitRegisterAccountRequest( + shouldCheckForDataBreaches: Boolean, + shouldIgnorePasswordStrength: Boolean, + captchaToken: String?, + ) { + mutableStateFlow.update { + it.copy(dialog = CompleteRegistrationDialog.Loading) + } + viewModelScope.launch { + val result = authRepository.register( + shouldCheckDataBreaches = shouldCheckForDataBreaches, + isMasterPasswordStrong = shouldIgnorePasswordStrength || + state.isMasterPasswordStrong, + email = state.userEmail, + masterPassword = state.passwordInput, + masterPasswordHint = state.passwordHintInput.ifBlank { null }, + captchaToken = captchaToken, + ) + sendAction( + CompleteRegistrationAction.Internal.ReceiveRegisterResult( + registerResult = result, + ), + ) + } + } +} + +/** + * UI state for the create account screen. + */ +@Parcelize +data class CompleteRegistrationState( + val userEmail: String, + val emailVerificationToken: String, + 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 create account 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 create account screen. + */ +sealed class CompleteRegistrationEvent { + + /** + * Navigate back to previous screen. + */ + data object NavigateBack : CompleteRegistrationEvent() + + /** + * Placeholder event for showing a toast. Can be removed once there are real events. + */ + data class ShowToast(val text: String) : CompleteRegistrationEvent() + + /** + * Navigates to the captcha verification screen. + */ + data class NavigateToCaptcha(val uri: Uri) : CompleteRegistrationEvent() + + /** + * Navigates to the captcha verification screen. + */ + data class NavigateToLogin( + val email: String, + val captchaToken: String, + ) : CompleteRegistrationEvent() +} + +/** + * Models actions for the create account 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 captcha callback token has been received. + */ + data class ReceiveCaptchaToken( + val tokenResult: CaptchaCallbackTokenResult, + ) : Internal() + + /** + * 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() + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 50efc90639..84ac399bde 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -913,5 +913,10 @@ Do you want to switch to this account? Passkey operation failed because of missing asset links Passkey operation failed because app not found in asset links Passkey operation failed because app could not be verified - Creating on + Creating on: + Follow the instructions in the email sent to %1$s to continue creating your account. + By continuing, you agree to the Terms of Service and Privacy Policy + Set password + Unsubscribe + Get emails from Bitwarden for announcements, advice, and research opportunities. Unsubscribe at any time. diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt index a7816cd7ca..02f689c3dd 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt @@ -49,6 +49,7 @@ class LandingScreenTest : BaseComposeTest() { private var onNavigateToCreateAccountCalled = false private var onNavigateToLoginCalled = false private var onNavigateToEnvironmentCalled = false + private var onNavigateToStartRegistrationCalled = false private val mutableEventFlow = bufferedMutableSharedFlow() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val viewModel = mockk(relaxed = true) { @@ -66,6 +67,7 @@ class LandingScreenTest : BaseComposeTest() { onNavigateToLoginCalled = true }, onNavigateToEnvironment = { onNavigateToEnvironmentCalled = true }, + onNavigateToStartRegistration = { onNavigateToStartRegistrationCalled = true }, viewModel = viewModel, ) }