PM-11256: Add RootNav logic to display Remove Password Screen (#3803)

This commit is contained in:
David Perez 2024-08-21 15:53:27 -05:00 committed by GitHub
parent 075956ce17
commit e7bd966e94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 119 additions and 12 deletions

View file

@ -37,4 +37,10 @@ data class JwtTokenDataJson(
@SerialName("amr") @SerialName("amr")
val authenticationMethodsReference: List<String>, val authenticationMethodsReference: List<String>,
) ) {
/**
* Indicates that this is an external user. Mainly used for SSO users with a key connector.
*/
val isExternal: Boolean
get() = authenticationMethodsReference.any { it == "external" }
}

View file

@ -19,6 +19,9 @@ import com.x8bit.bitwarden.ui.auth.feature.auth.AUTH_GRAPH_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.auth.authGraph 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.auth.navigateToAuthGraph
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.navigateToCompleteRegistration import com.x8bit.bitwarden.ui.auth.feature.completeregistration.navigateToCompleteRegistration
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
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.RESET_PASSWORD_ROUTE import com.x8bit.bitwarden.ui.auth.feature.resetpassword.RESET_PASSWORD_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.navigateToResetPasswordGraph import com.x8bit.bitwarden.ui.auth.feature.resetpassword.navigateToResetPasswordGraph
import com.x8bit.bitwarden.ui.auth.feature.resetpassword.resetPasswordDestination import com.x8bit.bitwarden.ui.auth.feature.resetpassword.resetPasswordDestination
@ -79,6 +82,7 @@ fun RootNavScreen(
) { ) {
splashDestination() splashDestination()
authGraph(navController) authGraph(navController)
removePasswordDestination()
resetPasswordDestination() resetPasswordDestination()
trustedDeviceGraph(navController) trustedDeviceGraph(navController)
vaultUnlockDestination() vaultUnlockDestination()
@ -93,6 +97,7 @@ fun RootNavScreen(
RootNavState.ResetPassword -> RESET_PASSWORD_ROUTE RootNavState.ResetPassword -> RESET_PASSWORD_ROUTE
RootNavState.SetPassword -> SET_PASSWORD_ROUTE RootNavState.SetPassword -> SET_PASSWORD_ROUTE
RootNavState.RemovePassword -> REMOVE_PASSWORD_ROUTE
RootNavState.Splash -> SPLASH_ROUTE RootNavState.Splash -> SPLASH_ROUTE
RootNavState.TrustedDevice -> TRUSTED_DEVICE_GRAPH_ROUTE RootNavState.TrustedDevice -> TRUSTED_DEVICE_GRAPH_ROUTE
RootNavState.VaultLocked -> VAULT_UNLOCK_ROUTE RootNavState.VaultLocked -> VAULT_UNLOCK_ROUTE
@ -149,13 +154,14 @@ fun RootNavScreen(
) )
} }
RootNavState.RemovePassword -> navController.navigateToRemovePassword(rootNavOptions)
RootNavState.ResetPassword -> navController.navigateToResetPasswordGraph(rootNavOptions) RootNavState.ResetPassword -> navController.navigateToResetPasswordGraph(rootNavOptions)
RootNavState.SetPassword -> navController.navigateToSetPassword(rootNavOptions) RootNavState.SetPassword -> navController.navigateToSetPassword(rootNavOptions)
RootNavState.Splash -> navController.navigateToSplash(rootNavOptions) RootNavState.Splash -> navController.navigateToSplash(rootNavOptions)
RootNavState.TrustedDevice -> navController.navigateToTrustedDeviceGraph(rootNavOptions) RootNavState.TrustedDevice -> navController.navigateToTrustedDeviceGraph(rootNavOptions)
RootNavState.VaultLocked -> navController.navigateToVaultUnlock(rootNavOptions) RootNavState.VaultLocked -> navController.navigateToVaultUnlock(rootNavOptions)
is RootNavState.VaultUnlocked -> navController.navigateToVaultUnlockedGraph( is RootNavState.VaultUnlocked -> navController.navigateToVaultUnlockedGraph(
rootNavOptions, navOptions = rootNavOptions,
) )
RootNavState.VaultUnlockedForNewSend -> { RootNavState.VaultUnlockedForNewSend -> {

View file

@ -3,7 +3,9 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav
import android.os.Parcelable import android.os.Parcelable
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.util.parseJwtTokenDataOrNull
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest
@ -11,6 +13,7 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -32,12 +35,12 @@ class RootNavViewModel @Inject constructor(
) { ) {
init { init {
combine( combine(
authRepository authRepository.authStateFlow,
.userStateFlow, authRepository.userStateFlow,
specialCircumstanceManager specialCircumstanceManager.specialCircumstanceStateFlow,
.specialCircumstanceStateFlow, ) { authState, userState, specialCircumstance ->
) { userState, specialCircumstance ->
RootNavAction.Internal.UserStateUpdateReceive( RootNavAction.Internal.UserStateUpdateReceive(
authState = authState,
userState = userState, userState = userState,
specialCircumstance = specialCircumstance, specialCircumstance = specialCircumstance,
) )
@ -86,6 +89,11 @@ class RootNavViewModel @Inject constructor(
} }
} }
userState.activeAccount.isVaultUnlocked &&
userState.shouldShowRemovePassword(authState = action.authState) -> {
RootNavState.RemovePassword
}
userState.activeAccount.isVaultUnlocked -> { userState.activeAccount.isVaultUnlocked -> {
when (specialCircumstance) { when (specialCircumstance) {
is SpecialCircumstance.AutofillSave -> { is SpecialCircumstance.AutofillSave -> {
@ -145,6 +153,20 @@ class RootNavViewModel @Inject constructor(
} }
mutableStateFlow.update { updatedRootNavState } mutableStateFlow.update { updatedRootNavState }
} }
private fun UserState.shouldShowRemovePassword(authState: AuthState): Boolean {
val isLoggedInUsingSso = (authState as? AuthState.Authenticated)
?.accessToken
?.let(::parseJwtTokenDataOrNull)
?.isExternal == true
val usesKeyConnectorAndNotAdmin = this.activeAccount.organizations.any {
it.shouldUseKeyConnector &&
it.role != OrganizationType.OWNER &&
it.role != OrganizationType.ADMIN
}
val userIsNotUsingKeyConnector = !this.activeAccount.isUsingKeyConnector
return isLoggedInUsingSso && usesKeyConnectorAndNotAdmin && userIsNotUsingKeyConnector
}
} }
/** /**
@ -163,6 +185,12 @@ sealed class RootNavState : Parcelable {
@Parcelize @Parcelize
data object AuthWithWelcome : RootNavState() data object AuthWithWelcome : RootNavState()
/**
* App should show remove password graph.
*/
@Parcelize
data object RemovePassword : RootNavState()
/** /**
* App should show reset password graph. * App should show reset password graph.
*/ */
@ -290,6 +318,7 @@ sealed class RootNavAction {
* User state in the repository layer changed. * User state in the repository layer changed.
*/ */
data class UserStateUpdateReceive( data class UserStateUpdateReceive(
val authState: AuthState,
val userState: UserState?, val userState: UserState?,
val specialCircumstance: SpecialCircumstance?, val specialCircumstance: SpecialCircumstance?,
) : RootNavAction() ) : RootNavAction()

View file

@ -2,7 +2,11 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav
import android.content.pm.SigningInfo import android.content.pm.SigningInfo
import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.JwtTokenDataJson
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.util.parseJwtTokenDataOrNull
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CredentialAssertionRequest import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2GetCredentialsRequest import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2GetCredentialsRequest
@ -12,11 +16,16 @@ import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.time.Clock import java.time.Clock
import java.time.Instant import java.time.Instant
@ -24,13 +33,25 @@ import java.time.ZoneOffset
@Suppress("LargeClass") @Suppress("LargeClass")
class RootNavViewModelTest : BaseViewModelTest() { class RootNavViewModelTest : BaseViewModelTest() {
private val mutableAuthStateFlow = MutableStateFlow<AuthState>(AuthState.Uninitialized)
private val mutableUserStateFlow = MutableStateFlow<UserState?>(null) private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
private val authRepository = mockk<AuthRepository> { private val authRepository = mockk<AuthRepository> {
every { userStateFlow } returns mutableUserStateFlow every { userStateFlow } returns mutableUserStateFlow
every { authStateFlow } returns mutableAuthStateFlow
every { showWelcomeCarousel } returns false every { showWelcomeCarousel } returns false
} }
private val specialCircumstanceManager = SpecialCircumstanceManagerImpl() private val specialCircumstanceManager = SpecialCircumstanceManagerImpl()
@BeforeEach
fun setup() {
mockkStatic(::parseJwtTokenDataOrNull)
}
@AfterEach
fun tearDown() {
unmockkStatic(::parseJwtTokenDataOrNull)
}
@Test @Test
fun `when there are no accounts the nav state should be Auth`() { fun `when there are no accounts the nav state should be Auth`() {
mutableUserStateFlow.tryEmit(null) mutableUserStateFlow.tryEmit(null)
@ -294,6 +315,49 @@ class RootNavViewModelTest : BaseViewModelTest() {
) )
} }
@Suppress("MaxLineLength")
@Test
fun `when the active user has an unlocked vault with an external user, a key-connector user account and is not currently using key connector the nav state should be RemovePassword`() {
val jwtTokenDataJson = mockk<JwtTokenDataJson> {
every { isExternal } returns true
}
every { parseJwtTokenDataOrNull(ACCESS_TOKEN) } returns jwtTokenDataJson
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 = listOf(
Organization(
id = "orgId",
name = "orgName",
shouldUseKeyConnector = true,
role = OrganizationType.USER,
),
),
needsMasterPassword = false,
trustedDevice = null,
hasMasterPassword = true,
isUsingKeyConnector = false,
),
),
),
)
mutableAuthStateFlow.value = AuthState.Authenticated(accessToken = ACCESS_TOKEN)
val viewModel = createViewModel()
assertEquals(RootNavState.RemovePassword, viewModel.stateFlow.value)
}
@Test @Test
fun `when the active user has an unlocked vault the nav state should be VaultUnlocked`() { fun `when the active user has an unlocked vault the nav state should be VaultUnlocked`() {
mutableUserStateFlow.tryEmit( mutableUserStateFlow.tryEmit(
@ -742,9 +806,11 @@ class RootNavViewModelTest : BaseViewModelTest() {
authRepository = authRepository, authRepository = authRepository,
specialCircumstanceManager = specialCircumstanceManager, specialCircumstanceManager = specialCircumstanceManager,
) )
private val FIXED_CLOCK: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
} }
private val FIXED_CLOCK: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private const val ACCESS_TOKEN: String = "access_token"