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

View file

@ -3,7 +3,9 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav
import android.os.Parcelable
import androidx.lifecycle.viewModelScope
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.util.parseJwtTokenDataOrNull
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.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.platform.manager.SpecialCircumstanceManager
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.combine
@ -32,12 +35,12 @@ class RootNavViewModel @Inject constructor(
) {
init {
combine(
authRepository
.userStateFlow,
specialCircumstanceManager
.specialCircumstanceStateFlow,
) { userState, specialCircumstance ->
authRepository.authStateFlow,
authRepository.userStateFlow,
specialCircumstanceManager.specialCircumstanceStateFlow,
) { authState, userState, specialCircumstance ->
RootNavAction.Internal.UserStateUpdateReceive(
authState = authState,
userState = userState,
specialCircumstance = specialCircumstance,
)
@ -86,6 +89,11 @@ class RootNavViewModel @Inject constructor(
}
}
userState.activeAccount.isVaultUnlocked &&
userState.shouldShowRemovePassword(authState = action.authState) -> {
RootNavState.RemovePassword
}
userState.activeAccount.isVaultUnlocked -> {
when (specialCircumstance) {
is SpecialCircumstance.AutofillSave -> {
@ -145,6 +153,20 @@ class RootNavViewModel @Inject constructor(
}
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
data object AuthWithWelcome : RootNavState()
/**
* App should show remove password graph.
*/
@Parcelize
data object RemovePassword : RootNavState()
/**
* App should show reset password graph.
*/
@ -290,6 +318,7 @@ sealed class RootNavAction {
* User state in the repository layer changed.
*/
data class UserStateUpdateReceive(
val authState: AuthState,
val userState: UserState?,
val specialCircumstance: SpecialCircumstance?,
) : RootNavAction()

View file

@ -2,7 +2,11 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav
import android.content.pm.SigningInfo
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.util.parseJwtTokenDataOrNull
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.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.SpecialCircumstance
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 io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
@ -24,13 +33,25 @@ import java.time.ZoneOffset
@Suppress("LargeClass")
class RootNavViewModelTest : BaseViewModelTest() {
private val mutableAuthStateFlow = MutableStateFlow<AuthState>(AuthState.Uninitialized)
private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
private val authRepository = mockk<AuthRepository> {
every { userStateFlow } returns mutableUserStateFlow
every { authStateFlow } returns mutableAuthStateFlow
every { showWelcomeCarousel } returns false
}
private val specialCircumstanceManager = SpecialCircumstanceManagerImpl()
@BeforeEach
fun setup() {
mockkStatic(::parseJwtTokenDataOrNull)
}
@AfterEach
fun tearDown() {
unmockkStatic(::parseJwtTokenDataOrNull)
}
@Test
fun `when there are no accounts the nav state should be Auth`() {
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
fun `when the active user has an unlocked vault the nav state should be VaultUnlocked`() {
mutableUserStateFlow.tryEmit(
@ -742,9 +806,11 @@ class RootNavViewModelTest : BaseViewModelTest() {
authRepository = authRepository,
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"