From 39b1409cbd3f98e239c6582ea48e5456c531ff79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Bispo?= Date: Tue, 15 Jul 2025 17:31:37 +0100 Subject: [PATCH] [PM-22399] Send 2FA email when view appears (#5498) Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> --- .../twofactorlogin/TwoFactorLoginViewModel.kt | 32 +++++++- .../TwoFactorLoginViewModelTest.kt | 79 +++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt index 83f157c018..2a9139ea8b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModel.kt @@ -122,6 +122,13 @@ class TwoFactorLoginViewModel @Inject constructor( .map { TwoFactorLoginAction.Internal.ReceiveWebAuthResult(webAuthResult = it) } .onEach(::sendAction) .launchIn(viewModelScope) + + viewModelScope.launch { + // If the auth method is email and it is not to verify the device, call resendEmail. + if (state.authMethod == TwoFactorAuthMethod.EMAIL && !state.isNewDeviceVerification) { + sendAction(TwoFactorLoginAction.Internal.SendVerificationCodeEmail) + } + } } override fun handleAction(action: TwoFactorLoginAction) { @@ -159,6 +166,10 @@ class TwoFactorLoginViewModel @Inject constructor( is TwoFactorLoginAction.Internal.ReceiveResendEmailResult -> { handleReceiveResendEmailResult(action) } + + TwoFactorLoginAction.Internal.SendVerificationCodeEmail -> { + handleSendVerificationCodeEmail() + } } } @@ -476,6 +487,20 @@ class TwoFactorLoginViewModel @Inject constructor( * Resend the verification code email. */ private fun handleResendEmailClick() { + sendVerificationCodeEmail(isUserInitiated = true) + } + + /** + * send the verification code email without user interaction. + */ + private fun handleSendVerificationCodeEmail() { + sendVerificationCodeEmail(isUserInitiated = false) + } + + /** + * Send the verification code email. + */ + private fun sendVerificationCodeEmail(isUserInitiated: Boolean) { // Ensure that the user is in fact verifying with email. if (state.authMethod != TwoFactorAuthMethod.EMAIL) { return @@ -500,7 +525,7 @@ class TwoFactorLoginViewModel @Inject constructor( sendAction( TwoFactorLoginAction.Internal.ReceiveResendEmailResult( resendEmailResult = result, - isUserInitiated = true, + isUserInitiated = isUserInitiated, ), ) } @@ -821,5 +846,10 @@ sealed class TwoFactorLoginAction { data class ReceiveWebAuthResult( val webAuthResult: WebAuthResult, ) : Internal() + + /** + * Indicates that the verification code email should be sent. + */ + data object SendVerificationCodeEmail : Internal() } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModelTest.kt index 36b3490fd4..185a35a7b1 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginViewModelTest.kt @@ -97,6 +97,64 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() { } } + @Test + fun `init with email auth method and not new device verification should call resendEmail`() { + val initialState = DEFAULT_STATE.copy( + authMethod = TwoFactorAuthMethod.EMAIL, + isNewDeviceVerification = false, + ) + coEvery { authRepository.resendVerificationCodeEmail() } returns ResendEmailResult.Success + + createViewModel(state = initialState) + + coVerify(exactly = 1) { + authRepository.resendVerificationCodeEmail() + } + } + + @Test + fun `init with email auth method and new device verification should not call resendEmail`() { + val initialState = DEFAULT_STATE.copy( + authMethod = TwoFactorAuthMethod.EMAIL, + isNewDeviceVerification = true, + ) + coEvery { authRepository.resendVerificationCodeEmail() } returns ResendEmailResult.Success + + createViewModel(state = initialState) + + coVerify(exactly = 0) { + authRepository.resendVerificationCodeEmail() + } + } + + @Test + @Suppress("MaxLineLength") + fun `init with non-email auth method and not new device verification should not call resendEmail`() { + val initialState = DEFAULT_STATE.copy( + authMethod = TwoFactorAuthMethod.AUTHENTICATOR_APP, + isNewDeviceVerification = false, + ) + coEvery { authRepository.resendVerificationCodeEmail() } returns ResendEmailResult.Success + + createViewModel(state = initialState) + + coVerify(exactly = 0) { + authRepository.resendVerificationCodeEmail() + } + } + + @Test + @Suppress("MaxLineLength") + fun `init with non-email auth method and new device verification should not call resendEmail`() { + val initialState = DEFAULT_STATE.copy( + authMethod = TwoFactorAuthMethod.AUTHENTICATOR_APP, + isNewDeviceVerification = true, + ) + createViewModel(state = initialState) + + coVerify(exactly = 0) { authRepository.resendVerificationCodeEmail() } + } + @Test fun `yubiKeyResultFlow update should populate the input field and attempt login`() { val initialState = DEFAULT_STATE.copy(authMethod = TwoFactorAuthMethod.YUBI_KEY) @@ -899,6 +957,27 @@ class TwoFactorLoginViewModelTest : BaseViewModelTest() { ) } + @Test + @Suppress("MaxLineLength") + fun `sendVerificationCodeEmail with isUserInitiated false should not show loading and snackbar on success`() = + runTest { + coEvery { authRepository.resendVerificationCodeEmail() } returns ResendEmailResult.Success + val viewModel = createViewModel() + // Simulate initial email send (not user initiated) + viewModel.trySendAction( + TwoFactorLoginAction.Internal.ReceiveResendEmailResult( + ResendEmailResult.Success, + isUserInitiated = false, + ), + ) + viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow -> + // No loading dialog + assertEquals(DEFAULT_STATE, stateFlow.awaitItem()) + // No snackbar + eventFlow.expectNoEvents() + } + } + @Test fun `ResendEmailClick returns success should emit ShowSnackbar`() = runTest { coEvery {