[PM-27001] Skip account selection only one exists on cxp flow (#6055)

This commit is contained in:
aj-rosado 2025-10-22 10:08:35 +01:00 committed by GitHub
parent ae4b398258
commit e610a7541d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 208 additions and 7 deletions

View File

@ -68,6 +68,7 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toVaultItemCipherType
import com.x8bit.bitwarden.ui.vault.feature.exportitems.ExportItemsGraphRoute
import com.x8bit.bitwarden.ui.vault.feature.exportitems.exportItemsGraph
import com.x8bit.bitwarden.ui.vault.feature.exportitems.navigateToExportItemsGraph
import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.navigateToVerifyPassword
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.navigateToVaultItemListingAsRoot
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
@ -142,7 +143,10 @@ fun RootNavScreen(
is RootNavState.VaultUnlockedForProviderGetCredentials,
-> VaultUnlockedGraphRoute
is RootNavState.CredentialExchangeExport -> ExportItemsGraphRoute
is RootNavState.CredentialExchangeExport,
is RootNavState.CredentialExchangeExportSkipAccountSelection,
-> ExportItemsGraphRoute
RootNavState.OnboardingAccountLockSetup -> SetupUnlockRoute.AsRoot
RootNavState.OnboardingAutoFillSetup -> SetupAutofillRoute.AsRoot
RootNavState.OnboardingBrowserAutofillSetup -> SetupBrowserAutofillRoute.AsRoot
@ -288,6 +292,13 @@ fun RootNavScreen(
is RootNavState.CredentialExchangeExport -> {
navController.navigateToExportItemsGraph(rootNavOptions)
}
is RootNavState.CredentialExchangeExportSkipAccountSelection -> {
navController.navigateToVerifyPassword(
userId = currentState.userId,
navOptions = rootNavOptions,
)
}
}
}
}

View File

@ -59,7 +59,7 @@ class RootNavViewModel @Inject constructor(
}
}
@Suppress("CyclomaticComplexMethod", "MaxLineLength", "LongMethod")
@Suppress("CyclomaticComplexMethod", "LongMethod")
private fun handleUserStateUpdateReceive(
action: RootNavAction.Internal.UserStateUpdateReceive,
) {
@ -89,7 +89,13 @@ class RootNavViewModel @Inject constructor(
}
specialCircumstance is SpecialCircumstance.CredentialExchangeExport -> {
RootNavState.CredentialExchangeExport
if (userState.accounts.size == 1) {
RootNavState.CredentialExchangeExportSkipAccountSelection(
userId = userState.accounts.first().userId,
)
} else {
RootNavState.CredentialExchangeExport
}
}
userState.activeAccount.isVaultUnlocked &&
@ -424,6 +430,14 @@ sealed class RootNavState : Parcelable {
*/
@Parcelize
data object CredentialExchangeExport : RootNavState()
/**
* App should begin the export items flow, skipping the account selection screen.
*/
@Parcelize
data class CredentialExchangeExportSkipAccountSelection(
val userId: String,
) : RootNavState()
}
/**

View File

@ -23,8 +23,12 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.credentials.providerevents.exception.ImportCredentialsCancellationException
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
import com.bitwarden.cxf.manager.model.ExportCredentialsResult
import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeCompletionManager
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
@ -54,6 +58,8 @@ fun VerifyPasswordScreen(
onNavigateBack: () -> Unit,
onPasswordVerified: (userId: String) -> Unit,
viewModel: VerifyPasswordViewModel = hiltViewModel(),
credentialExchangeCompletionManager: CredentialExchangeCompletionManager =
LocalCredentialExchangeCompletionManager.current,
snackbarHostState: BitwardenSnackbarHostState = rememberBitwardenSnackbarHostState(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@ -63,6 +69,16 @@ fun VerifyPasswordScreen(
EventsEffect(viewModel) { event ->
when (event) {
VerifyPasswordEvent.NavigateBack -> onNavigateBack()
VerifyPasswordEvent.CancelExport -> {
credentialExchangeCompletionManager
.completeCredentialExport(
exportResult = ExportCredentialsResult.Failure(
error = ImportCredentialsCancellationException(
errorMessage = "User cancelled import.",
),
),
)
}
is VerifyPasswordEvent.PasswordVerified -> {
onPasswordVerified(event.userId)
@ -81,7 +97,11 @@ fun VerifyPasswordScreen(
ExportItemsScaffold(
navIcon = rememberVectorPainter(
BitwardenDrawable.ic_back,
id = if (state.hasOtherAccounts) {
BitwardenDrawable.ic_back
} else {
BitwardenDrawable.ic_close
},
),
onNavigationIconClick = handler.onNavigateBackClick,
navigationIconContentDescription = stringResource(BitwardenString.back),
@ -263,6 +283,7 @@ private fun VerifyPasswordContent_MasterPassword_preview() {
val state = VerifyPasswordState(
title = BitwardenString.verify_your_master_password.asText(),
subtext = null,
hasOtherAccounts = true,
accountSummaryListItem = accountSummaryListItem,
)
ExportItemsScaffold(
@ -303,6 +324,7 @@ private fun VerifyPasswordContent_Otp_preview() {
.asText(),
accountSummaryListItem = accountSummaryListItem,
showResendCodeButton = true,
hasOtherAccounts = true,
)
ExportItemsScaffold(
navIcon = rememberVectorPainter(

View File

@ -56,6 +56,12 @@ class VerifyPasswordViewModel @Inject constructor(
?.firstOrNull { it.userId == args.userId }
?: throw IllegalStateException("Account not found")
val singleAccount = authRepository
.userStateFlow
.value
?.accounts
?.size == 1
val restrictedItemPolicyOrgIds = policyManager
.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
.filter { it.isEnabled }
@ -81,6 +87,7 @@ class VerifyPasswordViewModel @Inject constructor(
.any { it.id in restrictedItemPolicyOrgIds },
),
showResendCodeButton = !account.hasMasterPassword,
hasOtherAccounts = !singleAccount,
)
},
) {
@ -138,7 +145,11 @@ class VerifyPasswordViewModel @Inject constructor(
}
private fun handleNavigateBackClick() {
sendEvent(VerifyPasswordEvent.NavigateBack)
if (state.hasOtherAccounts) {
sendEvent(VerifyPasswordEvent.NavigateBack)
} else {
sendEvent(VerifyPasswordEvent.CancelExport)
}
}
private fun handleContinueClick() {
@ -421,8 +432,10 @@ data class VerifyPasswordState(
val accountSummaryListItem: AccountSelectionListItem,
val title: Text,
val subtext: Text?,
val hasOtherAccounts: Boolean,
// We never want this saved since the input is sensitive data.
@IgnoredOnParcel val input: String = "",
@IgnoredOnParcel
val input: String = "",
val dialog: DialogState? = null,
val showResendCodeButton: Boolean = false,
) : Parcelable {
@ -475,6 +488,11 @@ sealed class VerifyPasswordEvent {
*/
data class PasswordVerified(val userId: String) : VerifyPasswordEvent()
/**
* Cancel the export request.
*/
data object CancelExport : VerifyPasswordEvent()
/**
* Show a snackbar with the given data.
*/

View File

@ -25,6 +25,7 @@ import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditMode
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditRoute
import com.x8bit.bitwarden.ui.vault.feature.exportitems.ExportItemsGraphRoute
import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.VerifyPasswordRoute
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.ItemListingType
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingRoute
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
@ -450,6 +451,26 @@ class RootNavScreenTest : BitwardenComposeTest() {
)
}
}
// Make sure navigating to export items graph works as expected:
rootNavStateFlow.value = RootNavState.CredentialExchangeExportSkipAccountSelection(
userId = "activeUserId",
)
composeTestRule.runOnIdle {
verify {
mockNavHostController.navigate(
route = ExportItemsGraphRoute,
navOptions = expectedNavOptions,
)
mockNavHostController.navigate(
route = VerifyPasswordRoute(
userId = "activeUserId",
),
navOptions = expectedNavOptions,
)
}
}
}
}

View File

@ -1441,7 +1441,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
requestJson = "mockRequestJson",
),
)
mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_STATE)
mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_MULTIPLE_ACCOUNTS_STATE)
val viewModel = createViewModel()
assertEquals(
RootNavState.CredentialExchangeExport,
@ -1449,6 +1449,26 @@ class RootNavViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `when SpecialCircumstance is CredentialExchangeExport and only has 1 account, the nav state should be CredentialExchangeExportSkipAccountSelection`() {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.CredentialExchangeExport(
data = ImportCredentialsRequestData(
uri = mockk(),
requestJson = "mockRequestJson",
),
)
mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_STATE)
val viewModel = createViewModel()
assertEquals(
RootNavState.CredentialExchangeExportSkipAccountSelection(
userId = "activeUserId",
),
viewModel.stateFlow.value,
)
}
private fun createViewModel(): RootNavViewModel =
RootNavViewModel(
authRepository = authRepository,
@ -1487,3 +1507,48 @@ private val MOCK_VAULT_UNLOCKED_USER_STATE = UserState(
),
),
)
private val MOCK_VAULT_UNLOCKED_USER_MULTIPLE_ACCOUNTS_STATE = 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,
firstTimeState = FirstTimeState(false),
onboardingStatus = OnboardingStatus.COMPLETE,
),
UserState.Account(
userId = "activeUserTwoId",
name = "name two",
email = "email two",
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,
firstTimeState = FirstTimeState(false),
onboardingStatus = OnboardingStatus.COMPLETE,
),
),
)

View File

@ -297,4 +297,5 @@ private val DEFAULT_STATE = VerifyPasswordState(
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM,
input = "",
dialog = null,
hasOtherAccounts = true,
)

View File

@ -104,6 +104,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
initials = DEFAULT_USER_STATE.activeAccount.initials,
),
showResendCodeButton = true,
hasOtherAccounts = true,
),
it.stateFlow.value,
)
@ -119,6 +120,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
VerifyPasswordState(
title = BitwardenString.verify_your_master_password.asText(),
subtext = null,
hasOtherAccounts = true,
accountSummaryListItem = AccountSelectionListItem(
userId = DEFAULT_USER_ID,
email = DEFAULT_USER_STATE.activeAccount.email,
@ -150,6 +152,7 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
VerifyPasswordState(
title = BitwardenString.verify_your_master_password.asText(),
subtext = null,
hasOtherAccounts = true,
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM
.copy(isItemRestricted = true),
),
@ -285,6 +288,21 @@ class VerifyPasswordViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `NavigateBackClick should send CancelExport event when hasOtherAccounts is false`() =
runTest {
val initialState = DEFAULT_STATE.copy(hasOtherAccounts = false)
createViewModel(state = initialState).also {
it.trySendAction(VerifyPasswordAction.NavigateBackClick)
it.eventFlow.test {
assertEquals(
VerifyPasswordEvent.CancelExport,
awaitItem(),
)
}
}
}
@Test
fun `ContinueClick with empty input should show error dialog`() = runTest {
createViewModel().also {
@ -724,6 +742,36 @@ private val DEFAULT_USER_STATE = UserState(
onboardingStatus = OnboardingStatus.COMPLETE,
firstTimeState = FirstTimeState(showImportLoginsCard = true),
),
UserState.Account(
userId = "activeUserId2",
name = "Active User Two",
email = "active+two@bitwarden.com",
avatarColorHex = "#aa00aa",
environment = Environment.Us,
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = listOf(
Organization(
id = DEFAULT_ORGANIZATION_ID,
name = "Organization User Two",
shouldUseKeyConnector = false,
shouldManageResetPassword = false,
role = OrganizationType.USER,
keyConnectorUrl = null,
userIsClaimedByOrganization = false,
),
),
needsMasterPassword = false,
trustedDevice = null,
hasMasterPassword = true,
isUsingKeyConnector = false,
onboardingStatus = OnboardingStatus.COMPLETE,
firstTimeState = FirstTimeState(showImportLoginsCard = true),
),
),
)
private val DEFAULT_ACCOUNT_SELECTION_LIST_ITEM = AccountSelectionListItem(
@ -736,6 +784,7 @@ private val DEFAULT_ACCOUNT_SELECTION_LIST_ITEM = AccountSelectionListItem(
private val DEFAULT_STATE = VerifyPasswordState(
title = BitwardenString.verify_your_master_password.asText(),
subtext = null,
hasOtherAccounts = true,
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM,
input = "",
dialog = null,