From e468ec695b34dcae0e4a194504cdf5dc7405cc3c Mon Sep 17 00:00:00 2001
From: Dave Severns <149429124+dseverns-livefront@users.noreply.github.com>
Date: Fri, 6 Sep 2024 13:40:00 -0400
Subject: [PATCH] PM-11604 Network layer for checking email token, nav to UI if
 needed. (#3862)

---
 .../java/com/x8bit/bitwarden/MainActivity.kt  |  10 +
 .../java/com/x8bit/bitwarden/MainViewModel.kt |  61 +++++-
 .../network/api/UnauthenticatedIdentityApi.kt |   6 +
 .../model/VerifyEmailTokenRequestJson.kt      |  18 ++
 .../model/VerifyEmailTokenResponseJson.kt     |  38 ++++
 .../network/service/IdentityService.kt        |  10 +
 .../network/service/IdentityServiceImpl.kt    |  33 ++++
 .../data/auth/manager/AuthRequestManager.kt   |   2 +-
 .../data/auth/repository/AuthRepository.kt    |   9 +
 .../auth/repository/AuthRepositoryImpl.kt     |  28 +++
 .../auth/repository/model/EmailTokenResult.kt |  22 +++
 .../manager/SpecialCircumstanceManagerImpl.kt |   2 +-
 .../manager/model/SpecialCircumstance.kt      |  14 +-
 .../util/SpecialCircumstanceExtensions.kt     |   6 +-
 .../ExpiredRegistrationLinkScreen.kt          |  14 +-
 .../ExpiredRegistrationLinkViewModel.kt       |  20 +-
 .../feature/rootnav/RootNavViewModel.kt       |  28 ++-
 app/src/main/res/values/strings.xml           |   1 +
 .../com/x8bit/bitwarden/MainViewModelTest.kt  | 176 +++++++++++++++++-
 .../network/service/IdentityServiceTest.kt    |  73 ++++++++
 .../auth/repository/AuthRepositoryTest.kt     |  87 +++++++++
 .../manager/SpecialCircumstanceManagerTest.kt |   2 +-
 .../CompleteRegistrationViewModelTest.kt      |   5 +-
 .../ExpiredRegistrationLinkScreenTest.kt      |   8 +-
 .../ExpiredRegistrationLinkViewModelTest.kt   |  42 +++--
 .../feature/rootnav/RootNavViewModelTest.kt   |  97 +++++++++-
 26 files changed, 756 insertions(+), 56 deletions(-)
 create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifyEmailTokenRequestJson.kt
 create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifyEmailTokenResponseJson.kt
 create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/EmailTokenResult.kt

diff --git a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt
index 59c240733..8db178338 100644
--- a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt
@@ -5,6 +5,7 @@ import android.os.Bundle
 import android.view.KeyEvent
 import android.view.MotionEvent
 import android.view.WindowManager
+import android.widget.Toast
 import androidx.activity.compose.setContent
 import androidx.activity.viewModels
 import androidx.appcompat.app.AppCompatActivity
@@ -76,6 +77,15 @@ class MainActivity : AppCompatActivity() {
                     is MainEvent.CompleteAutofill -> handleCompleteAutofill(event)
                     MainEvent.Recreate -> handleRecreate()
                     MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
+                    is MainEvent.ShowToast -> {
+                        Toast
+                            .makeText(
+                                baseContext,
+                                event.message.invoke(resources),
+                                Toast.LENGTH_SHORT,
+                            )
+                            .show()
+                    }
                 }
             }
             updateScreenCapture(isScreenCaptureAllowed = state.isScreenCaptureAllowed)
diff --git a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt
index 7d2e05346..d76672d05 100644
--- a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt
@@ -6,6 +6,7 @@ import androidx.lifecycle.SavedStateHandle
 import androidx.lifecycle.viewModelScope
 import com.bitwarden.vault.CipherView
 import com.x8bit.bitwarden.data.auth.repository.AuthRepository
+import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
 import com.x8bit.bitwarden.data.auth.util.getCompleteRegistrationDataIntentOrNull
 import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull
 import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
@@ -17,11 +18,15 @@ import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
 import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
 import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
 import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
+import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
 import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
+import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
 import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
 import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
 import com.x8bit.bitwarden.data.vault.repository.VaultRepository
 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.feature.settings.appearance.model.AppTheme
 import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
 import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut
@@ -56,6 +61,7 @@ class MainViewModel @Inject constructor(
     settingsRepository: SettingsRepository,
     private val vaultRepository: VaultRepository,
     private val authRepository: AuthRepository,
+    private val environmentRepository: EnvironmentRepository,
     private val savedStateHandle: SavedStateHandle,
     private val clock: Clock,
 ) : BaseViewModel<MainState, MainEvent, MainAction>(
@@ -226,14 +232,7 @@ class MainViewModel @Inject constructor(
             }
 
             completeRegistrationData != null -> {
-                if (authRepository.activeUserId != null) {
-                    authRepository.hasPendingAccountAddition = true
-                }
-                specialCircumstanceManager.specialCircumstance =
-                    SpecialCircumstance.PreLogin.CompleteRegistration(
-                        completeRegistrationData = completeRegistrationData,
-                        timestamp = clock.millis(),
-                    )
+                handleCompleteRegistrationData(completeRegistrationData)
             }
 
             autofillSaveItem != null -> {
@@ -310,6 +309,47 @@ class MainViewModel @Inject constructor(
         sendEvent(MainEvent.Recreate)
         garbageCollectionManager.tryCollect()
     }
+
+    private fun handleCompleteRegistrationData(data: CompleteRegistrationData) {
+        viewModelScope.launch {
+            // Attempt to load the environment for the user if they have a pre-auth environment
+            // saved.
+            environmentRepository.loadEnvironmentForEmail(userEmail = data.email)
+            // Determine if the token is still valid.
+            val emailTokenResult = authRepository.validateEmailToken(
+                email = data.email,
+                token = data.verificationToken,
+            )
+            when (emailTokenResult) {
+                is EmailTokenResult.Error -> {
+                    sendEvent(
+                        MainEvent.ShowToast(
+                            message = emailTokenResult
+                                .message
+                                ?.asText()
+                                ?: R.string.there_was_an_issue_validating_the_registration_token
+                                    .asText(),
+                        ),
+                    )
+                }
+                EmailTokenResult.Expired -> {
+                    specialCircumstanceManager.specialCircumstance = SpecialCircumstance
+                        .RegistrationEvent
+                        .ExpiredRegistrationLink
+                }
+                EmailTokenResult.Success -> {
+                    if (authRepository.activeUserId != null) {
+                        authRepository.hasPendingAccountAddition = true
+                    }
+                    specialCircumstanceManager.specialCircumstance =
+                        SpecialCircumstance.RegistrationEvent.CompleteRegistration(
+                            completeRegistrationData = data,
+                            timestamp = clock.millis(),
+                        )
+                }
+            }
+        }
+    }
 }
 
 /**
@@ -396,4 +436,9 @@ sealed class MainEvent {
      * Navigate to the debug menu.
      */
     data object NavigateToDebugMenu : MainEvent()
+
+    /**
+     * Show a toast with the given [message].
+     */
+    data class ShowToast(val message: Text) : MainEvent()
 }
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedIdentityApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedIdentityApi.kt
index 547add087..343a86f6a 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedIdentityApi.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/UnauthenticatedIdentityApi.kt
@@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterFinishRequ
 import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
 import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
 import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
+import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson
 import kotlinx.serialization.json.JsonPrimitive
 import retrofit2.Call
 import retrofit2.http.Body
@@ -79,4 +80,9 @@ interface UnauthenticatedIdentityApi {
     suspend fun sendVerificationEmail(
         @Body body: SendVerificationEmailRequestJson,
     ): Result<JsonPrimitive?>
+
+    @POST("/accounts/register/verification-email-clicked")
+    suspend fun verifyEmailToken(
+        @Body body: VerifyEmailTokenRequestJson,
+    ): Result<Unit>
 }
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifyEmailTokenRequestJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifyEmailTokenRequestJson.kt
new file mode 100644
index 000000000..673f02971
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifyEmailTokenRequestJson.kt
@@ -0,0 +1,18 @@
+package com.x8bit.bitwarden.data.auth.datasource.network.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * Models the request body for verify email token endpoint.
+ *
+ * @param email the email address of the user to verify.
+ * @param token the provided email verification token.
+ */
+@Serializable
+data class VerifyEmailTokenRequestJson(
+    @SerialName("email")
+    val email: String,
+    @SerialName("emailVerificationToken")
+    val token: String,
+)
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifyEmailTokenResponseJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifyEmailTokenResponseJson.kt
new file mode 100644
index 000000000..a43bafeea
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/VerifyEmailTokenResponseJson.kt
@@ -0,0 +1,38 @@
+package com.x8bit.bitwarden.data.auth.datasource.network.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * Model the response of a verify email token request.
+ *
+ * A valid response will be a [VerifyEmailTokenResponseJson.Valid]
+ *
+ * an invalid response will be a [VerifyEmailTokenResponseJson.Invalid] with a message.
+ */
+@Serializable
+sealed class VerifyEmailTokenResponseJson {
+
+    /**
+     * The token is confirmed as valid from the response.
+     */
+    @Serializable
+    data object Valid : VerifyEmailTokenResponseJson()
+
+    /**
+     * The response is invalid.
+     *
+     * @property message The error message. Expected to explain the reason why the token is invalid.
+     */
+    @Serializable
+    data class Invalid(
+        @SerialName("message")
+        val message: String,
+    ) : VerifyEmailTokenResponseJson()
+
+    /**
+     * The token has expired. This is special case of similar to [Invalid].
+     */
+    @Serializable
+    data object TokenExpired : VerifyEmailTokenResponseJson()
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt
index 1c2ada0dc..09bd21c38 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt
@@ -10,6 +10,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJso
 import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
 import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
 import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
+import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson
+import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson
 
 /**
  * Provides an API for querying identity endpoints.
@@ -72,4 +74,12 @@ interface IdentityService {
      * Register a new account to Bitwarden using email verification flow.
      */
     suspend fun registerFinish(body: RegisterFinishRequestJson): Result<RegisterResponseJson>
+
+    /**
+     * Makes request to verify email registration token. If the token provided is
+     * still valid will return success.
+     */
+    suspend fun verifyEmailRegistrationToken(
+        body: VerifyEmailTokenRequestJson,
+    ): Result<VerifyEmailTokenResponseJson>
 }
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt
index 1132ac8b8..7a32b598a 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt
@@ -12,6 +12,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJso
 import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
 import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
 import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
+import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson
+import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson
 import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenError
 import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode
 import com.x8bit.bitwarden.data.platform.datasource.network.util.executeForResult
@@ -127,4 +129,35 @@ class IdentityServiceImpl(
             .sendVerificationEmail(body = body)
             .map { it?.content }
     }
+
+    override suspend fun verifyEmailRegistrationToken(
+        body: VerifyEmailTokenRequestJson,
+    ): Result<VerifyEmailTokenResponseJson> = unauthenticatedIdentityApi
+        .verifyEmailToken(
+            body = body,
+        )
+        .map {
+            VerifyEmailTokenResponseJson.Valid
+        }
+        .recoverCatching { throwable ->
+            val bitwardenError = throwable.toBitwardenError()
+            bitwardenError
+                .parseErrorBodyOrNull<VerifyEmailTokenResponseJson.Invalid>(
+                    code = 400,
+                    json = json,
+                )
+                ?.checkForExpiredMessage()
+                ?: throw throwable
+        }
 }
+
+/**
+ * If the message body contains text related to the token being expired, return
+ * the TokenExpired type. Otherwise, return the original Invalid response.
+ */
+private fun VerifyEmailTokenResponseJson.Invalid.checkForExpiredMessage() =
+    if (message.contains(other = "expired", ignoreCase = true)) {
+        VerifyEmailTokenResponseJson.TokenExpired
+    } else {
+        this
+    }
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManager.kt
index 60f6d8dba..71b825ade 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManager.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/manager/AuthRequestManager.kt
@@ -10,7 +10,7 @@ import com.x8bit.bitwarden.data.auth.manager.model.CreateAuthRequestResult
 import kotlinx.coroutines.flow.Flow
 
 /**
- * A manager class for handling authentication fo logging in with remote device.
+ * A manager class for handling authentication for logging in with remote device.
  */
 interface AuthRequestManager {
     /**
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt
index 0c77c36c9..2bae54c03 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt
@@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
 import com.x8bit.bitwarden.data.auth.repository.model.AuthState
 import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
 import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
+import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
 import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
 import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
 import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
@@ -377,4 +378,12 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
         name: String,
         receiveMarketingEmails: Boolean,
     ): SendVerificationEmailResult
+
+    /**
+     * Validates the given [token] for the given [email]. Part of th new account registration flow.
+     */
+    suspend fun validateEmailToken(
+        email: String,
+        token: String,
+    ): EmailTokenResult
 }
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt
index e969c1fed..dcca27efe 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt
@@ -26,6 +26,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequest
 import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson
 import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
 import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
+import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson
+import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson
 import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
 import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
 import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService
@@ -41,6 +43,7 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
 import com.x8bit.bitwarden.data.auth.repository.model.AuthState
 import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
 import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
+import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
 import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
 import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
 import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
@@ -1256,6 +1259,31 @@ class AuthRepositoryImpl(
                 },
             )
 
+    override suspend fun validateEmailToken(email: String, token: String): EmailTokenResult {
+        return identityService
+            .verifyEmailRegistrationToken(
+                body = VerifyEmailTokenRequestJson(
+                    email = email,
+                    token = token,
+                ),
+            )
+            .fold(
+                onSuccess = {
+                    when (val json = it) {
+                        VerifyEmailTokenResponseJson.Valid -> EmailTokenResult.Success
+                        is VerifyEmailTokenResponseJson.Invalid -> {
+                            EmailTokenResult.Error(json.message)
+                        }
+
+                        VerifyEmailTokenResponseJson.TokenExpired -> EmailTokenResult.Expired
+                    }
+                },
+                onFailure = {
+                    EmailTokenResult.Error(message = null)
+                },
+            )
+    }
+
     @Suppress("CyclomaticComplexMethod")
     private suspend fun validatePasswordAgainstPolicy(
         password: String,
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/EmailTokenResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/EmailTokenResult.kt
new file mode 100644
index 000000000..398904536
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/EmailTokenResult.kt
@@ -0,0 +1,22 @@
+package com.x8bit.bitwarden.data.auth.repository.model
+
+/**
+ * Model the result of a request to validate a given email token.
+ */
+sealed class EmailTokenResult {
+
+    /**
+     * The token is valid and the user can proceed with account creation.
+     */
+    data object Success : EmailTokenResult()
+
+    /**
+     * The token has expired and is no longer valid.
+     */
+    data object Expired : EmailTokenResult()
+
+    /**
+     * There was an error validating the token.
+     */
+    data class Error(val message: String?) : EmailTokenResult()
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManagerImpl.kt
index c35b46feb..486eb5dbd 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManagerImpl.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManagerImpl.kt
@@ -26,7 +26,7 @@ class SpecialCircumstanceManagerImpl(
                 it?.activeAccount?.isLoggedIn == true
             }
             .onEach { _ ->
-                if (specialCircumstance is SpecialCircumstance.PreLogin) {
+                if (specialCircumstance is SpecialCircumstance.RegistrationEvent) {
                     specialCircumstance = null
                 }
             }
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt
index 24a4a60a3..f8c376f34 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt
@@ -8,7 +8,6 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
 import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
 import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
 import kotlinx.parcelize.Parcelize
-import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
 
 /**
  * Represents a special circumstance the app may be in. These circumstances could require some kind
@@ -93,11 +92,9 @@ sealed class SpecialCircumstance : Parcelable {
     /**
      * A subset of [SpecialCircumstance] that are only relevant in a pre-login state and should be
      * cleared after a successful login.
-     *
-     * @see [SpecialCircumstanceManager.clearSpecialCircumstanceAfterLogin]
      */
     @Parcelize
-    sealed class PreLogin : SpecialCircumstance() {
+    sealed class RegistrationEvent : SpecialCircumstance() {
         /**
          * The app was launched via AppLink in order to allow the user complete an ongoing
          * registration.
@@ -106,6 +103,13 @@ sealed class SpecialCircumstance : Parcelable {
         data class CompleteRegistration(
             val completeRegistrationData: CompleteRegistrationData,
             val timestamp: Long,
-        ) : PreLogin()
+        ) : RegistrationEvent()
+
+        /**
+         * The app was launched via AppLink in order to allow the user to complete registration but,
+         * the registration link has expired.
+         */
+        @Parcelize
+        data object ExpiredRegistrationLink : RegistrationEvent()
     }
 }
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt
index befdf9770..a8a737225 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt
@@ -21,7 +21,8 @@ fun SpecialCircumstance.toAutofillSaveItemOrNull(): AutofillSaveItem? =
         is SpecialCircumstance.Fido2Save -> null
         is SpecialCircumstance.Fido2Assertion -> null
         is SpecialCircumstance.Fido2GetCredentials -> null
-        is SpecialCircumstance.PreLogin.CompleteRegistration -> null
+        is SpecialCircumstance.RegistrationEvent.CompleteRegistration -> null
+        SpecialCircumstance.RegistrationEvent.ExpiredRegistrationLink -> null
     }
 
 /**
@@ -38,7 +39,8 @@ fun SpecialCircumstance.toAutofillSelectionDataOrNull(): AutofillSelectionData?
         is SpecialCircumstance.Fido2Save -> null
         is SpecialCircumstance.Fido2Assertion -> null
         is SpecialCircumstance.Fido2GetCredentials -> null
-        is SpecialCircumstance.PreLogin.CompleteRegistration -> null
+        is SpecialCircumstance.RegistrationEvent.CompleteRegistration -> null
+        SpecialCircumstance.RegistrationEvent.ExpiredRegistrationLink -> null
     }
 
 /**
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreen.kt
index 38557b848..abb663e35 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreen.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreen.kt
@@ -1,5 +1,6 @@
 package com.x8bit.bitwarden.ui.auth.feature.expiredregistrationlink
 
+import androidx.activity.compose.BackHandler
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
@@ -52,6 +53,13 @@ fun ExpiredRegistrationLinkScreen(
             }
         }
     }
+    val sendCloseClicked = remember(viewModel) {
+        {
+            viewModel.trySendAction(ExpiredRegistrationLinkAction.CloseClicked)
+        }
+    }
+
+    BackHandler(onBack = sendCloseClicked)
 
     BitwardenScaffold(
         topBar = {
@@ -61,11 +69,7 @@ fun ExpiredRegistrationLinkScreen(
                 navigationIcon = NavigationIcon(
                     navigationIcon = rememberVectorPainter(id = R.drawable.ic_close),
                     navigationIconContentDescription = stringResource(id = R.string.close),
-                    onNavigationIconClick = remember(viewModel) {
-                        {
-                            viewModel.trySendAction(ExpiredRegistrationLinkAction.CloseClicked)
-                        }
-                    },
+                    onNavigationIconClick = sendCloseClicked,
                 ),
             )
         },
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModel.kt
index 36c28e75a..60c98edd9 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModel.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModel.kt
@@ -1,13 +1,17 @@
 package com.x8bit.bitwarden.ui.auth.feature.expiredregistrationlink
 
+import com.x8bit.bitwarden.data.auth.repository.AuthRepository
 import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
 import javax.inject.Inject
 
 /**
  * View model for the [ExpiredRegistrationLinkScreen].
  */
-class ExpiredRegistrationLinkViewModel @Inject constructor() :
-    BaseViewModel<Unit, ExpiredRegistrationLinkEvent, ExpiredRegistrationLinkAction>(
+@HiltViewModel
+class ExpiredRegistrationLinkViewModel @Inject constructor(
+    private val authRepository: AuthRepository,
+) : BaseViewModel<Unit, ExpiredRegistrationLinkEvent, ExpiredRegistrationLinkAction>(
         initialState = Unit,
     ) {
     override fun handleAction(action: ExpiredRegistrationLinkAction) {
@@ -21,16 +25,28 @@ class ExpiredRegistrationLinkViewModel @Inject constructor() :
     }
 
     private fun handleRestartRegistrationClicked() {
+        resetPendingAccountAddition()
         sendEvent(ExpiredRegistrationLinkEvent.NavigateToStartRegistration)
     }
 
     private fun handleGoToLoginClicked() {
+        resetPendingAccountAddition()
         sendEvent(ExpiredRegistrationLinkEvent.NavigateToLogin)
     }
 
     private fun handleCloseClicked() {
+        resetPendingAccountAddition()
         sendEvent(ExpiredRegistrationLinkEvent.NavigateBack)
     }
+
+    /**
+     * Since leaving the expired registration screen takes the user back to the landing
+     * screen we want to update the [AuthRepository] to add a pending account addition.
+     * This will help ensure we are in the correct user state when returning to the landing screen.
+     */
+    private fun resetPendingAccountAddition() {
+        authRepository.hasPendingAccountAddition = true
+    }
 }
 
 /**
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt
index dd2b5f831..8d2edc9bc 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt
@@ -70,13 +70,8 @@ class RootNavViewModel @Inject constructor(
 
             userState?.activeAccount?.needsPasswordReset == true -> RootNavState.ResetPassword
 
-            specialCircumstance is SpecialCircumstance.PreLogin.CompleteRegistration -> {
-                RootNavState.CompleteOngoingRegistration(
-                    email = specialCircumstance.completeRegistrationData.email,
-                    verificationToken = specialCircumstance.completeRegistrationData.verificationToken,
-                    fromEmail = specialCircumstance.completeRegistrationData.fromEmail,
-                    timestamp = specialCircumstance.timestamp,
-                )
+            specialCircumstance is SpecialCircumstance.RegistrationEvent -> {
+                getRegistrationEventNavState(specialCircumstance)
             }
 
             userState == null ||
@@ -141,7 +136,7 @@ class RootNavViewModel @Inject constructor(
                     null,
                     -> RootNavState.VaultUnlocked(activeUserId = userState.activeAccount.userId)
 
-                    is SpecialCircumstance.PreLogin.CompleteRegistration -> {
+                    is SpecialCircumstance.RegistrationEvent -> {
                         throw IllegalStateException(
                             "Special circumstance should have been already handled.",
                         )
@@ -154,6 +149,23 @@ class RootNavViewModel @Inject constructor(
         mutableStateFlow.update { updatedRootNavState }
     }
 
+    private fun getRegistrationEventNavState(
+        registrationEvent: SpecialCircumstance.RegistrationEvent,
+    ): RootNavState = when (registrationEvent) {
+        is SpecialCircumstance.RegistrationEvent.CompleteRegistration -> {
+            RootNavState.CompleteOngoingRegistration(
+                email = registrationEvent.completeRegistrationData.email,
+                verificationToken = registrationEvent.completeRegistrationData.verificationToken,
+                fromEmail = registrationEvent.completeRegistrationData.fromEmail,
+                timestamp = registrationEvent.timestamp,
+            )
+        }
+
+        SpecialCircumstance.RegistrationEvent.ExpiredRegistrationLink -> {
+            RootNavState.ExpiredRegistrationLink
+        }
+    }
+
     private fun UserState.shouldShowRemovePassword(authState: AuthState): Boolean {
         val isLoggedInUsingSso = (authState as? AuthState.Authenticated)
             ?.accessToken
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e86ab8c3a..7449ded51 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -994,4 +994,5 @@ Do you want to switch to this account?</string>
   <string name="restart_registration">Restart registration</string>
   <string name="authenticator_sync">Authenticator Sync</string>
   <string name="allow_bitwarden_authenticator_syncing">Allow Bitwarden Authenticator Syncing</string>
+  <string name="there_was_an_issue_validating_the_registration_token">There was an issue validating the registration token.</string>
 </resources>
diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt
index 99af228b3..3f14b2d67 100644
--- a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt
@@ -6,6 +6,7 @@ import androidx.lifecycle.SavedStateHandle
 import app.cash.turbine.test
 import com.bitwarden.vault.CipherView
 import com.x8bit.bitwarden.data.auth.repository.AuthRepository
+import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
 import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
 import com.x8bit.bitwarden.data.auth.repository.model.UserState
 import com.x8bit.bitwarden.data.auth.util.getCompleteRegistrationDataIntentOrNull
@@ -34,12 +35,14 @@ import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManage
 import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
 import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData
 import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
+import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
 import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
 import com.x8bit.bitwarden.data.platform.repository.model.Environment
 import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
 import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
 import com.x8bit.bitwarden.data.vault.repository.VaultRepository
 import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
+import com.x8bit.bitwarden.ui.platform.base.util.asText
 import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
 import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
 import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut
@@ -81,6 +84,7 @@ class MainViewModelTest : BaseViewModelTest() {
         every { activeUserId } returns DEFAULT_USER_STATE.activeUserId
         every { userStateFlow } returns mutableUserStateFlow
         every { switchAccount(any()) } returns SwitchAccountResult.NoChange
+        coEvery { validateEmailToken(any(), any()) } returns EmailTokenResult.Success
     }
     private val mutableVaultStateEventFlow = bufferedMutableSharedFlow<VaultStateEvent>()
     private val vaultRepository = mockk<VaultRepository> {
@@ -95,6 +99,9 @@ class MainViewModelTest : BaseViewModelTest() {
             authRepository = mockAuthRepository,
             dispatcherManager = FakeDispatcherManager(),
         )
+    private val environmentRepository = mockk<EnvironmentRepository>(relaxed = true) {
+        every { loadEnvironmentForEmail(any()) } returns true
+    }
     private val intentManager: IntentManager = mockk {
         every { getShareDataFromIntent(any()) } returns null
     }
@@ -326,9 +333,12 @@ class MainViewModelTest : BaseViewModelTest() {
 
     @Suppress("MaxLineLength")
     @Test
-    fun `on ReceiveFirstIntent with complete registration data should set the special circumstance to CompleteRegistration`() {
+    fun `on ReceiveFirstIntent with complete registration data should set the special circumstance to CompleteRegistration if token is valid`() {
         val viewModel = createViewModel()
-        val completeRegistrationData = mockk<CompleteRegistrationData>()
+        val completeRegistrationData = mockk<CompleteRegistrationData> {
+            every { email } returns "email"
+            every { verificationToken } returns "token"
+        }
         val mockIntent = mockk<Intent> {
             every { getPasswordlessRequestDataIntentOrNull() } returns null
             every { getAutofillSaveItemOrNull() } returns null
@@ -346,14 +356,173 @@ class MainViewModelTest : BaseViewModelTest() {
             ),
         )
         assertEquals(
-            SpecialCircumstance.PreLogin.CompleteRegistration(
+            SpecialCircumstance.RegistrationEvent.CompleteRegistration(
                 completeRegistrationData = completeRegistrationData,
                 timestamp = FIXED_CLOCK.millis(),
             ),
             specialCircumstanceManager.specialCircumstance,
         )
+
+        verify(exactly = 0) { authRepository.hasPendingAccountAddition = true }
     }
 
+    @Suppress("MaxLineLength")
+    @Test
+    fun `on ReceiveFirstIntent with complete registration data should set pending account addition to true if there is an active user`() {
+        val viewModel = createViewModel()
+        val completeRegistrationData = mockk<CompleteRegistrationData> {
+            every { email } returns "email"
+            every { verificationToken } returns "token"
+        }
+        val mockIntent = mockk<Intent> {
+            every { getPasswordlessRequestDataIntentOrNull() } returns null
+            every { getAutofillSaveItemOrNull() } returns null
+            every { getCompleteRegistrationDataIntentOrNull() } returns completeRegistrationData
+            every { getAutofillSelectionDataOrNull() } returns null
+            every { isMyVaultShortcut } returns false
+            every { isPasswordGeneratorShortcut } returns false
+        }
+        every { intentManager.getShareDataFromIntent(mockIntent) } returns null
+        every { authRepository.activeUserId } returns "activeId"
+        every { authRepository.hasPendingAccountAddition = true } just runs
+
+        viewModel.trySendAction(
+            MainAction.ReceiveFirstIntent(
+                intent = mockIntent,
+            ),
+        )
+        assertEquals(
+            SpecialCircumstance.RegistrationEvent.CompleteRegistration(
+                completeRegistrationData = completeRegistrationData,
+                timestamp = FIXED_CLOCK.millis(),
+            ),
+            specialCircumstanceManager.specialCircumstance,
+        )
+        verify { authRepository.hasPendingAccountAddition = true }
+    }
+
+    @Suppress("MaxLineLength")
+    @Test
+    fun `on ReceiveFirstIntent with complete registration data should set the special circumstance to ExpiredRegistration if token is not valid`() {
+        val viewModel = createViewModel()
+        val intentEmail = "email"
+        val token = "token"
+        val completeRegistrationData = mockk<CompleteRegistrationData> {
+            every { email } returns intentEmail
+            every { verificationToken } returns token
+        }
+        val mockIntent = mockk<Intent> {
+            every { getPasswordlessRequestDataIntentOrNull() } returns null
+            every { getAutofillSaveItemOrNull() } returns null
+            every { getCompleteRegistrationDataIntentOrNull() } returns completeRegistrationData
+            every { getAutofillSelectionDataOrNull() } returns null
+            every { isMyVaultShortcut } returns false
+            every { isPasswordGeneratorShortcut } returns false
+        }
+        every { intentManager.getShareDataFromIntent(mockIntent) } returns null
+        every { authRepository.activeUserId } returns null
+        coEvery {
+            authRepository.validateEmailToken(
+                email = intentEmail,
+                token = token,
+            )
+        } returns EmailTokenResult.Expired
+
+        viewModel.trySendAction(
+            MainAction.ReceiveFirstIntent(
+                intent = mockIntent,
+            ),
+        )
+        assertEquals(
+            SpecialCircumstance.RegistrationEvent.ExpiredRegistrationLink,
+            specialCircumstanceManager.specialCircumstance,
+        )
+    }
+
+    @Suppress("MaxLineLength")
+    @Test
+    fun `on ReceiveFirstIntent with complete registration data should show toast if token is not valid but unable to determine reason`() =
+        runTest {
+            val viewModel = createViewModel()
+            val intentEmail = "email"
+            val token = "token"
+            val completeRegistrationData = mockk<CompleteRegistrationData> {
+                every { email } returns intentEmail
+                every { verificationToken } returns token
+            }
+            val mockIntent = mockk<Intent> {
+                every { getPasswordlessRequestDataIntentOrNull() } returns null
+                every { getAutofillSaveItemOrNull() } returns null
+                every { getCompleteRegistrationDataIntentOrNull() } returns completeRegistrationData
+                every { getAutofillSelectionDataOrNull() } returns null
+                every { isMyVaultShortcut } returns false
+                every { isPasswordGeneratorShortcut } returns false
+            }
+            every { intentManager.getShareDataFromIntent(mockIntent) } returns null
+            every { authRepository.activeUserId } returns null
+            coEvery {
+                authRepository.validateEmailToken(
+                    intentEmail,
+                    token,
+                )
+            } returns EmailTokenResult.Error(message = null)
+
+            viewModel.trySendAction(
+                MainAction.ReceiveFirstIntent(
+                    intent = mockIntent,
+                ),
+            )
+            viewModel.eventFlow.test {
+                assertEquals(
+                    MainEvent.ShowToast(R.string.there_was_an_issue_validating_the_registration_token.asText()),
+                    awaitItem(),
+                )
+            }
+        }
+
+    @Suppress("MaxLineLength")
+    @Test
+    fun `on ReceiveFirstIntent with complete registration data should show toast with custom message if token is not valid but unable to determine reason`() =
+        runTest {
+            val viewModel = createViewModel()
+            val intentEmail = "email"
+            val token = "token"
+            val completeRegistrationData = mockk<CompleteRegistrationData> {
+                every { email } returns intentEmail
+                every { verificationToken } returns token
+            }
+            val mockIntent = mockk<Intent> {
+                every { getPasswordlessRequestDataIntentOrNull() } returns null
+                every { getAutofillSaveItemOrNull() } returns null
+                every { getCompleteRegistrationDataIntentOrNull() } returns completeRegistrationData
+                every { getAutofillSelectionDataOrNull() } returns null
+                every { isMyVaultShortcut } returns false
+                every { isPasswordGeneratorShortcut } returns false
+            }
+            every { intentManager.getShareDataFromIntent(mockIntent) } returns null
+            every { authRepository.activeUserId } returns null
+
+            val expectedMessage = "expectedMessage"
+            coEvery {
+                authRepository.validateEmailToken(
+                    intentEmail,
+                    token,
+                )
+            } returns EmailTokenResult.Error(message = expectedMessage)
+
+            viewModel.trySendAction(
+                MainAction.ReceiveFirstIntent(
+                    intent = mockIntent,
+                ),
+            )
+            viewModel.eventFlow.test {
+                assertEquals(
+                    MainEvent.ShowToast(expectedMessage.asText()),
+                    awaitItem(),
+                )
+            }
+        }
+
     @Suppress("MaxLineLength")
     @Test
     fun `on ReceiveFirstIntent with an autofill save item should set the special circumstance to AutofillSave`() {
@@ -804,6 +973,7 @@ class MainViewModelTest : BaseViewModelTest() {
         vaultRepository = vaultRepository,
         authRepository = authRepository,
         clock = FIXED_CLOCK,
+        environmentRepository = environmentRepository,
         savedStateHandle = savedStateHandle.apply {
             set(SPECIAL_CIRCUMSTANCE_KEY, initialSpecialCircumstance)
         },
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt
index a10b77c6f..4a5e11dd1 100644
--- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt
@@ -16,6 +16,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEm
 import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson
 import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
 import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson
+import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson
+import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson
 import com.x8bit.bitwarden.data.platform.base.BaseServiceTest
 import com.x8bit.bitwarden.data.platform.util.DeviceModelProvider
 import com.x8bit.bitwarden.data.platform.util.asSuccess
@@ -369,6 +371,77 @@ class IdentityServiceTest : BaseServiceTest() {
         assertTrue(result.isFailure)
     }
 
+    @Test
+    fun `verifyEmailToken should return Valid when response is success`() = runTest {
+        server.enqueue(MockResponse().setResponseCode(200))
+        val result = identityService.verifyEmailRegistrationToken(
+            body = VerifyEmailTokenRequestJson(
+                token = EMAIL_TOKEN,
+                email = EMAIL,
+            ),
+        )
+        assertTrue(result.isSuccess)
+    }
+
+    @Suppress("MaxLineLength")
+    @Test
+    fun `verifyEmailToken should return TokenExpired when response is expired error`() = runTest {
+        val json = """
+            {
+              "message": "Expired link. Please restart registration or try logging in. You may already have an account"
+            }
+        """.trimIndent()
+        val response = MockResponse().setResponseCode(400).setBody(json)
+        server.enqueue(response)
+        val result = identityService.verifyEmailRegistrationToken(
+            body = VerifyEmailTokenRequestJson(
+                token = EMAIL_TOKEN,
+                email = EMAIL,
+            ),
+        )
+        assertTrue(result.isSuccess)
+        assertEquals(
+            VerifyEmailTokenResponseJson.TokenExpired,
+            result.getOrThrow(),
+        )
+    }
+
+    @Suppress("MaxLineLength")
+    @Test
+    fun `verifyEmailToken should return Invalid when response message is non expired error`() = runTest {
+        val messageWithOutExpired = "message without expir... whoops"
+        val json = """
+            {
+              "message": "$messageWithOutExpired"
+            }
+        """.trimIndent()
+        val response = MockResponse().setResponseCode(400).setBody(json)
+        server.enqueue(response)
+        val result = identityService.verifyEmailRegistrationToken(
+            body = VerifyEmailTokenRequestJson(
+                token = EMAIL_TOKEN,
+                email = EMAIL,
+            ),
+        )
+        assertTrue(result.isSuccess)
+        assertEquals(
+            VerifyEmailTokenResponseJson.Invalid(messageWithOutExpired),
+            result.getOrThrow(),
+        )
+    }
+
+    @Test
+    fun `verifyEmailToken should return an error when response is an un-handled error`() = runTest {
+        server.enqueue(MockResponse().setResponseCode(500))
+        val result = identityService.verifyEmailRegistrationToken(
+            body = VerifyEmailTokenRequestJson(
+                email = EMAIL,
+                token = EMAIL_TOKEN,
+            ),
+        )
+        assertTrue(result.isFailure)
+    }
+
     companion object {
         private const val UNIQUE_APP_ID = "testUniqueAppId"
         private const val REFRESH_TOKEN = "refreshToken"
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt
index 35c1d4198..c79a7285e 100644
--- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt
@@ -42,6 +42,8 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserD
 import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
 import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
 import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson
+import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenRequestJson
+import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyEmailTokenResponseJson
 import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
 import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
 import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedService
@@ -61,6 +63,7 @@ import com.x8bit.bitwarden.data.auth.manager.model.AuthRequest
 import com.x8bit.bitwarden.data.auth.repository.model.AuthState
 import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
 import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult
+import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
 import com.x8bit.bitwarden.data.auth.repository.model.KnownDeviceResult
 import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
 import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
@@ -6012,6 +6015,90 @@ class AuthRepositoryTest {
         )
     }
 
+    @Test
+    fun `validateEmailToken should return success result when service returns success`() = runTest {
+        coEvery {
+            identityService
+                .verifyEmailRegistrationToken(
+                    body = VerifyEmailTokenRequestJson(
+                        email = EMAIL,
+                        token = EMAIL_VERIFICATION_TOKEN,
+                    ),
+                )
+        } returns VerifyEmailTokenResponseJson.Valid.asSuccess()
+
+        val emailTokenResult = repository.validateEmailToken(EMAIL, EMAIL_VERIFICATION_TOKEN)
+
+        assertEquals(
+            EmailTokenResult.Success,
+            emailTokenResult,
+        )
+    }
+
+    @Test
+    fun `validateEmailToken should return expired result when service returns TokenExpired`() =
+        runTest {
+            coEvery {
+                identityService
+                    .verifyEmailRegistrationToken(
+                        body = VerifyEmailTokenRequestJson(
+                            email = EMAIL,
+                            token = EMAIL_VERIFICATION_TOKEN,
+                        ),
+                    )
+            } returns VerifyEmailTokenResponseJson.TokenExpired.asSuccess()
+
+            val emailTokenResult = repository.validateEmailToken(EMAIL, EMAIL_VERIFICATION_TOKEN)
+
+            assertEquals(
+                EmailTokenResult.Expired,
+                emailTokenResult,
+            )
+        }
+
+    @Suppress("MaxLineLength")
+    @Test
+    fun `validateEmailToken should return error result when service returns error without expired message`() =
+        runTest {
+            val errorMessage = "I haven't heard of second breakfast."
+            coEvery {
+                identityService
+                    .verifyEmailRegistrationToken(
+                        body = VerifyEmailTokenRequestJson(
+                            email = EMAIL,
+                            token = EMAIL_VERIFICATION_TOKEN,
+                        ),
+                    )
+            } returns VerifyEmailTokenResponseJson.Invalid(message = errorMessage).asSuccess()
+
+            val emailTokenResult = repository.validateEmailToken(EMAIL, EMAIL_VERIFICATION_TOKEN)
+
+            assertEquals(
+                EmailTokenResult.Error(message = errorMessage),
+                emailTokenResult,
+            )
+        }
+
+    @Test
+    fun `validateEmailToken should return error result when service returns failure`() = runTest {
+        coEvery {
+            identityService
+                .verifyEmailRegistrationToken(
+                    body = VerifyEmailTokenRequestJson(
+                        email = EMAIL,
+                        token = EMAIL_VERIFICATION_TOKEN,
+                    ),
+                )
+        } returns Exception().asFailure()
+
+        val emailTokenResult = repository.validateEmailToken(EMAIL, EMAIL_VERIFICATION_TOKEN)
+
+        assertEquals(
+            EmailTokenResult.Error(message = null),
+            emailTokenResult,
+        )
+    }
+
     companion object {
         private const val UNIQUE_APP_ID = "testUniqueAppId"
         private const val NAME = "Example Name"
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManagerTest.kt
index 31c71858e..02d2bd91d 100644
--- a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManagerTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/SpecialCircumstanceManagerTest.kt
@@ -51,7 +51,7 @@ class SpecialCircumstanceManagerTest {
                 assertNull(awaitItem())
 
                 val preLoginSpecialCircumstance =
-                    mockk<SpecialCircumstance.PreLogin.CompleteRegistration>()
+                    mockk<SpecialCircumstance.RegistrationEvent.CompleteRegistration>()
 
                 specialCircumstanceManager.specialCircumstance = preLoginSpecialCircumstance
                 assertEquals(preLoginSpecialCircumstance, awaitItem())
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModelTest.kt
index 708ecf4b3..82748359c 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModelTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationViewModelTest.kt
@@ -20,7 +20,7 @@ import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
 import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
 import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
 import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
-import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance.PreLogin
+import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance.RegistrationEvent
 import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
 import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
 import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
@@ -93,7 +93,8 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
         every { getFeatureFlagFlow(FlagKey.OnboardingFlow) } returns mutableFeatureFlagFlow
     }
     private val mutableGeneratorResultFlow = bufferedMutableSharedFlow<GeneratorResult>()
-    private val mockCompleteRegistrationCircumstance = mockk<PreLogin.CompleteRegistration>()
+    private val mockCompleteRegistrationCircumstance =
+        mockk<RegistrationEvent.CompleteRegistration>()
     private val generatorRepository = mockk<GeneratorRepository>(relaxed = true) {
         every { generatorResultFlow } returns mutableGeneratorResultFlow
     }
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreenTest.kt
index 5d136fa9e..e76a6f6ec 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreenTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkScreenTest.kt
@@ -25,7 +25,7 @@ class ExpiredRegistrationLinkScreenTest : BaseComposeTest() {
 
     @Before
     fun setUp() {
-        composeTestRule.setContent {
+        setContentWithBackDispatcher {
             ExpiredRegistrationLinkScreen(
                 onNavigateBack = { onNavigateBackCalled = true },
                 onNavigateToLogin = { onNavigateToLoginCalled = true },
@@ -35,6 +35,12 @@ class ExpiredRegistrationLinkScreenTest : BaseComposeTest() {
         }
     }
 
+    @Test
+    fun `System back event invokes CloseClicked action`() {
+        backDispatcher?.onBackPressed()
+        verify { viewModel.trySendAction(ExpiredRegistrationLinkAction.CloseClicked) }
+    }
+
     @Test
     fun `CloseClicked sends NavigateBack action`() {
         composeTestRule
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModelTest.kt
index 16e10bffc..b40e82f48 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModelTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/expiredregistrationlink/ExpiredRegistrationLinkViewModelTest.kt
@@ -1,37 +1,51 @@
 package com.x8bit.bitwarden.ui.auth.feature.expiredregistrationlink
 
 import app.cash.turbine.test
+import com.x8bit.bitwarden.data.auth.repository.AuthRepository
 import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
+import io.mockk.mockk
+import io.mockk.verify
 import kotlinx.coroutines.test.runTest
 import org.junit.jupiter.api.Assertions.assertEquals
 import org.junit.jupiter.api.Test
 
 class ExpiredRegistrationLinkViewModelTest : BaseViewModelTest() {
 
+    private val authRepository = mockk<AuthRepository>(relaxed = true)
+
     @Test
-    fun `CloseClicked sends NavigateBack event`() = runTest {
-        val viewModel = ExpiredRegistrationLinkViewModel()
+    fun `CloseClicked sends NavigateBack event and resets pending account addition`() = runTest {
+        val viewModel = createViewModel()
         viewModel.eventFlow.test {
             viewModel.trySendAction(ExpiredRegistrationLinkAction.CloseClicked)
             assertEquals(ExpiredRegistrationLinkEvent.NavigateBack, awaitItem())
         }
+        verify { authRepository.hasPendingAccountAddition = true }
     }
 
+    @Suppress("MaxLineLength")
     @Test
-    fun `RestartRegistrationClicked sends NavigateToStartRegistration event`() = runTest {
-        val viewModel = ExpiredRegistrationLinkViewModel()
-        viewModel.eventFlow.test {
-            viewModel.trySendAction(ExpiredRegistrationLinkAction.RestartRegistrationClicked)
-            assertEquals(ExpiredRegistrationLinkEvent.NavigateToStartRegistration, awaitItem())
+    fun `RestartRegistrationClicked sends NavigateToStartRegistration event and resets pending account addition`() =
+        runTest {
+            val viewModel = createViewModel()
+            viewModel.eventFlow.test {
+                viewModel.trySendAction(ExpiredRegistrationLinkAction.RestartRegistrationClicked)
+                assertEquals(ExpiredRegistrationLinkEvent.NavigateToStartRegistration, awaitItem())
+            }
+            verify { authRepository.hasPendingAccountAddition = true }
         }
-    }
 
     @Test
-    fun `GoToLoginClicked sends NavigateToLogin event`() = runTest {
-        val viewModel = ExpiredRegistrationLinkViewModel()
-        viewModel.eventFlow.test {
-            viewModel.trySendAction(ExpiredRegistrationLinkAction.GoToLoginClicked)
-            assertEquals(ExpiredRegistrationLinkEvent.NavigateToLogin, awaitItem())
+    fun `GoToLoginClicked sends NavigateToLogin event and resets pending account addition`() =
+        runTest {
+            val viewModel = createViewModel()
+            viewModel.eventFlow.test {
+                viewModel.trySendAction(ExpiredRegistrationLinkAction.GoToLoginClicked)
+                assertEquals(ExpiredRegistrationLinkEvent.NavigateToLogin, awaitItem())
+            }
+            verify { authRepository.hasPendingAccountAddition = true }
         }
-    }
+
+    private fun createViewModel(): ExpiredRegistrationLinkViewModel =
+        ExpiredRegistrationLinkViewModel(authRepository = authRepository)
 }
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt
index 035f773a7..9081b1774 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt
@@ -663,7 +663,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
         every { authRepository.hasPendingAccountAddition } returns false
 
         specialCircumstanceManager.specialCircumstance =
-            SpecialCircumstance.PreLogin.CompleteRegistration(
+            SpecialCircumstance.RegistrationEvent.CompleteRegistration(
                 CompleteRegistrationData(
                     email = "example@email.com",
                     verificationToken = "token",
@@ -690,7 +690,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
         every { authRepository.hasPendingAccountAddition } returns true
 
         specialCircumstanceManager.specialCircumstance =
-            SpecialCircumstance.PreLogin.CompleteRegistration(
+            SpecialCircumstance.RegistrationEvent.CompleteRegistration(
                 CompleteRegistrationData(
                     email = "example@email.com",
                     verificationToken = "token",
@@ -740,7 +740,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
         every { authRepository.hasPendingAccountAddition } returns true
 
         specialCircumstanceManager.specialCircumstance =
-            SpecialCircumstance.PreLogin.CompleteRegistration(
+            SpecialCircumstance.RegistrationEvent.CompleteRegistration(
                 CompleteRegistrationData(
                     email = "example@email.com",
                     verificationToken = "token",
@@ -784,6 +784,97 @@ class RootNavViewModelTest : BaseViewModelTest() {
         )
     }
 
+    @Suppress("MaxLineLength")
+    @Test
+    fun `when there are no accounts but there is a ExpiredRegistrationLink special circumstance the nav state should be ExpiredRegistrationLink`() {
+        every { authRepository.hasPendingAccountAddition } returns false
+
+        specialCircumstanceManager.specialCircumstance =
+            SpecialCircumstance.RegistrationEvent.ExpiredRegistrationLink
+        mutableUserStateFlow.tryEmit(null)
+        val viewModel = createViewModel()
+        assertEquals(
+            RootNavState.ExpiredRegistrationLink,
+            viewModel.stateFlow.value,
+        )
+    }
+
+    @Suppress("MaxLineLength")
+    @Test
+    fun `when the active user has an unlocked vault but there is a ExpiredRegistrationLink special circumstance the nav state should be ExpiredRegistrationLink`() {
+        every { authRepository.hasPendingAccountAddition } returns true
+
+        specialCircumstanceManager.specialCircumstance =
+            SpecialCircumstance.RegistrationEvent.ExpiredRegistrationLink
+        mutableUserStateFlow.tryEmit(
+            UserState(
+                activeUserId = "activeUserId",
+                accounts = listOf(
+                    UserState.Account(
+                        userId = "activeUserId",
+                        name = "name",
+                        email = "email",
+                        avatarColorHex = "avatarHexColor",
+                        environment = Environment.Us,
+                        isPremium = true,
+                        isLoggedIn = true,
+                        isVaultUnlocked = true,
+                        needsPasswordReset = false,
+                        isBiometricsEnabled = false,
+                        organizations = emptyList(),
+                        needsMasterPassword = false,
+                        trustedDevice = null,
+                        hasMasterPassword = true,
+                        isUsingKeyConnector = false,
+                    ),
+                ),
+            ),
+        )
+        val viewModel = createViewModel()
+        assertEquals(
+            RootNavState.ExpiredRegistrationLink,
+            viewModel.stateFlow.value,
+        )
+    }
+
+    @Suppress("MaxLineLength")
+    @Test
+    fun `when the active user has a locked vault but there is a ExpiredRegistrationLink special circumstance the nav state should be ExpiredRegistrationLink`() {
+        every { authRepository.hasPendingAccountAddition } returns true
+
+        specialCircumstanceManager.specialCircumstance =
+            SpecialCircumstance.RegistrationEvent.ExpiredRegistrationLink
+        mutableUserStateFlow.tryEmit(
+            UserState(
+                activeUserId = "activeUserId",
+                accounts = listOf(
+                    UserState.Account(
+                        userId = "activeUserId",
+                        name = "name",
+                        email = "email",
+                        avatarColorHex = "avatarColorHex",
+                        environment = Environment.Us,
+                        isPremium = true,
+                        isLoggedIn = true,
+                        isVaultUnlocked = false,
+                        needsPasswordReset = false,
+                        isBiometricsEnabled = false,
+                        organizations = emptyList(),
+                        needsMasterPassword = false,
+                        trustedDevice = null,
+                        hasMasterPassword = true,
+                        isUsingKeyConnector = false,
+                    ),
+                ),
+            ),
+        )
+        val viewModel = createViewModel()
+        assertEquals(
+            RootNavState.ExpiredRegistrationLink,
+            viewModel.stateFlow.value,
+        )
+    }
+
     @Test
     fun `when the active user has a locked vault the nav state should be VaultLocked`() {
         mutableUserStateFlow.tryEmit(