Add initial UI flow for TDE (#1235)

This commit is contained in:
David Perez 2024-04-08 10:04:31 -05:00 committed by Álison Fernandes
parent bfbb8d47a6
commit a6a4c40693
12 changed files with 300 additions and 49 deletions

View file

@ -1137,11 +1137,13 @@ class AuthRepositoryImpl(
// Handle the Trusted Device Encryption flow
loginResponse.userDecryptionOptions?.trustedDeviceUserDecryptionOptions?.let { options ->
handleLoginCommonSuccessTrustedDeviceUserDecryptionOptions(
trustedDeviceDecryptionOptions = options,
userStateJson = userStateJson,
privateKey = requireNotNull(loginResponse.privateKey),
)
loginResponse.privateKey?.let { privateKey ->
handleLoginCommonSuccessTrustedDeviceUserDecryptionOptions(
trustedDeviceDecryptionOptions = options,
userStateJson = userStateJson,
privateKey = privateKey,
)
}
}
// Remove any cached data after successfully logging in.

View file

@ -23,7 +23,6 @@ import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.masterPasswordHint
import com.x8bit.bitwarden.ui.auth.feature.masterpasswordhint.navigateToMasterPasswordHint
import com.x8bit.bitwarden.ui.auth.feature.setpassword.navigateToSetPassword
import com.x8bit.bitwarden.ui.auth.feature.setpassword.setPasswordDestination
import com.x8bit.bitwarden.ui.auth.feature.trusteddevice.trustedDeviceDestination
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.navigateToTwoFactorLogin
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.twoFactorLoginDestination
@ -113,7 +112,6 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
masterPasswordHintDestination(
onNavigateBack = { navController.popBackStack() },
)
trustedDeviceDestination()
twoFactorLoginDestination(
onNavigateBack = { navController.popBackStack() },
)

View file

@ -0,0 +1,60 @@
package com.x8bit.bitwarden.ui.auth.feature.trusteddevice
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavOptions
import androidx.navigation.navigation
import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.loginWithDeviceDestination
import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.model.LoginWithDeviceType
import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.navigateToLoginWithDevice
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.navigateToTwoFactorLogin
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.twoFactorLoginDestination
const val TRUSTED_DEVICE_GRAPH_ROUTE: String = "trusted_device_graph"
/**
* Add trusted device destinations to the nav graph.
*/
fun NavGraphBuilder.trustedDeviceGraph(navController: NavHostController) {
navigation(
startDestination = TRUSTED_DEVICE_ROUTE,
route = TRUSTED_DEVICE_GRAPH_ROUTE,
) {
loginWithDeviceDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToTwoFactorLogin = {
navController.navigateToTwoFactorLogin(
emailAddress = it,
password = null,
)
},
)
trustedDeviceDestination(
onNavigateToAdminApproval = {
navController.navigateToLoginWithDevice(
emailAddress = it,
loginType = LoginWithDeviceType.SSO_ADMIN_APPROVAL,
)
},
onNavigateToLoginWithOtherDevice = {
navController.navigateToLoginWithDevice(
emailAddress = it,
loginType = LoginWithDeviceType.SSO_OTHER_DEVICE,
)
},
)
twoFactorLoginDestination(
onNavigateBack = { navController.popBackStack() },
)
}
}
/**
* Navigate to the trusted device graph.
*/
fun NavController.navigateToTrustedDeviceGraph(
navOptions: NavOptions? = null,
) {
navigate(TRUSTED_DEVICE_GRAPH_ROUTE, navOptions)
}

View file

@ -1,39 +1,29 @@
package com.x8bit.bitwarden.ui.auth.feature.trusteddevice
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.navArgument
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
private const val EMAIL_ADDRESS: String = "email_address"
private const val TRUSTED_DEVICE_PREFIX: String = "trusted_device"
private const val TRUSTED_DEVICE_ROUTE: String = "$TRUSTED_DEVICE_PREFIX/{${EMAIL_ADDRESS}}"
/**
* Class to retrieve trusted device arguments from the [SavedStateHandle].
* The route for navigating to the [TrustedDeviceScreen].
*/
@OmitFromCoverage
data class TrustedDeviceArgs(val emailAddress: String) {
constructor(savedStateHandle: SavedStateHandle) : this(
emailAddress = checkNotNull(savedStateHandle.get<String>(EMAIL_ADDRESS)),
)
}
const val TRUSTED_DEVICE_ROUTE: String = "trusted_device"
/**
* Add the Trusted Device Screen to the nav graph.
*/
fun NavGraphBuilder.trustedDeviceDestination() {
fun NavGraphBuilder.trustedDeviceDestination(
onNavigateToAdminApproval: (emailAddress: String) -> Unit,
onNavigateToLoginWithOtherDevice: (emailAddress: String) -> Unit,
) {
composableWithSlideTransitions(
route = TRUSTED_DEVICE_ROUTE,
arguments = listOf(
navArgument(EMAIL_ADDRESS) { type = NavType.StringType },
),
) {
TrustedDeviceScreen()
TrustedDeviceScreen(
onNavigateToAdminApproval = onNavigateToAdminApproval,
onNavigateToLoginWithOtherDevice = onNavigateToLoginWithOtherDevice,
)
}
}
@ -41,8 +31,7 @@ fun NavGraphBuilder.trustedDeviceDestination() {
* Navigate to the Trusted Device Screen.
*/
fun NavController.navigateToTrustedDevice(
emailAddress: String,
navOptions: NavOptions? = null,
) {
this.navigate("$TRUSTED_DEVICE_PREFIX/$emailAddress", navOptions)
this.navigate(TRUSTED_DEVICE_ROUTE, navOptions)
}

View file

@ -46,6 +46,8 @@ import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenSwitch
*/
@Composable
fun TrustedDeviceScreen(
onNavigateToAdminApproval: (emailAddress: String) -> Unit,
onNavigateToLoginWithOtherDevice: (emailAddress: String) -> Unit,
viewModel: TrustedDeviceViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@ -54,6 +56,14 @@ fun TrustedDeviceScreen(
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is TrustedDeviceEvent.NavigateToApproveWithAdmin -> {
onNavigateToAdminApproval(event.email)
}
is TrustedDeviceEvent.NavigateToApproveWithDevice -> {
onNavigateToLoginWithOtherDevice(event.email)
}
is TrustedDeviceEvent.ShowToast -> {
Toast
.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT)

View file

@ -25,14 +25,19 @@ class TrustedDeviceViewModel @Inject constructor(
) : BaseViewModel<TrustedDeviceState, TrustedDeviceEvent, TrustedDeviceAction>(
initialState = savedStateHandle[KEY_STATE]
?: run {
val account = authRepository.userStateFlow.value?.activeAccount
val trustedDevice = account?.trustedDevice
if (trustedDevice == null) authRepository.logout()
TrustedDeviceState(
emailAddress = TrustedDeviceArgs(savedStateHandle).emailAddress,
emailAddress = account?.email.orEmpty(),
environmentLabel = environmentRepository.environment.label,
isRemembered = false,
showContinueButton = false,
showOtherDeviceButton = false,
showRequestAdminButton = false,
showMasterPasswordButton = false,
isRemembered = true,
showContinueButton = trustedDevice
?.let { !it.hasAdminApproval && !it.hasMasterPassword }
?: false,
showOtherDeviceButton = trustedDevice?.hasLoginApprovingDevice ?: false,
showRequestAdminButton = trustedDevice?.hasAdminApproval ?: false,
showMasterPasswordButton = trustedDevice?.hasMasterPassword ?: false,
)
},
) {
@ -61,11 +66,13 @@ class TrustedDeviceViewModel @Inject constructor(
}
private fun handleApproveWithAdminClick() {
sendEvent(TrustedDeviceEvent.ShowToast("Not yet implemented".asText()))
authRepository.shouldTrustDevice = state.isRemembered
sendEvent(TrustedDeviceEvent.NavigateToApproveWithAdmin(state.emailAddress))
}
private fun handleApproveWithDeviceClick() {
sendEvent(TrustedDeviceEvent.ShowToast("Not yet implemented".asText()))
authRepository.shouldTrustDevice = state.isRemembered
sendEvent(TrustedDeviceEvent.NavigateToApproveWithDevice(state.emailAddress))
}
private fun handleApproveWithPasswordClick() {
@ -95,6 +102,20 @@ data class TrustedDeviceState(
* Models events for the Trusted Device screen.
*/
sealed class TrustedDeviceEvent {
/**
* Navigates to the approve with admin screen.
*/
data class NavigateToApproveWithAdmin(
val email: String,
) : TrustedDeviceEvent()
/**
* Navigates to the approve with device screen.
*/
data class NavigateToApproveWithDevice(
val email: String,
) : TrustedDeviceEvent()
/**
* Displays the [message] as a toast.
*/

View file

@ -21,6 +21,9 @@ import com.x8bit.bitwarden.ui.auth.feature.resetpassword.navigateToResetPassword
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.resetPasswordDestination
import com.x8bit.bitwarden.ui.auth.feature.setpassword.SET_PASSWORD_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.setpassword.navigateToSetPassword
import com.x8bit.bitwarden.ui.auth.feature.trusteddevice.TRUSTED_DEVICE_GRAPH_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.trusteddevice.navigateToTrustedDeviceGraph
import com.x8bit.bitwarden.ui.auth.feature.trusteddevice.trustedDeviceGraph
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.VAULT_UNLOCK_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.navigateToVaultUnlock
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.vaultUnlockDestination
@ -83,6 +86,7 @@ fun RootNavScreen(
splashDestination()
authGraph(navController)
resetPasswordDestination()
trustedDeviceGraph(navController)
vaultUnlockDestination()
vaultUnlockedGraph(navController)
}
@ -90,8 +94,9 @@ fun RootNavScreen(
val targetRoute = when (state) {
RootNavState.Auth -> AUTH_GRAPH_ROUTE
RootNavState.ResetPassword -> RESET_PASSWORD_ROUTE
is RootNavState.SetPassword -> SET_PASSWORD_ROUTE
RootNavState.SetPassword -> SET_PASSWORD_ROUTE
RootNavState.Splash -> SPLASH_ROUTE
RootNavState.TrustedDevice -> TRUSTED_DEVICE_GRAPH_ROUTE
RootNavState.VaultLocked -> VAULT_UNLOCK_ROUTE
is RootNavState.VaultUnlocked,
is RootNavState.VaultUnlockedForAutofillSave,
@ -130,8 +135,9 @@ fun RootNavScreen(
when (val currentState = state) {
RootNavState.Auth -> navController.navigateToAuthGraph(rootNavOptions)
RootNavState.ResetPassword -> navController.navigateToResetPasswordGraph(rootNavOptions)
is RootNavState.SetPassword -> navController.navigateToSetPassword(rootNavOptions)
RootNavState.SetPassword -> navController.navigateToSetPassword(rootNavOptions)
RootNavState.Splash -> navController.navigateToSplash(rootNavOptions)
RootNavState.TrustedDevice -> navController.navigateToTrustedDeviceGraph(rootNavOptions)
RootNavState.VaultLocked -> navController.navigateToVaultUnlock(rootNavOptions)
is RootNavState.VaultUnlocked -> navController.navigateToVaultUnlockedGraph(
rootNavOptions,

View file

@ -54,6 +54,7 @@ class RootNavViewModel @Inject constructor(
authRepository.updateLastActiveTime()
}
@Suppress("CyclomaticComplexMethod")
private fun handleUserStateUpdateReceive(
action: RootNavAction.Internal.UserStateUpdateReceive,
) {
@ -64,6 +65,9 @@ class RootNavViewModel @Inject constructor(
userState?.activeAccount?.needsPasswordReset == true -> RootNavState.ResetPassword
userState?.activeAccount?.trustedDevice?.isDeviceTrusted == false &&
!userState.activeAccount.isVaultUnlocked -> RootNavState.TrustedDevice
userState == null ||
!userState.activeAccount.isLoggedIn ||
userState.hasPendingAccountAddition -> RootNavState.Auth
@ -131,6 +135,12 @@ sealed class RootNavState : Parcelable {
@Parcelize
data object Splash : RootNavState()
/**
* App should show the trusted device destination.
*/
@Parcelize
data object TrustedDevice : RootNavState()
/**
* App should show vault locked nav graph.
*/

View file

@ -14,11 +14,15 @@ import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
class TrustedDeviceScreenTest : BaseComposeTest() {
private var onNavigateToAdminApprovalEmail: String? = null
private var onNavigateToLoginWithOtherDeviceEmail: String? = null
private val mutableEventFlow = bufferedMutableSharedFlow<TrustedDeviceEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
val viewModel = mockk<TrustedDeviceViewModel>(relaxed = true) {
@ -31,10 +35,26 @@ class TrustedDeviceScreenTest : BaseComposeTest() {
composeTestRule.setContent {
TrustedDeviceScreen(
viewModel = viewModel,
onNavigateToAdminApproval = { onNavigateToAdminApprovalEmail = it },
onNavigateToLoginWithOtherDevice = { onNavigateToLoginWithOtherDeviceEmail = it },
)
}
}
@Test
fun `on NavigateToApproveWithDevice event should invoke onNavigateToAdminApproval`() {
val email = "test@bitwarden.com"
mutableEventFlow.tryEmit(TrustedDeviceEvent.NavigateToApproveWithAdmin(email))
assertEquals(onNavigateToAdminApprovalEmail, email)
}
@Test
fun `on NavigateToApproveWithDevice event should invoke onNavigateToLoginWithOtherDevice`() {
val email = "test@bitwarden.com"
mutableEventFlow.tryEmit(TrustedDeviceEvent.NavigateToApproveWithDevice(email))
assertEquals(onNavigateToLoginWithOtherDeviceEmail, email)
}
@Test
fun `on back click should send BackClick`() {
composeTestRule.onNodeWithContentDescription("Close").performClick()

View file

@ -3,7 +3,9 @@ package com.x8bit.bitwarden.ui.auth.feature.trusteddevice
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -12,17 +14,32 @@ import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class TrustedDeviceViewModelTest : BaseViewModelTest() {
private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
private val authRepository: AuthRepository = mockk {
every { userStateFlow } returns mutableUserStateFlow
every { logout() } just runs
}
private val environmentRepo: FakeEnvironmentRepository = FakeEnvironmentRepository()
@Test
fun `on init should logout when trusted device is not present`() {
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(DEFAULT_ACCOUNT.copy(trustedDevice = null)),
)
createViewModel()
verify(exactly = 1) {
authRepository.logout()
}
}
@Test
fun `on BackClick should logout`() {
val viewModel = createViewModel()
@ -40,10 +57,10 @@ class TrustedDeviceViewModelTest : BaseViewModelTest() {
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(TrustedDeviceAction.RememberToggle(isRemembered = true))
assertEquals(DEFAULT_STATE.copy(isRemembered = true), awaitItem())
viewModel.trySendAction(TrustedDeviceAction.RememberToggle(isRemembered = false))
assertEquals(DEFAULT_STATE.copy(isRemembered = false), awaitItem())
viewModel.trySendAction(TrustedDeviceAction.RememberToggle(isRemembered = true))
assertEquals(DEFAULT_STATE.copy(isRemembered = true), awaitItem())
}
}
@ -58,22 +75,30 @@ class TrustedDeviceViewModelTest : BaseViewModelTest() {
}
@Test
fun `on ApproveWithAdminClick emits ShowToast`() = runTest {
fun `on ApproveWithAdminClick emits NavigateToApproveWithAdmin`() = runTest {
every { authRepository.shouldTrustDevice = true } just runs
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(TrustedDeviceAction.ApproveWithAdminClick)
assertEquals(TrustedDeviceEvent.ShowToast("Not yet implemented".asText()), awaitItem())
assertEquals(TrustedDeviceEvent.NavigateToApproveWithAdmin(email = EMAIL), awaitItem())
}
verify(exactly = 1) {
authRepository.shouldTrustDevice = true
}
}
@Test
fun `on ApproveWithDeviceClick emits ShowToast`() = runTest {
fun `on ApproveWithDeviceClick emits NavigateToApproveWithDevice`() = runTest {
every { authRepository.shouldTrustDevice = true } just runs
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(TrustedDeviceAction.ApproveWithDeviceClick)
assertEquals(TrustedDeviceEvent.ShowToast("Not yet implemented".asText()), awaitItem())
assertEquals(TrustedDeviceEvent.NavigateToApproveWithDevice(email = EMAIL), awaitItem())
}
verify(exactly = 1) {
authRepository.shouldTrustDevice = true
}
}
@ -113,12 +138,44 @@ class TrustedDeviceViewModelTest : BaseViewModelTest() {
)
}
private const val USER_ID: String = "userId"
private const val EMAIL: String = "email@bitwarden.com"
private val DEFAULT_STATE: TrustedDeviceState = TrustedDeviceState(
emailAddress = "email@bitwarden.com",
emailAddress = EMAIL,
environmentLabel = "bitwarden.com",
isRemembered = false,
isRemembered = true,
showContinueButton = false,
showOtherDeviceButton = false,
showRequestAdminButton = false,
showOtherDeviceButton = true,
showRequestAdminButton = true,
showMasterPasswordButton = false,
)
private val TRUSTED_DEVICE = UserState.TrustedDevice(
isDeviceTrusted = false,
hasMasterPassword = false,
hasAdminApproval = true,
hasLoginApprovingDevice = true,
hasResetPasswordPermission = false,
)
private val DEFAULT_ACCOUNT = UserState.Account(
userId = USER_ID,
name = "Active User",
email = EMAIL,
environment = Environment.Us,
avatarColorHex = "#aa00aa",
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = emptyList(),
needsMasterPassword = false,
trustedDevice = TRUSTED_DEVICE,
)
private val DEFAULT_USER_STATE = UserState(
activeUserId = USER_ID,
accounts = listOf(DEFAULT_ACCOUNT),
)

View file

@ -100,6 +100,15 @@ class RootNavScreenTest : BaseComposeTest() {
)
}
// Make sure navigating to set password works as expected:
rootNavStateFlow.value = RootNavState.TrustedDevice
composeTestRule.runOnIdle {
fakeNavHostController.assertLastNavigation(
route = "trusted_device_graph",
navOptions = expectedNavOptions,
)
}
// Make sure navigating to vault unlocked works as expected:
rootNavStateFlow.value = RootNavState.VaultUnlocked(activeUserId = "userId")
composeTestRule.runOnIdle {

View file

@ -119,6 +119,75 @@ class RootNavViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `when the active user has an untrusted device the nav state should be TrustedDevice`() {
mutableUserStateFlow.tryEmit(
UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "name",
email = "email",
avatarColorHex = "avatarColorHex",
environment = Environment.Us,
isPremium = true,
isLoggedIn = false,
isVaultUnlocked = false,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = emptyList(),
needsMasterPassword = false,
trustedDevice = UserState.TrustedDevice(
isDeviceTrusted = false,
hasMasterPassword = false,
hasAdminApproval = true,
hasLoginApprovingDevice = true,
hasResetPasswordPermission = false,
),
),
),
),
)
val viewModel = createViewModel()
assertEquals(RootNavState.TrustedDevice, viewModel.stateFlow.value)
}
@Suppress("MaxLineLength")
@Test
fun `when the active user has an untrusted device but an unlocked vault the nav state should be Auth`() {
mutableUserStateFlow.tryEmit(
UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "name",
email = "email",
avatarColorHex = "avatarColorHex",
environment = Environment.Us,
isPremium = true,
isLoggedIn = false,
isVaultUnlocked = true,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = emptyList(),
needsMasterPassword = false,
trustedDevice = UserState.TrustedDevice(
isDeviceTrusted = false,
hasMasterPassword = false,
hasAdminApproval = true,
hasLoginApprovingDevice = true,
hasResetPasswordPermission = false,
),
),
),
),
)
val viewModel = createViewModel()
assertEquals(RootNavState.Auth, viewModel.stateFlow.value)
}
@Suppress("MaxLineLength")
@Test
fun `when the active user but there are pending account additions the nav state should be Auth`() {