From a35ec8cf3c19ecc83791c9b89825ff6008e47a2c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Bispo?= <abispo@bitwarden.com>
Date: Fri, 27 Dec 2024 15:03:33 +0000
Subject: [PATCH] [PM-8217] New device two factor notice (#4508)

Co-authored-by: Federico Maccaroni <fedemkr@gmail.com>
---
 .../auth/datasource/disk/AuthDiskSource.kt    |  13 +-
 .../datasource/disk/AuthDiskSourceImpl.kt     |  19 +
 .../model/NewDeviceNoticeDisplayStatus.kt     |  60 +++
 .../data/auth/repository/AuthRepository.kt    |  16 +
 .../auth/repository/AuthRepositoryImpl.kt     |  87 ++++
 .../data/platform/manager/model/FlagKey.kt    |  20 +
 .../NewDeviceNoticeEmailAccessNavigation.kt   |   2 +
 .../NewDeviceNoticeEmailAccessScreen.kt       |   2 +
 .../NewDeviceNoticeEmailAccessViewModel.kt    |  34 +-
 .../NewDeviceNoticeTwoFactorNavigation.kt     |   4 +-
 .../NewDeviceNoticeTwoFactorScreen.kt         |  53 ++-
 .../NewDeviceNoticeTwoFactorViewModel.kt      | 130 +++++-
 .../components/FeatureFlagListItems.kt        |   4 +
 .../platform/feature/rootnav/RootNavScreen.kt |   9 +
 .../feature/rootnav/RootNavViewModel.kt       |  11 +
 .../vaultunlocked/VaultUnlockedNavigation.kt  |  10 +
 app/src/main/res/values/strings.xml           |   1 +
 .../main/res/values/strings_non_localized.xml |   2 +
 .../datasource/disk/AuthDiskSourceTest.kt     |  60 +++
 .../disk/util/FakeAuthDiskSource.kt           |  14 +
 .../auth/repository/AuthRepositoryTest.kt     | 418 +++++++++++++++++-
 .../data/platform/manager/FlagKeyTest.kt      |  20 +
 .../NewDeviceNoticeEmailAccessScreenTest.kt   |  21 +-
 ...NewDeviceNoticeEmailAccessViewModelTest.kt |  56 ++-
 .../NewDeviceNoticeTwoFactorScreenTest.kt     | 123 +++++-
 .../NewDeviceNoticeTwoFactorViewModelTest.kt  | 151 ++++++-
 .../debugmenu/DebugMenuViewModelTest.kt       |   4 +
 .../feature/rootnav/RootNavScreenTest.kt      |  10 +
 .../feature/rootnav/RootNavViewModelTest.kt   |  40 ++
 29 files changed, 1343 insertions(+), 51 deletions(-)
 create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/model/NewDeviceNoticeDisplayStatus.kt

diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt
index 7f207df50..ed5a90cfa 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt
@@ -1,6 +1,7 @@
 package com.x8bit.bitwarden.data.auth.datasource.disk
 
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
@@ -328,7 +329,17 @@ interface AuthDiskSource {
     fun storeShowImportLogins(userId: String, showImportLogins: Boolean?)
 
     /**
-     * Emits updates that track [getShowImportLogins]. This will replay the last known value,
+     * Emits updates that track [getShowImportLogins]. This will replay the last known value.
      */
     fun getShowImportLoginsFlow(userId: String): Flow<Boolean?>
+
+    /**
+     * Gets the new device notice state for the given [userId].
+     */
+    fun getNewDeviceNoticeState(userId: String): NewDeviceNoticeState
+
+    /**
+     * Stores the new device notice state for the given [userId].
+     */
+    fun storeNewDeviceNoticeState(userId: String, newState: NewDeviceNoticeState?)
 }
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt
index a702974e8..302a2ed57 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt
@@ -2,6 +2,8 @@ package com.x8bit.bitwarden.data.auth.datasource.disk
 
 import android.content.SharedPreferences
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
@@ -46,6 +48,7 @@ private const val TDE_LOGIN_COMPLETE = "tdeLoginComplete"
 private const val USES_KEY_CONNECTOR = "usesKeyConnector"
 private const val ONBOARDING_STATUS_KEY = "onboardingStatus"
 private const val SHOW_IMPORT_LOGINS_KEY = "showImportLogins"
+private const val NEW_DEVICE_NOTICE_STATE = "newDeviceNoticeState"
 
 /**
  * Primary implementation of [AuthDiskSource].
@@ -471,6 +474,22 @@ class AuthDiskSourceImpl(
         getMutableShowImportLoginsFlow(userId)
             .onSubscription { emit(getShowImportLogins(userId)) }
 
+    override fun getNewDeviceNoticeState(userId: String): NewDeviceNoticeState {
+        return getString(key = NEW_DEVICE_NOTICE_STATE.appendIdentifier(userId))?.let {
+            json.decodeFromStringOrNull(it)
+        } ?: NewDeviceNoticeState(
+            displayStatus = NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN,
+            lastSeenDate = null,
+        )
+    }
+
+    override fun storeNewDeviceNoticeState(userId: String, newState: NewDeviceNoticeState?) {
+        putString(
+            key = NEW_DEVICE_NOTICE_STATE.appendIdentifier(userId),
+            value = newState?.let { json.encodeToString(it) },
+        )
+    }
+
     private fun generateAndStoreUniqueAppId(): String =
         UUID
             .randomUUID()
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/model/NewDeviceNoticeDisplayStatus.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/model/NewDeviceNoticeDisplayStatus.kt
new file mode 100644
index 000000000..2f9a1f8ca
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/model/NewDeviceNoticeDisplayStatus.kt
@@ -0,0 +1,60 @@
+package com.x8bit.bitwarden.data.auth.datasource.disk.model
+
+import kotlinx.serialization.Contextual
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import java.time.ZonedDateTime
+
+/**
+ * Describes the current display status of the new device notice screen.
+ */
+@Serializable
+enum class NewDeviceNoticeDisplayStatus {
+    /**
+     * The user has seen the screen and indicated they can access their email.
+     */
+    @SerialName("canAccessEmail")
+    CAN_ACCESS_EMAIL,
+
+    /**
+     * The user has indicated they can access their email
+     * as specified by the Permanent mode of the notice.
+     */
+    @SerialName("canAccessEmailPermanent")
+    CAN_ACCESS_EMAIL_PERMANENT,
+
+    /**
+     * The user has not seen the screen.
+     */
+    @SerialName("hasNotSeen")
+    HAS_NOT_SEEN,
+
+    /**
+     * The user has seen the screen and selected "remind me later".
+     */
+    @SerialName("hasSeen")
+    HAS_SEEN,
+}
+
+/**
+ * The state of the new device notice screen.
+ */
+@Suppress("MagicNumber")
+@Serializable
+data class NewDeviceNoticeState(
+    @SerialName("displayStatus")
+    val displayStatus: NewDeviceNoticeDisplayStatus,
+
+    @SerialName("lastSeenDate")
+    @Contextual
+    val lastSeenDate: ZonedDateTime?,
+) {
+    /**
+     * Whether the [lastSeenDate] is at least 7 days old.
+     */
+    val shouldDisplayNoticeIfSeen = lastSeenDate
+        ?.isBefore(
+            ZonedDateTime.now().minusDays(7),
+        )
+        ?: false
+}
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 b9102b146..cf96efda0 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
@@ -1,6 +1,7 @@
 package com.x8bit.bitwarden.data.auth.repository
 
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
 import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
 import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
@@ -401,4 +402,19 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
      * Update the value of the onboarding status for the user.
      */
     fun setOnboardingStatus(userId: String, status: OnboardingStatus?)
+
+    /**
+     * Checks if a new device notice should be displayed.
+     */
+    fun checkUserNeedsNewDeviceTwoFactorNotice(): Boolean
+
+    /**
+     * Gets the new device notice state of active user.
+     */
+    fun getNewDeviceNoticeState(): NewDeviceNoticeState?
+
+    /**
+     * Stores the new device notice state for active user.
+     */
+    fun setNewDeviceNoticeState(newState: NewDeviceNoticeState?)
 }
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 0902f2437..5c305c099 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
@@ -8,6 +8,8 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
 import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountResponseJson
@@ -106,6 +108,7 @@ import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
 import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies
 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.platform.repository.util.toEnvironmentUrls
 import com.x8bit.bitwarden.data.platform.util.asFailure
@@ -141,6 +144,7 @@ import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.flow.receiveAsFlow
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
+import java.time.ZonedDateTime
 import javax.inject.Singleton
 
 /**
@@ -1333,6 +1337,89 @@ class AuthRepositoryImpl(
         authDiskSource.storeOnboardingStatus(userId = userId, onboardingStatus = status)
     }
 
+    override fun getNewDeviceNoticeState(): NewDeviceNoticeState? {
+        return activeUserId?.let { userId ->
+            authDiskSource.getNewDeviceNoticeState(userId = userId)
+        }
+    }
+
+    override fun setNewDeviceNoticeState(newState: NewDeviceNoticeState?) {
+        activeUserId?.let { userId ->
+            authDiskSource.storeNewDeviceNoticeState(userId = userId, newState = newState)
+        }
+    }
+
+    override fun checkUserNeedsNewDeviceTwoFactorNotice(): Boolean {
+        return activeUserId?.let { userId ->
+            val temporaryFlag = featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss)
+            val permanentFlag = featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss)
+
+            // check if feature flags are disabled
+            if (!temporaryFlag && !permanentFlag) {
+                return false
+            }
+
+            if (!newDeviceNoticePreConditionsValid()) {
+                return false
+            }
+
+            val newDeviceNoticeState = authDiskSource.getNewDeviceNoticeState(userId = userId)
+            return when (newDeviceNoticeState.displayStatus) {
+                // if the user has already attested email access but permanent flag is enabled,
+                // the notice needs to appear again
+                NewDeviceNoticeDisplayStatus.CAN_ACCESS_EMAIL -> permanentFlag
+                // if the user has already seen but 7 days have already passed,
+                // the notice needs to appear again
+                NewDeviceNoticeDisplayStatus.HAS_SEEN ->
+                    newDeviceNoticeState.shouldDisplayNoticeIfSeen
+                NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN -> true
+                // the user never needs to see the notice again
+                NewDeviceNoticeDisplayStatus.CAN_ACCESS_EMAIL_PERMANENT -> false
+            }
+        }
+            ?: false
+    }
+
+    /**
+     * Checks if the preconditions are met for a user to see a new device notice:
+     * - Must be a Bitwarden cloud user.
+     * - The account must be at least one week old.
+     * - Cannot have an active policy requiring SSO to be enabled.
+     * - Cannot have two-factor authentication enabled.
+     */
+    private fun newDeviceNoticePreConditionsValid(): Boolean {
+        if (environmentRepository.environment.type == Environment.Type.SELF_HOSTED) {
+            return false
+        }
+
+        val userProfile = authDiskSource.userState?.activeAccount?.profile
+        val isProfileAtLeastWeekOld = userProfile
+            ?.let {
+                it.creationDate
+                    ?.plusWeeks(1)
+                    ?.isBefore(
+                        ZonedDateTime.now(),
+                    )
+            }
+            ?: false
+        if (!isProfileAtLeastWeekOld) {
+            return false
+        }
+
+        val hasTwoFactorEnabled = userProfile
+            ?.isTwoFactorEnabled
+            ?: false
+        if (hasTwoFactorEnabled) {
+            return false
+        }
+
+        val hasSSOPolicy =
+            policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO)
+                .any { p -> p.isEnabled }
+
+        return !hasSSOPolicy
+    }
+
     @Suppress("CyclomaticComplexMethod")
     private suspend fun validatePasswordAgainstPolicy(
         password: String,
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt
index 353f955e9..1b2bf1ea7 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt
@@ -36,6 +36,8 @@ sealed class FlagKey<out T : Any> {
                 CredentialExchangeProtocolImport,
                 CredentialExchangeProtocolExport,
                 AppReviewPrompt,
+                NewDevicePermanentDismiss,
+                NewDeviceTemporaryDismiss,
             )
         }
     }
@@ -141,6 +143,24 @@ sealed class FlagKey<out T : Any> {
         override val isRemotelyConfigured: Boolean = true
     }
 
+    /**
+     * Data object holding the feature flag key for the New Device Temporary Dismiss feature.
+     */
+    data object NewDeviceTemporaryDismiss : FlagKey<Boolean>() {
+        override val keyName: String = "new-device-temporary-dismiss"
+        override val defaultValue: Boolean = false
+        override val isRemotelyConfigured: Boolean = true
+    }
+
+    /**
+     * Data object holding the feature flag key for the New Device Permanent Dismiss feature.
+     */
+    data object NewDevicePermanentDismiss : FlagKey<Boolean>() {
+        override val keyName: String = "new-device-permanent-dismiss"
+        override val defaultValue: Boolean = false
+        override val isRemotelyConfigured: Boolean = true
+    }
+
     //region Dummy keys for testing
     /**
      * Data object holding the key for a [Boolean] flag to be used in tests.
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessNavigation.kt
index dca5b9ec3..2be6f4a47 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessNavigation.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessNavigation.kt
@@ -41,6 +41,7 @@ fun NavController.navigateToNewDeviceNoticeEmailAccess(
  * Add the new device notice email access screen to the nav graph.
  */
 fun NavGraphBuilder.newDeviceNoticeEmailAccessDestination(
+    onNavigateBackToVault: () -> Unit,
     onNavigateToTwoFactorOptions: () -> Unit,
 ) {
     composableWithSlideTransitions(
@@ -50,6 +51,7 @@ fun NavGraphBuilder.newDeviceNoticeEmailAccessDestination(
         ),
     ) {
         NewDeviceNoticeEmailAccessScreen(
+            onNavigateBackToVault = onNavigateBackToVault,
             onNavigateToTwoFactorOptions = onNavigateToTwoFactorOptions,
         )
     }
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreen.kt
index 4313b461d..596777274 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreen.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreen.kt
@@ -44,6 +44,7 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
  */
 @Composable
 fun NewDeviceNoticeEmailAccessScreen(
+    onNavigateBackToVault: () -> Unit,
     onNavigateToTwoFactorOptions: () -> Unit,
     viewModel: NewDeviceNoticeEmailAccessViewModel = hiltViewModel(),
 ) {
@@ -51,6 +52,7 @@ fun NewDeviceNoticeEmailAccessScreen(
     EventsEffect(viewModel = viewModel) { event ->
         when (event) {
             NavigateToTwoFactorOptions -> onNavigateToTwoFactorOptions()
+            NewDeviceNoticeEmailAccessEvent.NavigateBackToVault -> onNavigateBackToVault()
         }
     }
 
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModel.kt
index 32a20f9cb..78b2d256f 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModel.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModel.kt
@@ -2,6 +2,12 @@ package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice
 
 import android.os.Parcelable
 import androidx.lifecycle.SavedStateHandle
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus.CAN_ACCESS_EMAIL
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus.CAN_ACCESS_EMAIL_PERMANENT
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState
+import com.x8bit.bitwarden.data.auth.repository.AuthRepository
+import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
+import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
 import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailAccessAction.ContinueClick
 import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailAccessAction.EmailAccessToggle
 import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailAccessEvent.NavigateToTwoFactorOptions
@@ -9,6 +15,7 @@ import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
 import dagger.hilt.android.lifecycle.HiltViewModel
 import kotlinx.coroutines.flow.update
 import kotlinx.parcelize.Parcelize
+import java.time.ZonedDateTime
 import javax.inject.Inject
 
 private const val KEY_STATE = "state"
@@ -18,6 +25,8 @@ private const val KEY_STATE = "state"
  */
 @HiltViewModel
 class NewDeviceNoticeEmailAccessViewModel @Inject constructor(
+    private val authRepository: AuthRepository,
+    private val featureFlagManager: FeatureFlagManager,
     savedStateHandle: SavedStateHandle,
 ) : BaseViewModel<
     NewDeviceNoticeEmailAccessState,
@@ -38,8 +47,24 @@ class NewDeviceNoticeEmailAccessViewModel @Inject constructor(
     }
 
     private fun handleContinueClick() {
-        // TODO PM-8217: update new device notice status and navigate accordingly
-        sendEvent(NavigateToTwoFactorOptions)
+        if (state.isEmailAccessEnabled) {
+            val displayStatus =
+                if (featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss)) {
+                    CAN_ACCESS_EMAIL_PERMANENT
+                } else {
+                    CAN_ACCESS_EMAIL
+                }
+
+            authRepository.setNewDeviceNoticeState(
+                NewDeviceNoticeState(
+                    displayStatus = displayStatus,
+                    lastSeenDate = ZonedDateTime.now(),
+                ),
+            )
+            sendEvent(NewDeviceNoticeEmailAccessEvent.NavigateBackToVault)
+        } else {
+            sendEvent(NavigateToTwoFactorOptions)
+        }
     }
 
     private fun handleEmailAccessToggle(action: EmailAccessToggle) {
@@ -66,6 +91,11 @@ sealed class NewDeviceNoticeEmailAccessEvent {
      * Navigates to the Two Factor Options screen.
      */
     data object NavigateToTwoFactorOptions : NewDeviceNoticeEmailAccessEvent()
+
+    /**
+     * Navigates back.
+     */
+    data object NavigateBackToVault : NewDeviceNoticeEmailAccessEvent()
 }
 
 /**
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorNavigation.kt
index 1e60f58fb..6c2896900 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorNavigation.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorNavigation.kt
@@ -23,13 +23,13 @@ fun NavController.navigateToNewDeviceNoticeTwoFactor(
  * Add the new device notice two factor screen to the nav graph.
  */
 fun NavGraphBuilder.newDeviceNoticeTwoFactorDestination(
-    onNavigateBack: () -> Unit,
+    onNavigateBackToVault: () -> Unit,
 ) {
     composableWithSlideTransitions(
         route = NEW_DEVICE_NOTICE_TWO_FACTOR_ROUTE,
     ) {
         NewDeviceNoticeTwoFactorScreen(
-            onNavigateBack = onNavigateBack,
+            onNavigateBackToVault = onNavigateBackToVault,
         )
     }
 }
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorScreen.kt
index 7190232e7..3cd9c30b3 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorScreen.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorScreen.kt
@@ -13,6 +13,7 @@ import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.res.stringResource
@@ -21,17 +22,21 @@ import androidx.compose.ui.tooling.preview.PreviewScreenSizes
 import androidx.compose.ui.unit.dp
 import androidx.core.net.toUri
 import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import com.x8bit.bitwarden.R
 import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.ChangeAccountEmailClick
+import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.ContinueDialogClick
+import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.DismissDialogClick
 import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.RemindMeLaterClick
 import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.TurnOnTwoFactorClick
-import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorEvent.NavigateBack
+import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorEvent.NavigateBackToVault
 import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorEvent.NavigateToChangeAccountEmail
 import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorEvent.NavigateToTurnOnTwoFactor
 import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
 import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
 import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
 import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
+import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
 import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
 import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
 import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
@@ -43,10 +48,11 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
  */
 @Composable
 fun NewDeviceNoticeTwoFactorScreen(
-    onNavigateBack: () -> Unit,
+    onNavigateBackToVault: () -> Unit,
     intentManager: IntentManager = LocalIntentManager.current,
     viewModel: NewDeviceNoticeTwoFactorViewModel = hiltViewModel(),
 ) {
+    val state by viewModel.stateFlow.collectAsStateWithLifecycle()
     EventsEffect(viewModel = viewModel) { event ->
         when (event) {
             is NavigateToTurnOnTwoFactor -> {
@@ -57,10 +63,28 @@ fun NewDeviceNoticeTwoFactorScreen(
                 intentManager.launchUri(event.url.toUri())
             }
 
-            NavigateBack -> onNavigateBack()
+            NavigateBackToVault -> onNavigateBackToVault()
         }
     }
 
+    // Show dialog if needed:
+    when (val dialogState = state.dialogState) {
+        is NewDeviceNoticeTwoFactorDialogState.TurnOnTwoFactorDialog,
+        is NewDeviceNoticeTwoFactorDialogState.ChangeAccountEmailDialog,
+            ->
+            BitwardenTwoButtonDialog(
+                title = stringResource(R.string.continue_to_web_app),
+                message = dialogState.message(),
+                confirmButtonText = stringResource(id = R.string.confirm),
+                dismissButtonText = stringResource(id = R.string.cancel),
+                onConfirmClick = { viewModel.trySendAction(ContinueDialogClick) },
+                onDismissClick = { viewModel.trySendAction(DismissDialogClick) },
+                onDismissRequest = { viewModel.trySendAction(DismissDialogClick) },
+            )
+
+        null -> Unit
+    }
+
     BitwardenScaffold {
         NewDeviceNoticeTwoFactorContent(
             onTurnOnTwoFactorClick = {
@@ -72,6 +96,7 @@ fun NewDeviceNoticeTwoFactorScreen(
             onRemindMeLaterClick = {
                 viewModel.trySendAction(RemindMeLaterClick)
             },
+            state = state,
         )
     }
 }
@@ -84,6 +109,7 @@ private fun NewDeviceNoticeTwoFactorContent(
     onTurnOnTwoFactorClick: () -> Unit,
     onChangeAccountEmailClick: () -> Unit,
     onRemindMeLaterClick: () -> Unit,
+    state: NewDeviceNoticeTwoFactorState,
     modifier: Modifier = Modifier,
 ) {
     Column(
@@ -100,6 +126,7 @@ private fun NewDeviceNoticeTwoFactorContent(
             onTurnOnTwoFactorClick = onTurnOnTwoFactorClick,
             onChangeAccountEmailClick = onChangeAccountEmailClick,
             onRemindMeLaterClick = onRemindMeLaterClick,
+            state = state,
         )
         Spacer(modifier = Modifier.navigationBarsPadding())
     }
@@ -142,6 +169,7 @@ private fun ColumnScope.MainContent(
     onTurnOnTwoFactorClick: () -> Unit,
     onChangeAccountEmailClick: () -> Unit,
     onRemindMeLaterClick: () -> Unit,
+    state: NewDeviceNoticeTwoFactorState,
 ) {
     BitwardenFilledButton(
         label = stringResource(R.string.turn_on_two_step_login),
@@ -158,13 +186,15 @@ private fun ColumnScope.MainContent(
         modifier = Modifier
             .fillMaxWidth(),
     )
-    Spacer(modifier = Modifier.height(12.dp))
-    BitwardenOutlinedButton(
-        label = stringResource(R.string.remind_me_later),
-        onClick = onRemindMeLaterClick,
-        modifier = Modifier
-            .fillMaxWidth(),
-    )
+    if (state.shouldShowRemindMeLater) {
+        Spacer(modifier = Modifier.height(12.dp))
+        BitwardenOutlinedButton(
+            label = stringResource(R.string.remind_me_later),
+            onClick = onRemindMeLaterClick,
+            modifier = Modifier
+                .fillMaxWidth(),
+        )
+    }
 }
 
 @PreviewScreenSizes
@@ -175,6 +205,9 @@ private fun NewDeviceNoticeTwoFactorScreen_preview() {
             onTurnOnTwoFactorClick = {},
             onChangeAccountEmailClick = {},
             onRemindMeLaterClick = {},
+            state = NewDeviceNoticeTwoFactorState(
+                shouldShowRemindMeLater = true,
+            ),
         )
     }
 }
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorViewModel.kt
index b342f8c8f..5ab680290 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorViewModel.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorViewModel.kt
@@ -1,12 +1,27 @@
 package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice
 
+import android.os.Parcelable
+import com.x8bit.bitwarden.R
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState
+import com.x8bit.bitwarden.data.auth.repository.AuthRepository
+import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
+import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
 import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
 import com.x8bit.bitwarden.data.platform.repository.util.baseWebVaultUrlOrDefault
 import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.ChangeAccountEmailClick
+import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.ContinueDialogClick
+import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.DismissDialogClick
 import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.RemindMeLaterClick
 import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.TurnOnTwoFactorClick
+import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorDialogState.ChangeAccountEmailDialog
+import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorDialogState.TurnOnTwoFactorDialog
 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 dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.update
+import kotlinx.parcelize.Parcelize
 import javax.inject.Inject
 
 /**
@@ -14,9 +29,19 @@ import javax.inject.Inject
  */
 @HiltViewModel
 class NewDeviceNoticeTwoFactorViewModel @Inject constructor(
+    val authRepository: AuthRepository,
     val environmentRepository: EnvironmentRepository,
-) : BaseViewModel<Unit, NewDeviceNoticeTwoFactorEvent, NewDeviceNoticeTwoFactorAction>(
-    initialState = Unit,
+    val featureFlagManager: FeatureFlagManager,
+) : BaseViewModel<
+    NewDeviceNoticeTwoFactorState,
+    NewDeviceNoticeTwoFactorEvent,
+    NewDeviceNoticeTwoFactorAction,
+    >(
+    initialState = NewDeviceNoticeTwoFactorState(
+        shouldShowRemindMeLater = !featureFlagManager.getFeatureFlag(
+            FlagKey.NewDevicePermanentDismiss,
+        ),
+    ),
 ) {
     private val webTwoFactorUrl: String
         get() {
@@ -38,22 +63,51 @@ class NewDeviceNoticeTwoFactorViewModel @Inject constructor(
 
     override fun handleAction(action: NewDeviceNoticeTwoFactorAction) {
         when (action) {
-            ChangeAccountEmailClick -> sendEvent(
-                NewDeviceNoticeTwoFactorEvent.NavigateToChangeAccountEmail(
-                    url = webAccountUrl,
-                ),
-            )
+            ChangeAccountEmailClick -> updateDialogState(newState = ChangeAccountEmailDialog)
 
-            TurnOnTwoFactorClick -> sendEvent(
-                NewDeviceNoticeTwoFactorEvent.NavigateToTurnOnTwoFactor(
-                    url = webTwoFactorUrl,
-                ),
-            )
+            TurnOnTwoFactorClick -> updateDialogState(newState = TurnOnTwoFactorDialog)
 
-            RemindMeLaterClick -> {
-                // TODO PM-8217: Add logic to remind me later
-                sendEvent(NewDeviceNoticeTwoFactorEvent.NavigateBack)
+            RemindMeLaterClick -> handleRemindMeLater()
+
+            DismissDialogClick -> updateDialogState(newState = null)
+
+            ContinueDialogClick -> handleContinueDialog()
+        }
+    }
+
+    private fun handleRemindMeLater() {
+        authRepository.setNewDeviceNoticeState(
+            NewDeviceNoticeState(
+                displayStatus = NewDeviceNoticeDisplayStatus.HAS_SEEN,
+                lastSeenDate = null,
+            ),
+        )
+        sendEvent(NewDeviceNoticeTwoFactorEvent.NavigateBackToVault)
+    }
+
+    private fun handleContinueDialog() {
+        when (state.dialogState) {
+            is ChangeAccountEmailDialog -> {
+                sendEvent(
+                    NewDeviceNoticeTwoFactorEvent.NavigateToChangeAccountEmail(url = webAccountUrl),
+                )
+                updateDialogState(newState = null)
             }
+
+            is TurnOnTwoFactorDialog -> {
+                sendEvent(
+                    NewDeviceNoticeTwoFactorEvent.NavigateToTurnOnTwoFactor(url = webTwoFactorUrl),
+                )
+                updateDialogState(newState = null)
+            }
+
+            null -> return
+        }
+    }
+
+    private fun updateDialogState(newState: NewDeviceNoticeTwoFactorDialogState?) {
+        mutableStateFlow.update {
+            it.copy(dialogState = newState)
         }
     }
 }
@@ -75,9 +129,9 @@ sealed class NewDeviceNoticeTwoFactorEvent {
     data class NavigateToChangeAccountEmail(val url: String) : NewDeviceNoticeTwoFactorEvent()
 
     /**
-     * Navigates back.
+     * Navigates back to vault.
      */
-    data object NavigateBack : NewDeviceNoticeTwoFactorEvent()
+    data object NavigateBackToVault : NewDeviceNoticeTwoFactorEvent()
 }
 
 /**
@@ -98,4 +152,46 @@ sealed class NewDeviceNoticeTwoFactorAction {
      * User tapped the remind me later button.
      */
     data object RemindMeLaterClick : NewDeviceNoticeTwoFactorAction()
+
+    /**
+     * User tapped the dismiss dialog button.
+     */
+    data object DismissDialogClick : NewDeviceNoticeTwoFactorAction()
+
+    /**
+     * User tapped the continue dialog button.
+     */
+    data object ContinueDialogClick : NewDeviceNoticeTwoFactorAction()
+}
+
+/**
+ * Models state of the new device notice two factor screen.
+ */
+@Parcelize
+data class NewDeviceNoticeTwoFactorState(
+    val dialogState: NewDeviceNoticeTwoFactorDialogState? = null,
+    val shouldShowRemindMeLater: Boolean,
+) : Parcelable
+
+/**
+ * Dialog states for the new device notice two factor screen.
+ */
+sealed class NewDeviceNoticeTwoFactorDialogState(
+    val message: Text,
+) : Parcelable {
+    /**
+     * Represents the turn on two factor dialog.
+     */
+    @Parcelize
+    data object TurnOnTwoFactorDialog : NewDeviceNoticeTwoFactorDialogState(
+        message = R.string.two_step_login_description_long.asText(),
+    )
+
+    /**
+     * Represents the change account email dialog.
+     */
+    @Parcelize
+    data object ChangeAccountEmailDialog : NewDeviceNoticeTwoFactorDialogState(
+        R.string.you_can_change_your_account_email_on_the_bitwarden_web_app.asText(),
+    )
 }
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt
index d85c87b04..3783148fe 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt
@@ -33,6 +33,8 @@ fun <T : Any> FlagKey<T>.ListItemContent(
     FlagKey.CredentialExchangeProtocolExport,
     FlagKey.AppReviewPrompt,
     FlagKey.CipherKeyEncryption,
+    FlagKey.NewDevicePermanentDismiss,
+    FlagKey.NewDeviceTemporaryDismiss,
         -> BooleanFlagItem(
         label = flagKey.getDisplayLabel(),
         key = flagKey as FlagKey<Boolean>,
@@ -81,4 +83,6 @@ private fun <T : Any> FlagKey<T>.getDisplayLabel(): String = when (this) {
     FlagKey.CredentialExchangeProtocolExport -> stringResource(R.string.cxp_export)
     FlagKey.AppReviewPrompt -> stringResource(R.string.app_review_prompt)
     FlagKey.CipherKeyEncryption -> stringResource(R.string.cipher_key_encryption)
+    FlagKey.NewDevicePermanentDismiss -> stringResource(R.string.new_device_permanent_dismiss)
+    FlagKey.NewDeviceTemporaryDismiss -> stringResource(R.string.new_device_temporary_dismiss)
 }
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt
index 4bb22e3fa..bde02656c 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt
@@ -29,6 +29,7 @@ import com.x8bit.bitwarden.ui.auth.feature.auth.authGraph
 import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuthGraph
 import com.x8bit.bitwarden.ui.auth.feature.completeregistration.navigateToCompleteRegistration
 import com.x8bit.bitwarden.ui.auth.feature.expiredregistrationlink.navigateToExpiredRegistrationLinkScreen
+import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.navigateToNewDeviceNoticeEmailAccess
 import com.x8bit.bitwarden.ui.auth.feature.removepassword.REMOVE_PASSWORD_ROUTE
 import com.x8bit.bitwarden.ui.auth.feature.removepassword.navigateToRemovePassword
 import com.x8bit.bitwarden.ui.auth.feature.removepassword.removePasswordDestination
@@ -126,6 +127,7 @@ fun RootNavScreen(
         is RootNavState.VaultUnlockedForFido2Save,
         is RootNavState.VaultUnlockedForFido2Assertion,
         is RootNavState.VaultUnlockedForFido2GetCredentials,
+        is RootNavState.NewDeviceTwoFactorNotice,
             -> VAULT_UNLOCKED_GRAPH_ROUTE
 
         RootNavState.OnboardingAccountLockSetup -> SETUP_UNLOCK_AS_ROOT_ROUTE
@@ -254,6 +256,13 @@ fun RootNavScreen(
             RootNavState.OnboardingStepsComplete -> {
                 navController.navigateToSetupCompleteScreen(rootNavOptions)
             }
+
+            is RootNavState.NewDeviceTwoFactorNotice -> {
+                navController.navigateToNewDeviceNoticeEmailAccess(
+                    emailAddress = currentState.email,
+                    navOptions = rootNavOptions,
+                )
+            }
         }
     }
 }
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 e4c03861a..b9a4e62cd 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
@@ -103,6 +103,9 @@ class RootNavViewModel @Inject constructor(
                 }
             }
 
+            userState.activeAccount.isVaultUnlocked &&
+                authRepository.checkUserNeedsNewDeviceTwoFactorNotice() -> RootNavState.NewDeviceTwoFactorNotice(userState.activeAccount.email)
+
             userState.activeAccount.isVaultUnlocked -> {
                 when (specialCircumstance) {
                     is SpecialCircumstance.AutofillSave -> {
@@ -367,6 +370,14 @@ sealed class RootNavState : Parcelable {
      */
     @Parcelize
     data object OnboardingStepsComplete : RootNavState()
+
+    /**
+     * App should show the new device two factor notice screen.
+     */
+    @Parcelize
+    data class NewDeviceTwoFactorNotice(
+        val email: String,
+    ) : RootNavState()
 }
 
 /**
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt
index 1c96b4a94..56f1ac764 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt
@@ -8,6 +8,9 @@ import com.x8bit.bitwarden.ui.auth.feature.accountsetup.navigateToSetupAutoFillS
 import com.x8bit.bitwarden.ui.auth.feature.accountsetup.navigateToSetupUnlockScreen
 import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupAutoFillDestination
 import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupUnlockDestination
+import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.navigateToNewDeviceNoticeTwoFactor
+import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.newDeviceNoticeEmailAccessDestination
+import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.newDeviceNoticeTwoFactorDestination
 import com.x8bit.bitwarden.ui.platform.feature.search.navigateToSearch
 import com.x8bit.bitwarden.ui.platform.feature.search.searchDestination
 import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.deleteAccountDestination
@@ -232,5 +235,12 @@ fun NavGraphBuilder.vaultUnlockedGraph(
         importLoginsScreenDestination(
             onNavigateBack = { navController.popBackStack() },
         )
+        newDeviceNoticeEmailAccessDestination(
+            onNavigateBackToVault = { navController.navigateToVaultUnlockedGraph() },
+            onNavigateToTwoFactorOptions = { navController.navigateToNewDeviceNoticeTwoFactor() },
+        )
+        newDeviceNoticeTwoFactorDestination(
+            onNavigateBackToVault = { navController.navigateToVaultUnlockedGraph() },
+        )
     }
 }
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c225460a5..f3b88d3b2 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1111,4 +1111,5 @@ Do you want to switch to this account?</string>
   <string name="we_couldnt_verify_the_servers_certificate">We couldn’t verify the server’s certificate. The certificate chain or proxy settings on your device or your Bitwarden server may not be set up correctly.</string>
   <string name="review_flow_launched">Review flow launched!</string>
   <string name="copy_private_key">Copy private key</string>
+  <string name="you_can_change_your_account_email_on_the_bitwarden_web_app">You can change your account email on the Bitwarden web app.</string>
 </resources>
diff --git a/app/src/main/res/values/strings_non_localized.xml b/app/src/main/res/values/strings_non_localized.xml
index 263a42e3b..221fd5472 100644
--- a/app/src/main/res/values/strings_non_localized.xml
+++ b/app/src/main/res/values/strings_non_localized.xml
@@ -22,5 +22,7 @@
     <string name="restart_onboarding_carousel_details">This will force the change to app state which will cause the first time carousel to show. The carousel will continue to show for any \"new\" account until a login is completed. May need to exit debug menu manually.</string>
     <string name="app_review_prompt">App Review Prompt</string>
     <string name="cipher_key_encryption">Cipher Key Encryption</string>">
+    <string name="new_device_permanent_dismiss">New device notice permanent dismiss</string>">
+    <string name="new_device_temporary_dismiss">New device notice temporary dismiss</string>">
     <!-- /Debug Menu -->
 </resources>
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt
index 03ccab427..40e51ecb5 100644
--- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt
@@ -7,6 +7,8 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
@@ -1246,6 +1248,64 @@ class AuthDiskSourceTest {
             assertTrue(awaitItem() ?: false)
         }
     }
+
+    @Test
+    fun `getNewDeviceNoticeState should pull from SharedPreferences`() {
+        val storeKey = "bwPreferencesStorage:newDeviceNoticeState"
+        val mockUserId = "mockUserId"
+        val expectedState = NewDeviceNoticeState(
+            displayStatus = NewDeviceNoticeDisplayStatus.HAS_SEEN,
+            lastSeenDate = ZonedDateTime.parse("2024-12-25T01:00:00.00Z"),
+        )
+        fakeSharedPreferences.edit {
+            putString(
+                "${storeKey}_$mockUserId",
+                json.encodeToString(expectedState),
+            )
+        }
+        val actual = authDiskSource.getNewDeviceNoticeState(userId = mockUserId)
+        assertEquals(
+            expectedState,
+            actual,
+        )
+    }
+
+    @Test
+    fun `getNewDeviceNoticeState should pull default from SharedPreferences if no user is found`() {
+        val mockUserId = "mockUserId"
+        val defaultState = NewDeviceNoticeState(
+            displayStatus = NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN,
+            lastSeenDate = null,
+        )
+        val actual = authDiskSource.getNewDeviceNoticeState(userId = mockUserId)
+        assertEquals(
+            defaultState,
+            actual,
+        )
+    }
+
+    @Test
+    fun `setNewDeviceNoticeState should update SharedPreferences`() {
+        val storeKey = "bwPreferencesStorage:newDeviceNoticeState"
+        val mockUserId = "mockUserId"
+        val mockStatus = NewDeviceNoticeState(
+            displayStatus = NewDeviceNoticeDisplayStatus.HAS_SEEN,
+            lastSeenDate = ZonedDateTime.parse("2024-12-25T01:00:00.00Z"),
+        )
+        authDiskSource.storeNewDeviceNoticeState(
+            userId = mockUserId,
+            mockStatus,
+        )
+
+        val actual = fakeSharedPreferences.getString(
+            "${storeKey}_$mockUserId",
+            null,
+        )
+        assertEquals(
+            json.encodeToString(mockStatus),
+            actual,
+        )
+    }
 }
 
 private const val USER_STATE_JSON = """
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt
index f920cb5c6..479a3809b 100644
--- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt
@@ -2,6 +2,8 @@ package com.x8bit.bitwarden.data.auth.datasource.disk.util
 
 import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
@@ -60,6 +62,7 @@ class FakeAuthDiskSource : AuthDiskSource {
     private val storedPolicies = mutableMapOf<String, List<SyncResponseJson.Policy>?>()
     private val storedOnboardingStatus = mutableMapOf<String, OnboardingStatus?>()
     private val storedShowImportLogins = mutableMapOf<String, Boolean?>()
+    private val storedNewDeviceNoticeState = mutableMapOf<String, NewDeviceNoticeState?>()
 
     override var userState: UserStateJson? = null
         set(value) {
@@ -298,6 +301,17 @@ class FakeAuthDiskSource : AuthDiskSource {
         getMutableShowImportLoginsFlow(userId)
             .onSubscription { emit(getShowImportLogins(userId)) }
 
+    override fun getNewDeviceNoticeState(userId: String): NewDeviceNoticeState {
+        return storedNewDeviceNoticeState[userId] ?: NewDeviceNoticeState(
+            displayStatus = NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN,
+            lastSeenDate = null,
+            )
+    }
+
+    override fun storeNewDeviceNoticeState(userId: String, newState: NewDeviceNoticeState?) {
+        storedNewDeviceNoticeState[userId] = newState
+    }
+
     /**
      * Assert the the [isTdeLoginComplete] was stored successfully using the [userId].
      */
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 934c3095e..e2165e401 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
@@ -16,6 +16,8 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
 import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
@@ -147,6 +149,7 @@ import kotlinx.serialization.json.put
 import org.junit.jupiter.api.AfterEach
 import org.junit.jupiter.api.Assertions.assertEquals
 import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNotNull
 import org.junit.jupiter.api.Assertions.assertNull
 import org.junit.jupiter.api.Assertions.assertTrue
 import org.junit.jupiter.api.BeforeEach
@@ -6468,6 +6471,408 @@ class AuthRepositoryTest {
             assertNull(fakeAuthDiskSource.getOnboardingStatus(USER_ID_1))
         }
 
+    @Test
+    fun `getNewDeviceNoticeState should return device notice state if an account is active`() =
+        runTest {
+            fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
+            val deviceNoticeState = repository.getNewDeviceNoticeState()
+            assertNotNull(deviceNoticeState)
+        }
+
+    @Test
+    fun `getNewDeviceNoticeState should return null if no account is active`() =
+        runTest {
+            val deviceNoticeState = repository.getNewDeviceNoticeState()
+            assertNull(deviceNoticeState)
+        }
+
+    @Test
+    fun `setNewDeviceNoticeState should update disk source`() =
+        runTest {
+            val userId = "2a135b23-e1fb-42c9-bec3-573857bc8181"
+            fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
+            repository.setNewDeviceNoticeState(
+                NewDeviceNoticeState(
+                    displayStatus = NewDeviceNoticeDisplayStatus.HAS_SEEN,
+                    lastSeenDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
+                ),
+            )
+            assertEquals(
+                NewDeviceNoticeState(
+                    displayStatus = NewDeviceNoticeDisplayStatus.HAS_SEEN,
+                    lastSeenDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
+                ),
+                fakeAuthDiskSource.getNewDeviceNoticeState(userId),
+            )
+        }
+
+    @Test
+    @Suppress("MaxLineLength")
+    fun `setNewDeviceNoticeState without an active account should not update disk source and return default`() =
+        runTest {
+            val userId = "2a135b23-e1fb-42c9-bec3-573857bc8181"
+            repository.setNewDeviceNoticeState(
+                NewDeviceNoticeState(
+                    displayStatus = NewDeviceNoticeDisplayStatus.HAS_SEEN,
+                    lastSeenDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
+                ),
+            )
+            assertEquals(
+                NewDeviceNoticeState(
+                    displayStatus = NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN,
+                    lastSeenDate = null,
+                ),
+                fakeAuthDiskSource.getNewDeviceNoticeState(userId),
+            )
+        }
+
+    @Test
+    @Suppress("MaxLineLength")
+    fun `checkUserNeedsNewDeviceTwoFactorNotice flags on, is cloud user, profile at least week old, no required sso policy, no two factor enable returns true`() =
+        runTest {
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss)
+            } returns true
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss)
+            } returns true
+            every {
+                policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO)
+            } returns listOf()
+            fakeEnvironmentRepository.environment = Environment.Us
+
+            fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
+
+            val shouldShowNewDeviceNotice = repository.checkUserNeedsNewDeviceTwoFactorNotice()
+
+            assertTrue(shouldShowNewDeviceNotice)
+        }
+
+    @Test
+    @Suppress("MaxLineLength")
+    fun `checkUserNeedsNewDeviceTwoFactorNotice NewDeviceTemporaryDismiss and NewDevicePermanentDismiss flags are off returns false`() =
+        runTest {
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss)
+            } returns false
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss)
+            } returns false
+            every {
+                policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO)
+            } returns listOf()
+            fakeEnvironmentRepository.environment = Environment.Us
+
+            fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
+
+            val shouldShowNewDeviceNotice = repository.checkUserNeedsNewDeviceTwoFactorNotice()
+
+            assertFalse(shouldShowNewDeviceNotice)
+        }
+
+    @Test
+    fun `checkUserNeedsNewDeviceTwoFactorNotice has required SSO policy returns false`() =
+        runTest {
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss)
+            } returns true
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss)
+            } returns true
+            every {
+                policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO)
+            } returns listOf(
+                createMockPolicy(
+                    type = PolicyTypeJson.REQUIRE_SSO,
+                    isEnabled = true,
+                ),
+            )
+            fakeEnvironmentRepository.environment = Environment.Us
+
+            fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
+
+            val shouldShowNewDeviceNotice = repository.checkUserNeedsNewDeviceTwoFactorNotice()
+
+            assertFalse(shouldShowNewDeviceNotice)
+        }
+
+    @Test
+    fun `checkUserNeedsNewDeviceTwoFactorNotice with two factor enable returns false`() =
+        runTest {
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss)
+            } returns true
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss)
+            } returns true
+            every {
+                policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO)
+            } returns listOf()
+            fakeEnvironmentRepository.environment = Environment.Us
+
+            fakeAuthDiskSource.userState = SINGLE_USER_STATE_2
+
+            val shouldShowNewDeviceNotice = repository.checkUserNeedsNewDeviceTwoFactorNotice()
+
+            assertFalse(shouldShowNewDeviceNotice)
+        }
+
+    @Test
+    fun `checkUserNeedsNewDeviceTwoFactorNotice account less than a week old returns false`() =
+        runTest {
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss)
+            } returns true
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss)
+            } returns true
+            every {
+                policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO)
+            } returns listOf()
+
+            fakeEnvironmentRepository.environment = Environment.Us
+
+            fakeAuthDiskSource.userState = UserStateJson(
+                activeUserId = USER_ID_1,
+                accounts = mapOf(
+                    USER_ID_1 to ACCOUNT_1.copy(
+                        profile = ACCOUNT_1.profile.copy(
+                            creationDate = ZonedDateTime.now().minusDays(2),
+                        ),
+                    ),
+                ),
+            )
+
+            val shouldShowNewDeviceNotice = repository.checkUserNeedsNewDeviceTwoFactorNotice()
+
+            assertFalse(shouldShowNewDeviceNotice)
+        }
+
+    @Test
+    @Suppress("MaxLineLength")
+    fun `checkUserNeedsNewDeviceTwoFactorNotice with NewDeviceNoticeDisplayStatus CAN_ACCESS_EMAIL_PERMANENT return false`() =
+        runTest {
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss)
+            } returns true
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss)
+            } returns true
+            every {
+                policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO)
+            } returns listOf()
+            fakeEnvironmentRepository.environment = Environment.Us
+
+            fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
+            fakeAuthDiskSource.storeNewDeviceNoticeState(
+                userId = USER_ID_1,
+                newState = NewDeviceNoticeState(
+                    displayStatus = NewDeviceNoticeDisplayStatus.CAN_ACCESS_EMAIL_PERMANENT,
+                    lastSeenDate = null,
+                ),
+            )
+
+            val shouldShowNewDeviceNotice = repository.checkUserNeedsNewDeviceTwoFactorNotice()
+
+            assertFalse(shouldShowNewDeviceNotice)
+        }
+
+    @Test
+    @Suppress("MaxLineLength")
+    fun `checkUserNeedsNewDeviceTwoFactorNotice with NewDeviceNoticeDisplayStatus HAS_NOT_SEEN return true`() =
+        runTest {
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss)
+            } returns true
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss)
+            } returns true
+            every {
+                policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO)
+            } returns listOf()
+            fakeEnvironmentRepository.environment = Environment.Us
+
+            fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
+            fakeAuthDiskSource.storeNewDeviceNoticeState(
+                userId = USER_ID_1,
+                newState = NewDeviceNoticeState(
+                    displayStatus = NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN,
+                    lastSeenDate = null,
+                ),
+            )
+
+            val shouldShowNewDeviceNotice = repository.checkUserNeedsNewDeviceTwoFactorNotice()
+
+            assertTrue(shouldShowNewDeviceNotice)
+        }
+
+    @Test
+    @Suppress("MaxLineLength")
+    fun `checkUserNeedsNewDeviceTwoFactorNotice with NewDeviceNoticeDisplayStatus HAS_SEEN return true if date is older than 7 days`() =
+        runTest {
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss)
+            } returns true
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss)
+            } returns true
+            every {
+                policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO)
+            } returns listOf()
+            fakeEnvironmentRepository.environment = Environment.Us
+
+            fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
+            fakeAuthDiskSource.storeNewDeviceNoticeState(
+                userId = USER_ID_1,
+                newState = NewDeviceNoticeState(
+                    displayStatus = NewDeviceNoticeDisplayStatus.HAS_SEEN,
+                    lastSeenDate = ZonedDateTime.now().minusDays(10),
+                ),
+            )
+
+            val shouldShowNewDeviceNotice = repository.checkUserNeedsNewDeviceTwoFactorNotice()
+
+            assertTrue(shouldShowNewDeviceNotice)
+        }
+
+    @Test
+    @Suppress("MaxLineLength")
+    fun `checkUserNeedsNewDeviceTwoFactorNotice with NewDeviceNoticeDisplayStatus HAS_SEEN return false if date is not older than 7 days`() =
+        runTest {
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss)
+            } returns true
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss)
+            } returns true
+            every {
+                policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO)
+            } returns listOf()
+            fakeEnvironmentRepository.environment = Environment.Us
+
+            fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
+            fakeAuthDiskSource.storeNewDeviceNoticeState(
+                userId = USER_ID_1,
+                newState = NewDeviceNoticeState(
+                    displayStatus = NewDeviceNoticeDisplayStatus.HAS_SEEN,
+                    lastSeenDate = ZonedDateTime.now().minusDays(2),
+                ),
+            )
+
+            val shouldShowNewDeviceNotice = repository.checkUserNeedsNewDeviceTwoFactorNotice()
+
+            assertFalse(shouldShowNewDeviceNotice)
+        }
+
+    @Test
+    @Suppress("MaxLineLength")
+    fun `checkUserNeedsNewDeviceTwoFactorNotice with NewDeviceNoticeDisplayStatus CAN_ACCESS_EMAIL return permanent flag value`() =
+        runTest {
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss)
+            } returns true
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss)
+            } returns true
+            every {
+                policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO)
+            } returns listOf()
+            fakeEnvironmentRepository.environment = Environment.Us
+
+            fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
+            fakeAuthDiskSource.storeNewDeviceNoticeState(
+                userId = USER_ID_1,
+                newState = NewDeviceNoticeState(
+                    displayStatus = NewDeviceNoticeDisplayStatus.CAN_ACCESS_EMAIL,
+                    lastSeenDate = ZonedDateTime.now().minusDays(2),
+                ),
+            )
+
+            assertTrue(repository.checkUserNeedsNewDeviceTwoFactorNotice())
+
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss)
+            } returns false
+
+            assertFalse(repository.checkUserNeedsNewDeviceTwoFactorNotice())
+        }
+
+    @Test
+    fun `checkUserNeedsNewDeviceTwoFactorNotice with no active user returns false`() =
+        runTest {
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss)
+            } returns true
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss)
+            } returns true
+            every {
+                policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO)
+            } returns listOf()
+            fakeEnvironmentRepository.environment = Environment.Us
+
+            fakeAuthDiskSource.userState = null
+
+            assertFalse(repository.checkUserNeedsNewDeviceTwoFactorNotice())
+        }
+
+    @Test
+    fun `checkUserNeedsNewDeviceTwoFactorNotice account with null creationDate returns false`() =
+        runTest {
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss)
+            } returns true
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss)
+            } returns true
+            every {
+                policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO)
+            } returns listOf()
+            fakeEnvironmentRepository.environment = Environment.Us
+
+            fakeAuthDiskSource.userState = UserStateJson(
+                activeUserId = USER_ID_1,
+                accounts = mapOf(
+                    USER_ID_1 to ACCOUNT_1.copy(
+                        profile = ACCOUNT_1.profile.copy(
+                            creationDate = null,
+                        ),
+                    ),
+                ),
+            )
+
+            assertFalse(repository.checkUserNeedsNewDeviceTwoFactorNotice())
+        }
+
+    @Test
+    @Suppress("MaxLineLength")
+    fun `checkUserNeedsNewDeviceTwoFactorNotice account with null isTwoFactorEnabled returns true`() =
+        runTest {
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss)
+            } returns true
+            every {
+                featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss)
+            } returns true
+            every {
+                policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO)
+            } returns listOf()
+            fakeEnvironmentRepository.environment = Environment.Us
+
+            fakeAuthDiskSource.userState = UserStateJson(
+                activeUserId = USER_ID_1,
+                accounts = mapOf(
+                    USER_ID_1 to ACCOUNT_1.copy(
+                        profile = ACCOUNT_1.profile.copy(
+                            isTwoFactorEnabled = null,
+                        ),
+                    ),
+                ),
+            )
+
+            assertTrue(repository.checkUserNeedsNewDeviceTwoFactorNotice())
+        }
+
     companion object {
         private const val UNIQUE_APP_ID = "testUniqueAppId"
         private const val NAME = "Example Name"
@@ -6596,7 +7001,7 @@ class AuthRepositoryTest {
                 kdfMemory = null,
                 kdfParallelism = null,
                 userDecryptionOptions = null,
-                isTwoFactorEnabled = false,
+                isTwoFactorEnabled = true,
                 creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
             ),
             settings = AccountJson.Settings(
@@ -6623,6 +7028,17 @@ class AuthRepositoryTest {
                 ),
             ),
         )
+
+        private val SINGLE_USER_STATE_1_NEW_ACCOUNT = UserStateJson(
+            activeUserId = USER_ID_1,
+            accounts = mapOf(
+                USER_ID_1 to ACCOUNT_1.copy(
+                    profile = ACCOUNT_1.profile.copy(
+                        creationDate = ZonedDateTime.parse("2024-09-14T01:00:00.00Z"),
+                    ),
+                ),
+            ),
+        )
         private val SINGLE_USER_STATE_2 = UserStateJson(
             activeUserId = USER_ID_2,
             accounts = mapOf(
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/FlagKeyTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/FlagKeyTest.kt
index a0deccade..587ecd95d 100644
--- a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/FlagKeyTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/FlagKeyTest.kt
@@ -46,4 +46,24 @@ class FlagKeyTest {
     fun `AppReviewPrompt default value should be false`() {
         assertFalse(FlagKey.AppReviewPrompt.defaultValue)
     }
+
+    @Test
+    fun `NewDevicePermanentDismiss default value should be false`() {
+        assertFalse(FlagKey.NewDevicePermanentDismiss.defaultValue)
+    }
+
+    @Test
+    fun `NewDevicePermanentDismiss is remotely configured value should be true`() {
+        assertTrue(FlagKey.NewDevicePermanentDismiss.isRemotelyConfigured)
+    }
+
+    @Test
+    fun `NewDeviceTemporaryDismiss default value should be false`() {
+        assertFalse(FlagKey.NewDeviceTemporaryDismiss.defaultValue)
+    }
+
+    @Test
+    fun `NewDeviceTemporaryDismiss is remotely configured value should be true`() {
+        assertTrue(FlagKey.NewDeviceTemporaryDismiss.isRemotelyConfigured)
+    }
 }
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreenTest.kt
index 2b6e12332..2418d9695 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreenTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreenTest.kt
@@ -13,12 +13,14 @@ import io.mockk.verify
 import junit.framework.TestCase.assertTrue
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.update
+import org.junit.After
 import org.junit.Before
 import org.junit.Test
 
 class NewDeviceNoticeEmailAccessScreenTest : BaseComposeTest() {
     private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
     private val mutableEventFlow = bufferedMutableSharedFlow<NewDeviceNoticeEmailAccessEvent>()
+    private var onNavigateBackToVaultCalled = false
     private var onNavigateToTwoFactorOptionsCalled = false
     private val viewModel = mockk<NewDeviceNoticeEmailAccessViewModel>(relaxed = true) {
         every { stateFlow } returns mutableStateFlow
@@ -29,14 +31,21 @@ class NewDeviceNoticeEmailAccessScreenTest : BaseComposeTest() {
     fun setUp() {
         composeTestRule.setContent {
             NewDeviceNoticeEmailAccessScreen(
+                onNavigateBackToVault = { onNavigateBackToVaultCalled = true },
                 onNavigateToTwoFactorOptions = { onNavigateToTwoFactorOptionsCalled = true },
                 viewModel = viewModel,
             )
         }
     }
 
-    @Suppress("MaxLineLength")
+    @After
+    fun tearDown() {
+        onNavigateBackToVaultCalled = false
+        onNavigateToTwoFactorOptionsCalled = false
+    }
+
     @Test
+    @Suppress("MaxLineLength")
     fun `Do you have reliable access to your email should be toggled on or off according to the state`() {
         composeTestRule
             .onNodeWithText("Yes, I can reliably access my email", substring = true)
@@ -70,7 +79,15 @@ class NewDeviceNoticeEmailAccessScreenTest : BaseComposeTest() {
     }
 
     @Test
-    fun `ContinueClick should call onNavigateToTwoFactorOptions`() {
+    fun `ContinueClick should call onNavigateBackToVault if isEmailAccessEnabled is false`() {
+        mutableStateFlow.update { it.copy(isEmailAccessEnabled = false) }
+        mutableEventFlow.tryEmit(NewDeviceNoticeEmailAccessEvent.NavigateBackToVault)
+        assertTrue(onNavigateBackToVaultCalled)
+    }
+
+    @Test
+    fun `ContinueClick should call onNavigateToTwoFactorOptions if isEmailAccessEnabled is true`() {
+        mutableStateFlow.update { it.copy(isEmailAccessEnabled = true) }
         mutableEventFlow.tryEmit(NewDeviceNoticeEmailAccessEvent.NavigateToTwoFactorOptions)
         assertTrue(onNavigateToTwoFactorOptionsCalled)
     }
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModelTest.kt
index 6c401286c..6e8b72271 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModelTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModelTest.kt
@@ -2,12 +2,34 @@ package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice
 
 import androidx.lifecycle.SavedStateHandle
 import app.cash.turbine.test
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState
+import com.x8bit.bitwarden.data.auth.repository.AuthRepository
+import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
+import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
 import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
 import kotlinx.coroutines.test.runTest
 import org.junit.jupiter.api.Assertions.assertEquals
 import org.junit.jupiter.api.Test
 
 class NewDeviceNoticeEmailAccessViewModelTest : BaseViewModelTest() {
+    private val authRepository = mockk<AuthRepository> {
+        every { getNewDeviceNoticeState() } returns NewDeviceNoticeState(
+            displayStatus = NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN,
+            lastSeenDate = null,
+        )
+        every { setNewDeviceNoticeState(any()) } just runs
+        every { checkUserNeedsNewDeviceTwoFactorNotice() } returns true
+    }
+
+    private val featureFlagManager = mockk<FeatureFlagManager>(relaxed = true) {
+        every { getFeatureFlag(FlagKey.NewDevicePermanentDismiss) } returns true
+        every { getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss) } returns true
+    }
 
     @Test
     fun `initial state should be correct with email from state handle`() = runTest {
@@ -30,7 +52,37 @@ class NewDeviceNoticeEmailAccessViewModelTest : BaseViewModelTest() {
     }
 
     @Test
-    fun `ContinueClick should emit NavigateToTwoFactorOptions`() = runTest {
+    fun `ContinueClick should emit NavigateBackToVault if isEmailAccessEnabled`() = runTest {
+        val viewModel = createViewModel()
+        viewModel.trySendAction(NewDeviceNoticeEmailAccessAction.EmailAccessToggle(true))
+        viewModel.eventFlow.test {
+            viewModel.trySendAction(NewDeviceNoticeEmailAccessAction.ContinueClick)
+            assertEquals(
+                NewDeviceNoticeEmailAccessEvent.NavigateBackToVault,
+                awaitItem(),
+            )
+        }
+    }
+
+    @Test
+    @Suppress("MaxLineLength")
+    fun `ContinueClick should emit NavigateBackToVault if isEmailAccessEnabled and NewDevicePermanentDismiss flag is off`() = runTest {
+        every { featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss) } returns false
+
+        val viewModel = createViewModel()
+        viewModel.trySendAction(NewDeviceNoticeEmailAccessAction.EmailAccessToggle(true))
+        viewModel.eventFlow.test {
+            viewModel.trySendAction(NewDeviceNoticeEmailAccessAction.ContinueClick)
+            assertEquals(
+                NewDeviceNoticeEmailAccessEvent.NavigateBackToVault,
+                awaitItem(),
+            )
+        }
+    }
+
+    @Test
+    @Suppress("MaxLineLength")
+    fun `ContinueClick should emit NavigateToTwoFactorOptions if isEmailAccessEnabled is false`() = runTest {
         val viewModel = createViewModel()
         viewModel.eventFlow.test {
             viewModel.trySendAction(NewDeviceNoticeEmailAccessAction.ContinueClick)
@@ -46,6 +98,8 @@ class NewDeviceNoticeEmailAccessViewModelTest : BaseViewModelTest() {
             it["email_address"] = EMAIL
         },
     ): NewDeviceNoticeEmailAccessViewModel = NewDeviceNoticeEmailAccessViewModel(
+        authRepository = authRepository,
+        featureFlagManager = featureFlagManager,
         savedStateHandle = savedStateHandle,
     )
 }
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorScreenTest.kt
index 37f7055b1..00fd15292 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorScreenTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorScreenTest.kt
@@ -1,5 +1,9 @@
 package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice
 
+import androidx.compose.ui.test.assert
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.hasAnyAncestor
+import androidx.compose.ui.test.isDialog
 import androidx.compose.ui.test.onNodeWithText
 import androidx.compose.ui.test.performClick
 import androidx.compose.ui.test.performScrollTo
@@ -13,6 +17,8 @@ import io.mockk.mockk
 import io.mockk.runs
 import io.mockk.verify
 import junit.framework.TestCase.assertTrue
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
 import org.junit.After
 import org.junit.Before
 import org.junit.Test
@@ -22,8 +28,10 @@ class NewDeviceNoticeTwoFactorScreenTest : BaseComposeTest() {
         every { startCustomTabsActivity(any()) } just runs
     }
     private var onNavigateBackCalled = false
+    private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
     private val mutableEventFlow = bufferedMutableSharedFlow<NewDeviceNoticeTwoFactorEvent>()
     private val viewModel = mockk<NewDeviceNoticeTwoFactorViewModel>(relaxed = true) {
+        every { stateFlow } returns mutableStateFlow
         every { eventFlow } returns mutableEventFlow
     }
 
@@ -31,7 +39,7 @@ class NewDeviceNoticeTwoFactorScreenTest : BaseComposeTest() {
     fun setUp() {
         composeTestRule.setContent {
             NewDeviceNoticeTwoFactorScreen(
-                onNavigateBack = { onNavigateBackCalled = true },
+                onNavigateBackToVault = { onNavigateBackCalled = true },
                 intentManager,
                 viewModel = viewModel,
             )
@@ -108,7 +116,118 @@ class NewDeviceNoticeTwoFactorScreenTest : BaseComposeTest() {
 
     @Test
     fun `RemindMeLaterClick should call OnNavigateBack`() {
-        mutableEventFlow.tryEmit(NewDeviceNoticeTwoFactorEvent.NavigateBack)
+        mutableEventFlow.tryEmit(NewDeviceNoticeTwoFactorEvent.NavigateBackToVault)
         assertTrue(onNavigateBackCalled)
     }
+
+    @Test
+    fun `remind me later button visibility should update according to state`() {
+        composeTestRule
+            .onNodeWithText("Remind me later")
+            .performScrollTo()
+            .assertIsDisplayed()
+
+        mutableStateFlow.update {
+            it.copy(shouldShowRemindMeLater = false)
+        }
+        composeTestRule
+            .onNodeWithText("Remind me later")
+            .assertDoesNotExist()
+    }
+
+    @Test
+    @Suppress("MaxLineLength")
+    fun `turn on two factor dialog should be shown or hidden according to the state`() {
+        composeTestRule.onNode(isDialog()).assertDoesNotExist()
+
+        mutableStateFlow.update {
+            it.copy(
+                dialogState = NewDeviceNoticeTwoFactorDialogState.TurnOnTwoFactorDialog,
+            )
+        }
+
+        composeTestRule.onNode(isDialog()).assertIsDisplayed()
+
+        composeTestRule
+            .onNodeWithText("Continue to web app", substring = true, ignoreCase = true)
+            .assert(hasAnyAncestor(isDialog()))
+            .assertIsDisplayed()
+        composeTestRule
+            .onNodeWithText(
+                "Make your account more secure by setting up two-step login in the Bitwarden web app.",
+                substring = true,
+                ignoreCase = true,
+                )
+            .assert(hasAnyAncestor(isDialog()))
+            .assertIsDisplayed()
+        composeTestRule
+            .onNodeWithText("Continue", substring = true, ignoreCase = true)
+            .assert(hasAnyAncestor(isDialog()))
+            .assertIsDisplayed()
+        composeTestRule
+            .onNodeWithText("Cancel", substring = true, ignoreCase = true)
+            .assert(hasAnyAncestor(isDialog()))
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun `change account email dialog should be shown or hidden according to the state`() {
+        composeTestRule.onNode(isDialog()).assertDoesNotExist()
+
+        mutableStateFlow.update {
+            it.copy(
+                dialogState = NewDeviceNoticeTwoFactorDialogState.ChangeAccountEmailDialog,
+            )
+        }
+
+        composeTestRule.onNode(isDialog()).assertIsDisplayed()
+
+        composeTestRule
+            .onNodeWithText("Continue to web app", substring = true, ignoreCase = true)
+            .assert(hasAnyAncestor(isDialog()))
+            .assertIsDisplayed()
+        composeTestRule
+            .onNodeWithText(
+                "You can change your account email on the Bitwarden web app.",
+                substring = true,
+                ignoreCase = true,
+            )
+            .assert(hasAnyAncestor(isDialog()))
+            .assertIsDisplayed()
+        composeTestRule
+            .onNodeWithText("Continue", substring = true, ignoreCase = true)
+            .assert(hasAnyAncestor(isDialog()))
+            .assertIsDisplayed()
+        composeTestRule
+            .onNodeWithText("Cancel", substring = true, ignoreCase = true)
+            .assert(hasAnyAncestor(isDialog()))
+            .assertIsDisplayed()
+    }
+
+    @Test
+    fun `dialog should be hidden according to the state`() {
+        composeTestRule.onNode(isDialog()).assertDoesNotExist()
+
+        mutableStateFlow.update {
+            it.copy(
+                dialogState = NewDeviceNoticeTwoFactorDialogState.ChangeAccountEmailDialog,
+            )
+        }
+
+        composeTestRule.onNode(isDialog()).assertIsDisplayed()
+
+        mutableStateFlow.update {
+            it.copy(
+                dialogState = null,
+            )
+        }
+
+        composeTestRule.onNode(isDialog()).assertDoesNotExist()
+    }
 }
+
+private val DEFAULT_STATE =
+    NewDeviceNoticeTwoFactorState(
+        shouldShowRemindMeLater = true,
+        dialogState = null,
+    )
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorViewModelTest.kt
index 11d26f317..113012518 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorViewModelTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorViewModelTest.kt
@@ -1,39 +1,101 @@
 package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice
 
 import app.cash.turbine.test
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus
+import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState
+import com.x8bit.bitwarden.data.auth.repository.AuthRepository
+import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
+import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
 import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
+import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorDialogState.ChangeAccountEmailDialog
+import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorDialogState.TurnOnTwoFactorDialog
 import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
 import kotlinx.coroutines.test.runTest
 import org.junit.jupiter.api.Assertions.assertEquals
 import org.junit.jupiter.api.Test
 
 class NewDeviceNoticeTwoFactorViewModelTest : BaseViewModelTest() {
     private val environmentRepository = FakeEnvironmentRepository()
+    private val authRepository = mockk<AuthRepository> {
+        every { getNewDeviceNoticeState() } returns NewDeviceNoticeState(
+            displayStatus = NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN,
+            lastSeenDate = null,
+        )
+        every { setNewDeviceNoticeState(any()) } just runs
+    }
+
+    private val featureFlagManager = mockk<FeatureFlagManager>(relaxed = true) {
+        every { getFeatureFlag(FlagKey.NewDevicePermanentDismiss) } returns false
+        every { getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss) } returns true
+    }
 
     @Test
-    fun `ChangeAccountEmailClick should emit NavigateToChangeAccountEmail`() = runTest {
+    fun `initial state should be correct with NewDevicePermanentDismiss flag false`() = runTest {
         val viewModel = createViewModel()
-        viewModel.eventFlow.test {
-            viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.ChangeAccountEmailClick)
+        viewModel.stateFlow.test {
+            assertEquals(DEFAULT_STATE, awaitItem())
+        }
+    }
+
+    @Test
+    fun `initial state should be correct with NewDevicePermanentDismiss flag true`() = runTest {
+        every { featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss) } returns true
+        val viewModel = createViewModel()
+        viewModel.stateFlow.test {
             assertEquals(
-                NewDeviceNoticeTwoFactorEvent.NavigateToChangeAccountEmail(
-                    url = "https://vault.bitwarden.com/#/settings/account",
-                ),
+                DEFAULT_STATE.copy(shouldShowRemindMeLater = false),
                 awaitItem(),
             )
         }
     }
 
     @Test
-    fun `TurnOnTwoFactorClick should emit NavigateToTurnOnTwoFactor`() = runTest {
+    fun `initial state should be correct with email from state handle`() = runTest {
         val viewModel = createViewModel()
+        viewModel.stateFlow.test {
+            assertEquals(DEFAULT_STATE, awaitItem())
+        }
+    }
+
+    @Test
+    fun `ChangeAccountEmailClick should should change dialog state to ChangeAccountEmailDialog`() =
+        runTest {
+            val viewModel = createViewModel()
+            viewModel.eventFlow.test {
+                viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.ChangeAccountEmailClick)
+                assertEquals(
+                    DEFAULT_STATE.copy(dialogState = ChangeAccountEmailDialog),
+                    viewModel.stateFlow.value,
+                )
+            }
+        }
+
+    @Test
+    fun `TurnOnTwoFactorClick should should change dialog state to TurnOnTwoFactorDialog`() =
+        runTest {
+            val viewModel = createViewModel()
+            viewModel.eventFlow.test {
+                viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.TurnOnTwoFactorClick)
+                assertEquals(
+                    DEFAULT_STATE.copy(dialogState = TurnOnTwoFactorDialog),
+                    viewModel.stateFlow.value,
+                )
+            }
+        }
+
+    @Test
+    fun `DismissDialogClick should should change dialog state to null`() = runTest {
+        val viewModel = createViewModel()
+        viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.TurnOnTwoFactorClick)
         viewModel.eventFlow.test {
-            viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.TurnOnTwoFactorClick)
+            viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.DismissDialogClick)
             assertEquals(
-                NewDeviceNoticeTwoFactorEvent.NavigateToTurnOnTwoFactor(
-                    url = "https://vault.bitwarden.com/#/settings/security/two-factor",
-                ),
-                awaitItem(),
+                DEFAULT_STATE,
+                viewModel.stateFlow.value,
             )
         }
     }
@@ -44,14 +106,77 @@ class NewDeviceNoticeTwoFactorViewModelTest : BaseViewModelTest() {
         viewModel.eventFlow.test {
             viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.RemindMeLaterClick)
             assertEquals(
-                NewDeviceNoticeTwoFactorEvent.NavigateBack,
+                NewDeviceNoticeTwoFactorEvent.NavigateBackToVault,
                 awaitItem(),
             )
         }
     }
 
+    @Test
+    @Suppress("MaxLineLength")
+    fun `ContinueDialogClick should emit NavigateToTurnOnTwoFactor if dialog state is TurnOnTwoFactorDialog`() =
+        runTest {
+            val viewModel = createViewModel()
+            viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.TurnOnTwoFactorClick)
+            viewModel.eventFlow.test {
+                viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.ContinueDialogClick)
+                assertEquals(
+                    NewDeviceNoticeTwoFactorEvent.NavigateToTurnOnTwoFactor(
+                        url = "https://vault.bitwarden.com/#/settings/security/two-factor",
+                    ),
+                    awaitItem(),
+                )
+                assertEquals(
+                    DEFAULT_STATE,
+                    viewModel.stateFlow.value,
+                )
+            }
+        }
+
+    @Test
+    @Suppress("MaxLineLength")
+    fun `ContinueDialogClick should emit NavigateToChangeAccountEmail if dialog state is ChangeAccountEmailClick`() =
+        runTest {
+            val viewModel = createViewModel()
+            viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.ChangeAccountEmailClick)
+            viewModel.eventFlow.test {
+                viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.ContinueDialogClick)
+                assertEquals(
+                    NewDeviceNoticeTwoFactorEvent.NavigateToChangeAccountEmail(
+                        url = "https://vault.bitwarden.com/#/settings/account",
+                    ),
+                    awaitItem(),
+                )
+                assertEquals(
+                    DEFAULT_STATE,
+                    viewModel.stateFlow.value,
+                )
+            }
+        }
+
+    @Test
+    @Suppress("MaxLineLength")
+    fun `ContinueDialogClick should return if dialog state is null`() = runTest {
+        val viewModel = createViewModel()
+        viewModel.eventFlow.test {
+            viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.ContinueDialogClick)
+            assertEquals(
+                DEFAULT_STATE,
+                viewModel.stateFlow.value,
+            )
+        }
+    }
+
     private fun createViewModel(): NewDeviceNoticeTwoFactorViewModel =
         NewDeviceNoticeTwoFactorViewModel(
+            authRepository = authRepository,
             environmentRepository = environmentRepository,
+            featureFlagManager = featureFlagManager,
         )
 }
+
+private val DEFAULT_STATE =
+    NewDeviceNoticeTwoFactorState(
+        shouldShowRemindMeLater = true,
+        dialogState = null,
+    )
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt
index 07b34f118..9d8dd79b8 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt
@@ -117,6 +117,8 @@ private val DEFAULT_MAP_VALUE: Map<FlagKey<Any>, Any> = mapOf(
     FlagKey.CredentialExchangeProtocolImport to true,
     FlagKey.CredentialExchangeProtocolExport to true,
     FlagKey.AppReviewPrompt to true,
+    FlagKey.NewDeviceTemporaryDismiss to true,
+    FlagKey.NewDevicePermanentDismiss to true,
 )
 
 private val UPDATED_MAP_VALUE: Map<FlagKey<Any>, Any> = mapOf(
@@ -130,6 +132,8 @@ private val UPDATED_MAP_VALUE: Map<FlagKey<Any>, Any> = mapOf(
     FlagKey.CredentialExchangeProtocolImport to false,
     FlagKey.CredentialExchangeProtocolExport to false,
     FlagKey.AppReviewPrompt to false,
+    FlagKey.NewDeviceTemporaryDismiss to false,
+    FlagKey.NewDevicePermanentDismiss to false,
 )
 
 private val DEFAULT_STATE = DebugMenuState(
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt
index 16e327f6f..f5520654c 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt
@@ -264,6 +264,16 @@ class RootNavScreenTest : BaseComposeTest() {
                 navOptions = expectedNavOptions,
             )
         }
+
+        // Make sure navigating to new device two factor works as expected:
+        rootNavStateFlow.value =
+            RootNavState.NewDeviceTwoFactorNotice(email = "example@bitwarden.com")
+        composeTestRule.runOnIdle {
+            fakeNavHostController.assertLastNavigation(
+                route = "new_device_notice/example@bitwarden.com",
+                navOptions = expectedNavOptions,
+            )
+        }
     }
 }
 
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 c82a14d7e..730f8da39 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
@@ -43,6 +43,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
         every { userStateFlow } returns mutableUserStateFlow
         every { authStateFlow } returns mutableAuthStateFlow
         every { showWelcomeCarousel } returns false
+        every { checkUserNeedsNewDeviceTwoFactorNotice() } returns false
     }
 
     private val mockAuthRepository = mockk<AuthRepository>(relaxed = true)
@@ -1333,6 +1334,45 @@ class RootNavViewModelTest : BaseViewModelTest() {
         )
     }
 
+    @Suppress("MaxLineLength")
+    @Test
+    fun `when the active user has an unlocked vault and they need to be shown the new device notice the nav state should be NewDeviceTwoFactorNotice`() {
+        every { authRepository.checkUserNeedsNewDeviceTwoFactorNotice() } returns true
+        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 = true,
+                        needsPasswordReset = false,
+                        isBiometricsEnabled = false,
+                        organizations = emptyList(),
+                        needsMasterPassword = false,
+                        trustedDevice = null,
+                        hasMasterPassword = true,
+                        isUsingKeyConnector = false,
+                        onboardingStatus = OnboardingStatus.COMPLETE,
+                        firstTimeState = FirstTimeState(
+                            showImportLoginsCard = true,
+                        ),
+                    ),
+                ),
+            ),
+        )
+        val viewModel = createViewModel()
+        assertEquals(
+            RootNavState.NewDeviceTwoFactorNotice("email"),
+            viewModel.stateFlow.value,
+        )
+    }
+
     private fun createViewModel(): RootNavViewModel =
         RootNavViewModel(
             authRepository = authRepository,