mirror of
https://github.com/bitwarden/android.git
synced 2024-11-24 18:36:32 +03:00
PM-11387 on new create account with email verification, attempt login… (#3842)
This commit is contained in:
parent
3c39d8beac
commit
17c579bfc2
5 changed files with 171 additions and 97 deletions
|
@ -54,7 +54,7 @@ fun NavGraphBuilder.completeRegistrationDestination(
|
|||
onNavigateBack: () -> Unit,
|
||||
onNavigateToPasswordGuidance: () -> Unit,
|
||||
onNavigateToPreventAccountLockout: () -> Unit,
|
||||
onNavigateToLogin: (email: String, token: String) -> Unit,
|
||||
onNavigateToLogin: (email: String, token: String?) -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions(
|
||||
route = COMPLETE_REGISTRATION_ROUTE,
|
||||
|
|
|
@ -73,7 +73,7 @@ fun CompleteRegistrationScreen(
|
|||
onNavigateBack: () -> Unit,
|
||||
onNavigateToPasswordGuidance: () -> Unit,
|
||||
onNavigateToPreventAccountLockout: () -> Unit,
|
||||
onNavigateToLogin: (email: String, token: String) -> Unit,
|
||||
onNavigateToLogin: (email: String, token: String?) -> Unit,
|
||||
viewModel: CompleteRegistrationViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
|
|
@ -6,6 +6,7 @@ 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.LoginResult
|
||||
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.FeatureFlagManager
|
||||
|
@ -106,18 +107,14 @@ class CompleteRegistrationViewModel @Inject constructor(
|
|||
|
||||
override fun handleAction(action: CompleteRegistrationAction) {
|
||||
when (action) {
|
||||
is Internal -> handleInternalAction(action)
|
||||
is ConfirmPasswordInputChange -> handleConfirmPasswordInputChanged(action)
|
||||
is PasswordHintChange -> handlePasswordHintChanged(action)
|
||||
is PasswordInputChange -> handlePasswordInputChanged(action)
|
||||
is BackClick -> handleBackClicked()
|
||||
is ErrorDialogDismiss -> handleDialogDismiss()
|
||||
is CheckDataBreachesToggle -> handleCheckDataBreachesToggle(action)
|
||||
is Internal.ReceiveRegisterResult -> {
|
||||
handleReceiveRegisterAccountResult(action)
|
||||
}
|
||||
|
||||
ContinueWithBreachedPasswordClick -> handleContinueWithBreachedPasswordClick()
|
||||
is ReceivePasswordStrengthResult -> handlePasswordStrengthResult(action)
|
||||
CompleteRegistrationAction.LearnToPreventLockoutClick -> {
|
||||
handlePreventAccountLockoutClickAction()
|
||||
}
|
||||
|
@ -127,10 +124,16 @@ class CompleteRegistrationViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
CompleteRegistrationAction.CallToActionClick -> handleCallToActionClick()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: Internal) {
|
||||
when (action) {
|
||||
is Internal.GeneratedPasswordResult -> handleGeneratedPasswordResult(action)
|
||||
is ReceivePasswordStrengthResult -> handlePasswordStrengthResult(action)
|
||||
is Internal.ReceiveRegisterResult -> handleReceiveRegisterAccountResult(action)
|
||||
is Internal.UpdateOnboardingFeatureState -> handleUpdateOnboardingFeatureState(action)
|
||||
is Internal.GeneratedPasswordResult -> handleGeneratedPasswordResult(
|
||||
action,
|
||||
)
|
||||
is Internal.ReceiveLoginResult -> handleLoginResult(action)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -215,13 +218,19 @@ class CompleteRegistrationViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
is RegisterResult.Success -> {
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
sendEvent(
|
||||
CompleteRegistrationEvent.NavigateToLogin(
|
||||
viewModelScope.launch {
|
||||
val loginResult = authRepository.login(
|
||||
email = state.userEmail,
|
||||
password = state.passwordInput,
|
||||
captchaToken = registerAccountResult.captchaToken,
|
||||
),
|
||||
)
|
||||
)
|
||||
sendAction(
|
||||
Internal.ReceiveLoginResult(
|
||||
loginResult = loginResult,
|
||||
captchaToken = registerAccountResult.captchaToken,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
RegisterResult.DataBreachFound -> {
|
||||
|
@ -259,6 +268,25 @@ class CompleteRegistrationViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleLoginResult(action: Internal.ReceiveLoginResult) {
|
||||
clearDialogState()
|
||||
sendEvent(
|
||||
CompleteRegistrationEvent.ShowToast(
|
||||
message = R.string.account_created_success.asText(),
|
||||
),
|
||||
)
|
||||
// If the login result is Success the state based navigation will take care of it.
|
||||
// otherwise we need to navigate to the login screen.
|
||||
if (action.loginResult !is LoginResult.Success) {
|
||||
sendEvent(
|
||||
CompleteRegistrationEvent.NavigateToLogin(
|
||||
email = state.userEmail,
|
||||
captchaToken = action.captchaToken,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCheckDataBreachesToggle(action: CheckDataBreachesToggle) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(isCheckDataBreachesToggled = action.newState)
|
||||
|
@ -266,9 +294,7 @@ class CompleteRegistrationViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun handleDialogDismiss() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = null)
|
||||
}
|
||||
clearDialogState()
|
||||
}
|
||||
|
||||
private fun handleBackClicked() {
|
||||
|
@ -393,6 +419,12 @@ class CompleteRegistrationViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearDialogState() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -509,7 +541,7 @@ sealed class CompleteRegistrationEvent {
|
|||
*/
|
||||
data class NavigateToLogin(
|
||||
val email: String,
|
||||
val captchaToken: String,
|
||||
val captchaToken: String?,
|
||||
) : CompleteRegistrationEvent()
|
||||
}
|
||||
|
||||
|
@ -595,5 +627,17 @@ sealed class CompleteRegistrationAction {
|
|||
* Indicates a generated password has been received.
|
||||
*/
|
||||
data class GeneratedPasswordResult(val generatedPassword: String) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates registration was successful and will now attempt to login and unlock the vault.
|
||||
* @property captchaToken The captcha token to use for login. With the login function this
|
||||
* is possible to be negative.
|
||||
*
|
||||
* @see [AuthRepository.login]
|
||||
*/
|
||||
data class ReceiveLoginResult(
|
||||
val loginResult: LoginResult,
|
||||
val captchaToken: String?,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -82,6 +82,7 @@
|
|||
<string name="yes">Yes</string>
|
||||
<string name="account">Account</string>
|
||||
<string name="account_created">Your new account has been created! You may now log in.</string>
|
||||
<string name="account_created_success">Your new account has been created!</string>
|
||||
<string name="add_an_item">Add an Item</string>
|
||||
<string name="app_extension">App extension</string>
|
||||
<string name="autofill_accessibility_description">Use the Bitwarden accessibility service to auto-fill your logins across apps and the web.</string>
|
||||
|
|
|
@ -10,6 +10,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL
|
|||
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.LoginResult
|
||||
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.model.UserState
|
||||
|
@ -58,6 +59,25 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
|
|||
private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
|
||||
private val mockAuthRepository = mockk<AuthRepository>() {
|
||||
every { userStateFlow } returns mutableUserStateFlow
|
||||
coEvery {
|
||||
login(
|
||||
email = any(),
|
||||
password = any(),
|
||||
captchaToken = any(),
|
||||
)
|
||||
} returns LoginResult.Success
|
||||
|
||||
coEvery {
|
||||
register(
|
||||
email = any(),
|
||||
masterPassword = any(),
|
||||
masterPasswordHint = any(),
|
||||
emailVerificationToken = any(),
|
||||
captchaToken = any(),
|
||||
shouldCheckDataBreaches = any(),
|
||||
isMasterPasswordStrong = any(),
|
||||
)
|
||||
} returns RegisterResult.Success(captchaToken = CAPTCHA_BYPASS_TOKEN)
|
||||
}
|
||||
|
||||
private val fakeEnvironmentRepository = FakeEnvironmentRepository()
|
||||
|
@ -119,10 +139,10 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `CallToActionClick with all inputs valid should show and hide loading dialog`() = runTest {
|
||||
val repo = mockk<AuthRepository> {
|
||||
fun `CallToActionClick with all inputs valid should account created toast and hide dialog`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
register(
|
||||
mockAuthRepository.register(
|
||||
email = EMAIL,
|
||||
masterPassword = PASSWORD,
|
||||
masterPasswordHint = null,
|
||||
|
@ -132,45 +152,39 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
|
|||
isMasterPasswordStrong = true,
|
||||
)
|
||||
} returns RegisterResult.Success(captchaToken = CAPTCHA_BYPASS_TOKEN)
|
||||
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE)
|
||||
turbineScope {
|
||||
val stateFlow = viewModel.stateFlow.testIn(backgroundScope)
|
||||
val eventFlow = viewModel.eventFlow.testIn(backgroundScope)
|
||||
assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem())
|
||||
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
|
||||
assertEquals(
|
||||
VALID_INPUT_STATE.copy(dialog = CompleteRegistrationDialog.Loading),
|
||||
stateFlow.awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
CompleteRegistrationEvent.ShowToast(R.string.account_created_success.asText()),
|
||||
eventFlow.awaitItem(),
|
||||
)
|
||||
// Make sure loading dialog is hidden:
|
||||
assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem())
|
||||
}
|
||||
}
|
||||
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.CallToActionClick)
|
||||
assertEquals(
|
||||
VALID_INPUT_STATE.copy(dialog = CompleteRegistrationDialog.Loading),
|
||||
stateFlow.awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
CompleteRegistrationEvent.NavigateToLogin(
|
||||
EMAIL,
|
||||
CAPTCHA_BYPASS_TOKEN,
|
||||
),
|
||||
eventFlow.awaitItem(),
|
||||
)
|
||||
// Make sure loading dialog is hidden:
|
||||
assertEquals(VALID_INPUT_STATE, stateFlow.awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CallToActionClick 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)
|
||||
coEvery {
|
||||
mockAuthRepository.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)
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(VALID_INPUT_STATE, awaitItem())
|
||||
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
|
||||
|
@ -193,52 +207,68 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `CallToActionClick 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.CallToActionClick)
|
||||
assertEquals(
|
||||
CompleteRegistrationEvent.NavigateToLogin(
|
||||
EMAIL,
|
||||
CAPTCHA_BYPASS_TOKEN,
|
||||
),
|
||||
awaitItem(),
|
||||
fun `CallToActionClick register returns Success should attempt login`() = runTest {
|
||||
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE)
|
||||
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
|
||||
coVerify {
|
||||
mockAuthRepository.login(
|
||||
email = EMAIL,
|
||||
password = PASSWORD,
|
||||
captchaToken = CAPTCHA_BYPASS_TOKEN,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
fun `when login attempt returns success should wait for state based navigation`() = runTest {
|
||||
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE)
|
||||
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
|
||||
viewModel.eventFlow.test {
|
||||
assertTrue(awaitItem() is CompleteRegistrationEvent.ShowToast)
|
||||
expectNoEvents()
|
||||
}
|
||||
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE, repo)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `when login attempt returns anything other than success should send navigate to login event`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
mockAuthRepository.login(
|
||||
email = EMAIL,
|
||||
password = PASSWORD,
|
||||
captchaToken = CAPTCHA_BYPASS_TOKEN,
|
||||
)
|
||||
} returns LoginResult.TwoFactorRequired
|
||||
|
||||
val viewModel = createCompleteRegistrationViewModel(VALID_INPUT_STATE)
|
||||
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
|
||||
viewModel.eventFlow.test {
|
||||
assertTrue(awaitItem() is CompleteRegistrationEvent.ShowToast)
|
||||
assertEquals(
|
||||
CompleteRegistrationEvent.NavigateToLogin(EMAIL, CAPTCHA_BYPASS_TOKEN),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ContinueWithBreachedPasswordClick should call repository with checkDataBreaches false`() {
|
||||
coEvery {
|
||||
mockAuthRepository.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)
|
||||
viewModel.trySendAction(CompleteRegistrationAction.ContinueWithBreachedPasswordClick)
|
||||
coVerify {
|
||||
repo.register(
|
||||
mockAuthRepository.register(
|
||||
email = EMAIL,
|
||||
masterPassword = PASSWORD,
|
||||
masterPasswordHint = null,
|
||||
|
@ -632,14 +662,13 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
|
|||
|
||||
private fun createCompleteRegistrationViewModel(
|
||||
completeRegistrationState: CompleteRegistrationState? = DEFAULT_STATE,
|
||||
authRepository: AuthRepository = mockAuthRepository,
|
||||
): CompleteRegistrationViewModel = CompleteRegistrationViewModel(
|
||||
savedStateHandle = SavedStateHandle(
|
||||
mapOf(
|
||||
"state" to completeRegistrationState,
|
||||
),
|
||||
),
|
||||
authRepository = authRepository,
|
||||
authRepository = mockAuthRepository,
|
||||
environmentRepository = fakeEnvironmentRepository,
|
||||
specialCircumstanceManager = specialCircumstanceManager,
|
||||
featureFlagManager = featureFlagManager,
|
||||
|
|
Loading…
Reference in a new issue