[PM-6701] Add Complete Registration screen

(cherry picked from commit da27115906)
This commit is contained in:
André Bispo
2024-06-17 21:23:22 +01:00
parent 6f7b6fea6d
commit 9094f6f1d0
6 changed files with 814 additions and 2 deletions

View File

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

View File

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

View File

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

View File

@@ -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<CompleteRegistrationState, CompleteRegistrationEvent, CompleteRegistrationAction>(
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()
}
}

View File

@@ -913,5 +913,10 @@ Do you want to switch to this account?</string>
<string name="passkey_operation_failed_because_of_missing_asset_links">Passkey operation failed because of missing asset links</string>
<string name="passkey_operation_failed_because_app_not_found_in_asset_links">Passkey operation failed because app not found in asset links</string>
<string name="passkey_operation_failed_because_app_could_not_be_verified">Passkey operation failed because app could not be verified</string>
<string name="creating_on">Creating on</string>
<string name="creating_on">Creating on:</string>
<string name="follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account">Follow the instructions in the email sent to %1$s to continue creating your account.</string>
<string name="by_continuing_you_agree_to_the_terms_of_service_and_privacy_policy">By continuing, you agree to the Terms of Service and Privacy Policy</string>
<string name="set_password">Set password</string>
<string name="unsubscribe">Unsubscribe</string>
<string name="get_emails_from_bitwarden_for_announcements_advices_and_research_opportunities_unsubscribe_any_time">Get emails from Bitwarden for announcements, advice, and research opportunities. Unsubscribe at any time.</string>
</resources>

View File

@@ -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<LandingEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<LandingViewModel>(relaxed = true) {
@@ -66,6 +67,7 @@ class LandingScreenTest : BaseComposeTest() {
onNavigateToLoginCalled = true
},
onNavigateToEnvironment = { onNavigateToEnvironmentCalled = true },
onNavigateToStartRegistration = { onNavigateToStartRegistrationCalled = true },
viewModel = viewModel,
)
}