diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/JwtTokenDataJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/JwtTokenDataJson.kt index 31e126b26..45e8e4e84 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/JwtTokenDataJson.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/JwtTokenDataJson.kt @@ -37,4 +37,10 @@ data class JwtTokenDataJson( @SerialName("amr") val authenticationMethodsReference: List, -) +) { + /** + * Indicates that this is an external user. Mainly used for SSO users with a key connector. + */ + val isExternal: Boolean + get() = authenticationMethodsReference.any { it == "external" } +} 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 3c6a8c136..1f270c109 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 @@ -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 -> { 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 4507b6c4f..03ef3e9aa 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 @@ -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() 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 be3cdb104..9fec3c0ab 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 @@ -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.Uninitialized) private val mutableUserStateFlow = MutableStateFlow(null) private val authRepository = mockk { 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 { + 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"