[PM-22399] Send 2FA email when view appears (#5498)

Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
This commit is contained in:
André Bispo
2025-07-15 17:31:37 +01:00
committed by GitHub
parent f26d54a2e2
commit 39b1409cbd
2 changed files with 110 additions and 1 deletions

View File

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

View File

@@ -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 {