[PM-26110] Add verify password screen for item export (#5935)

This commit is contained in:
Patrick Honkonen
2025-09-26 10:57:59 -04:00
committed by GitHub
parent 2694138aa1
commit 7bf4acbb28
9 changed files with 1579 additions and 4 deletions

View File

@@ -65,6 +65,7 @@ import com.x8bit.bitwarden.ui.tools.feature.send.addedit.navigateToAddEditSend
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditArgs
import com.x8bit.bitwarden.ui.vault.feature.addedit.navigateToVaultAddEdit
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toVaultItemCipherType
import com.x8bit.bitwarden.ui.vault.feature.exportitems.ExportItemsRoute
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.itemlisting.navigateToVaultItemListingAsRoot
@@ -113,7 +114,7 @@ fun RootNavScreen(
setupBrowserAutofillDestinationAsRoot()
setupAutoFillDestinationAsRoot()
setupCompleteDestination()
exportItemsGraph()
exportItemsGraph(navController)
}
val targetRoute = when (state) {
@@ -139,9 +140,9 @@ fun RootNavScreen(
is RootNavState.VaultUnlockedForFido2Assertion,
is RootNavState.VaultUnlockedForPasswordGet,
is RootNavState.VaultUnlockedForProviderGetCredentials,
is RootNavState.CredentialExchangeExport,
-> VaultUnlockedGraphRoute
is RootNavState.CredentialExchangeExport -> ExportItemsRoute
RootNavState.OnboardingAccountLockSetup -> SetupUnlockRoute.AsRoot
RootNavState.OnboardingAutoFillSetup -> SetupAutofillRoute.AsRoot
RootNavState.OnboardingBrowserAutofillSetup -> SetupBrowserAutofillRoute.AsRoot

View File

@@ -9,6 +9,8 @@ import androidx.navigation.navigation
import com.bitwarden.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountRoute
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.selectAccountDestination
import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.navigateToVerifyPassword
import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.verifyPasswordDestination
import kotlinx.serialization.Serializable
/**
@@ -21,13 +23,21 @@ data object ExportItemsRoute
/**
* Add export items destinations to the nav graph.
*/
fun NavGraphBuilder.exportItemsGraph() {
fun NavGraphBuilder.exportItemsGraph(
navController: NavController,
) {
navigation<ExportItemsRoute>(
startDestination = SelectAccountRoute,
) {
selectAccountDestination(
onAccountSelected = {
// TODO: [PM-26110] Navigate to verify password screen.
navController.navigateToVerifyPassword(userId = it)
},
)
verifyPasswordDestination(
onNavigateBack = { navController.popBackStack() },
onPasswordVerified = {
// TODO: [PM-26111] Navigate to confirm export screen.
},
)
}

View File

@@ -0,0 +1,79 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.toRoute
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
import com.bitwarden.ui.platform.util.ParcelableRouteSerializer
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
/**
* The type-safe route for the verify password screen.
*/
@Parcelize
@Serializable(with = VerifyPasswordRoute.Serializer::class)
@OmitFromCoverage
data class VerifyPasswordRoute(
val userId: String,
) : Parcelable {
/**
* Custom serializer to support polymorphic routes.
*/
class Serializer : ParcelableRouteSerializer<VerifyPasswordRoute>(
kClass = VerifyPasswordRoute::class,
)
}
/**
* Class to retrieve verify password arguments from the [SavedStateHandle].
*/
@OmitFromCoverage
data class VerifyPasswordArgs(
val userId: String,
)
/**
* Constructs a [VerifyPasswordArgs] from the [SavedStateHandle] and internal route data.
*/
fun SavedStateHandle.toVerifyPasswordArgs(): VerifyPasswordArgs {
val route = this.toRoute<VerifyPasswordRoute>()
return VerifyPasswordArgs(
userId = route.userId,
)
}
/**
* Add the [VerifyPasswordScreen] to the nav graph.
*/
fun NavGraphBuilder.verifyPasswordDestination(
onNavigateBack: () -> Unit,
onPasswordVerified: (userId: String) -> Unit,
) {
composableWithPushTransitions<VerifyPasswordRoute> {
VerifyPasswordScreen(
onNavigateBack = onNavigateBack,
onPasswordVerified = onPasswordVerified,
)
}
}
/**
* Navigate to the [VerifyPasswordScreen].
*/
fun NavController.navigateToVerifyPassword(
userId: String,
navOptions: NavOptions? = null,
) {
navigate(
route = VerifyPasswordRoute(userId = userId),
navOptions = navOptions,
)
}

View File

@@ -0,0 +1,201 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.bitwarden.ui.platform.components.field.BitwardenPasswordField
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.vault.feature.exportitems.component.AccountSummaryListItem
import com.x8bit.bitwarden.ui.vault.feature.exportitems.component.ExportItemsScaffold
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.handlers.rememberVerifyPasswordHandler
/**
* Top level composable for the Verify Password screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VerifyPasswordScreen(
onNavigateBack: () -> Unit,
onPasswordVerified: (userId: String) -> Unit,
viewModel: VerifyPasswordViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val handler = rememberVerifyPasswordHandler(viewModel)
EventsEffect(viewModel) { event ->
when (event) {
VerifyPasswordEvent.NavigateBack -> onNavigateBack()
is VerifyPasswordEvent.PasswordVerified -> {
onPasswordVerified(event.userId)
}
}
}
VerifyPasswordDialogs(
dialog = state.dialog,
onDismiss = handler.onDismissDialog,
)
ExportItemsScaffold(
navIcon = rememberVectorPainter(
BitwardenDrawable.ic_back,
),
onNavigationIconClick = handler.onNavigateBackClick,
navigationIconContentDescription = stringResource(BitwardenString.back),
scrollBehavior = scrollBehavior,
modifier = Modifier.fillMaxSize(),
) {
VerifyPasswordContent(
state = state,
onInputChanged = handler.onInputChanged,
onUnlockClick = handler.onUnlockClick,
modifier = Modifier
.fillMaxSize()
.standardHorizontalMargin(),
)
}
}
@Composable
private fun VerifyPasswordDialogs(
dialog: VerifyPasswordState.DialogState?,
onDismiss: () -> Unit,
) {
when (dialog) {
is VerifyPasswordState.DialogState.General -> {
BitwardenBasicDialog(
title = dialog.title(),
message = dialog.message(),
throwable = dialog.error,
onDismissRequest = onDismiss,
)
}
is VerifyPasswordState.DialogState.Loading -> {
BitwardenLoadingDialog(text = dialog.message())
}
null -> Unit
}
}
@Composable
private fun VerifyPasswordContent(
state: VerifyPasswordState,
onInputChanged: (String) -> Unit,
onUnlockClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(BitwardenString.verify_your_master_password),
textAlign = TextAlign.Center,
style = BitwardenTheme.typography.titleMedium,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
AccountSummaryListItem(
item = state.accountSummaryListItem,
cardStyle = CardStyle.Full,
clickable = false,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
BitwardenPasswordField(
label = stringResource(BitwardenString.master_password),
value = state.input,
onValueChange = onInputChanged,
showPasswordTestTag = "PasswordVisibilityToggle",
imeAction = ImeAction.Done,
keyboardActions = KeyboardActions(
onDone = {
if (state.isUnlockButtonEnabled) {
onUnlockClick()
} else {
defaultKeyboardAction(ImeAction.Done)
}
},
),
supportingText = stringResource(BitwardenString.vault_locked_master_password),
passwordFieldTestTag = "MasterPasswordEntry",
cardStyle = CardStyle.Full,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
BitwardenFilledButton(
label = stringResource(BitwardenString.unlock),
onClick = onUnlockClick,
isEnabled = state.isUnlockButtonEnabled,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(showBackground = true)
@Composable
private fun VerifyPasswordContent_Preview() {
val accountSummaryListItem = AccountSelectionListItem(
userId = "userId",
isItemRestricted = false,
avatarColorHex = "#FF0000",
initials = "JD",
email = "john.doe@example.com",
)
val state = VerifyPasswordState(
accountSummaryListItem = accountSummaryListItem,
)
VerifyPasswordContent(
state = state,
onInputChanged = {},
onUnlockClick = {},
modifier = Modifier
.fillMaxSize()
.standardHorizontalMargin(),
)
}

View File

@@ -0,0 +1,392 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
import dagger.hilt.android.lifecycle.HiltViewModel
import jakarta.inject.Inject
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
private const val KEY_STATE = "state"
/**
* ViewModel for the VerifyPassword screen.
*
* This view model does not assume password verification is requested for the active user. Switching
* to the provided account is deferred until password verification is explicitly requested. This is
* done to reduce the number of times account switching is performed, since it can be a costly
* operation.
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class VerifyPasswordViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val vaultRepository: VaultRepository,
private val policyManager: PolicyManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<VerifyPasswordState, VerifyPasswordEvent, VerifyPasswordAction>(
initialState = savedStateHandle[KEY_STATE]
?: run {
val args = savedStateHandle.toVerifyPasswordArgs()
val account = authRepository
.userStateFlow
.value
?.accounts
?.firstOrNull { it.userId == args.userId }
?: throw IllegalStateException("Account not found")
val restrictedItemPolicyOrgIds = policyManager
.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
.filter { it.isEnabled }
.map { it.organizationId }
VerifyPasswordState(
accountSummaryListItem = AccountSelectionListItem(
userId = args.userId,
avatarColorHex = account.avatarColorHex,
email = account.email,
initials = account.initials,
isItemRestricted = account
.organizations
.any { it.id in restrictedItemPolicyOrgIds },
),
)
},
) {
init {
// As state updates, write to saved state handle.
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: VerifyPasswordAction) {
when (action) {
VerifyPasswordAction.NavigateBackClick -> {
handleNavigateBackClick()
}
VerifyPasswordAction.UnlockClick -> {
handleUnlockClick()
}
is VerifyPasswordAction.PasswordInputChangeReceive -> {
handlePasswordInputChange(action)
}
VerifyPasswordAction.DismissDialog -> {
handleDismissDialog()
}
is VerifyPasswordAction.Internal -> {
handleInternalAction(action)
}
}
}
private fun handleNavigateBackClick() {
sendEvent(VerifyPasswordEvent.NavigateBack)
}
private fun handleUnlockClick() {
if (state.input.isBlank()) {
mutableStateFlow.update {
it.copy(
dialog = VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.validation_field_required.asText(
BitwardenString.master_password.asText(),
),
),
)
}
return
}
mutableStateFlow.update {
it.copy(
dialog = VerifyPasswordState.DialogState.Loading(
message = BitwardenString.loading.asText(),
),
)
}
if (authRepository.activeUserId != state.accountSummaryListItem.userId) {
switchAccountAndVerifyPassword()
} else {
validatePassword()
}
}
private fun handlePasswordInputChange(
action: VerifyPasswordAction.PasswordInputChangeReceive,
) {
mutableStateFlow.update { it.copy(input = action.input) }
}
private fun handleDismissDialog() {
mutableStateFlow.update { it.copy(dialog = null) }
}
private fun handleInternalAction(action: VerifyPasswordAction.Internal) {
when (action) {
is VerifyPasswordAction.Internal.ValidatePasswordResultReceive -> {
handleValidatePasswordResultReceive(action)
}
is VerifyPasswordAction.Internal.UnlockVaultResultReceive -> {
handleUnlockVaultResultReceive(action)
}
}
}
private fun handleValidatePasswordResultReceive(
action: VerifyPasswordAction.Internal.ValidatePasswordResultReceive,
) {
mutableStateFlow.update { it.copy(dialog = null) }
when (action.result) {
is ValidatePasswordResult.Success -> {
if (action.result.isValid) {
sendEvent(
VerifyPasswordEvent.PasswordVerified(
state.accountSummaryListItem.userId,
),
)
} else {
showInvalidMasterPasswordDialog()
}
}
is ValidatePasswordResult.Error -> {
showGenericErrorDialog(throwable = action.result.error)
}
}
}
private fun handleUnlockVaultResultReceive(
action: VerifyPasswordAction.Internal.UnlockVaultResultReceive,
) {
mutableStateFlow.update { it.copy(dialog = null) }
when (action.vaultUnlockResult) {
VaultUnlockResult.Success -> {
// A successful unlock result means the provided password is correct so we can
// consider the password verified and send the event.
sendEvent(
VerifyPasswordEvent.PasswordVerified(
state.accountSummaryListItem.userId,
),
)
}
is VaultUnlockResult.AuthenticationError -> {
showInvalidMasterPasswordDialog(
throwable = action.vaultUnlockResult.error,
)
}
is VaultUnlockResult.InvalidStateError,
is VaultUnlockResult.BiometricDecodingError,
is VaultUnlockResult.GenericError,
-> {
showGenericErrorDialog(throwable = action.vaultUnlockResult.error)
}
}
}
private fun switchAccountAndVerifyPassword() {
val switchAccountResult = authRepository
.switchAccount(userId = state.accountSummaryListItem.userId)
when (switchAccountResult) {
SwitchAccountResult.AccountSwitched -> validatePassword()
SwitchAccountResult.NoChange -> {
mutableStateFlow.update {
it.copy(
dialog = VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
),
)
}
}
}
}
private fun validatePassword() {
val userId = state.accountSummaryListItem.userId
viewModelScope.launch {
if (vaultRepository.isVaultUnlocked(userId)) {
// If the vault is already unlocked, validate the password directly.
sendAction(
VerifyPasswordAction.Internal.ValidatePasswordResultReceive(
authRepository.validatePassword(password = state.input),
),
)
} else {
// Otherwise, unlock the vault with the provided password. The unlock result will
// indicate whether the password is correct.
sendAction(
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
vaultRepository
.unlockVaultWithMasterPassword(masterPassword = state.input),
),
)
}
}
}
private fun showInvalidMasterPasswordDialog(
throwable: Throwable? = null,
) {
mutableStateFlow.update {
it.copy(
dialog = VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.invalid_master_password.asText(),
error = throwable,
),
)
}
}
private fun showGenericErrorDialog(throwable: Throwable?) {
mutableStateFlow.update {
it.copy(
dialog = VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
error = throwable,
),
)
}
}
}
/**
* Represents the state of the VerifyPassword screen.
* @param accountSummaryListItem The account summary to display.
* @param input The current password input.
* @param dialog The current dialog state, or null if no dialog is shown.
*/
@Parcelize
data class VerifyPasswordState(
val accountSummaryListItem: AccountSelectionListItem,
val input: String = "",
val dialog: DialogState? = null,
) : Parcelable {
/**
* Whether the unlock button should be enabled.
*/
val isUnlockButtonEnabled: Boolean
get() = input.isNotBlank() && dialog !is DialogState.Loading
/**
* Represents the state of a dialog.
*/
@Parcelize
sealed class DialogState : Parcelable {
/**
* Represents a general dialog with a title, message, and optional error.
* @param title The dialog title.
* @param message The dialog message.
* @param error An optional error associated with the dialog.
*/
data class General(
val title: Text,
val message: Text,
val error: Throwable? = null,
) : DialogState()
/**
* Represents a loading dialog with a message.
* @param message The loading message.
*/
data class Loading(
val message: Text,
) : DialogState()
}
}
/**
* Represents events that can be emitted from the VerifyPasswordViewModel.
*/
sealed class VerifyPasswordEvent {
/**
* Indicates a request to navigate back.
*/
data object NavigateBack : VerifyPasswordEvent()
/**
* Indicates that the password has been successfully verified.
* @param userId The ID of the user whose password was verified.
*/
data class PasswordVerified(val userId: String) : VerifyPasswordEvent()
}
/**
* Represents actions that can be handled by the VerifyPasswordViewModel.
*/
sealed class VerifyPasswordAction {
/**
* Represents a click on the navigate back button.
*/
data object NavigateBackClick : VerifyPasswordAction()
/**
* Represents a click on the unlock button.
*/
data object UnlockClick : VerifyPasswordAction()
/**
* Dismiss the current dialog.
*/
data object DismissDialog : VerifyPasswordAction()
/**
* Represents a change in the password input.
* @param input The new password input.
*/
data class PasswordInputChangeReceive(val input: String) : VerifyPasswordAction()
/**
* Represents internal actions that the VerifyPasswordViewModel itself may send.
*/
sealed class Internal : VerifyPasswordAction() {
/**
* Represents a result of validating the password.
* @param result The result of validating the password.
*/
data class ValidatePasswordResultReceive(
val result: ValidatePasswordResult,
) : Internal()
/**
* Represents a result of unlocking the vault.
* @param vaultUnlockResult The result of unlocking the vault.
*/
data class UnlockVaultResultReceive(
val vaultUnlockResult: VaultUnlockResult,
) : Internal()
}
}

View File

@@ -0,0 +1,49 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.handlers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.VerifyPasswordAction
import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.VerifyPasswordViewModel
/**
* A handler for the VerifyPassword screen interactions.
*/
data class VerifyPasswordHandlers(
val onNavigateBackClick: () -> Unit,
val onUnlockClick: () -> Unit,
val onInputChanged: (String) -> Unit,
val onDismissDialog: () -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
/**
* Creates a [VerifyPasswordHandlers] from a [VerifyPasswordViewModel].
*/
fun create(viewModel: VerifyPasswordViewModel): VerifyPasswordHandlers =
VerifyPasswordHandlers(
onNavigateBackClick = {
viewModel.trySendAction(VerifyPasswordAction.NavigateBackClick)
},
onUnlockClick = {
viewModel.trySendAction(VerifyPasswordAction.UnlockClick)
},
onInputChanged = {
viewModel.trySendAction(
VerifyPasswordAction.PasswordInputChangeReceive(it),
)
},
onDismissDialog = {
viewModel.trySendAction(VerifyPasswordAction.DismissDialog)
},
)
}
}
/**
* Helper function to remember a [VerifyPasswordHandlers] instance in a [Composable] scope.
*/
@Composable
fun rememberVerifyPasswordHandler(viewModel: VerifyPasswordViewModel): VerifyPasswordHandlers =
remember(viewModel) { VerifyPasswordHandlers.create(viewModel) }

View File

@@ -0,0 +1,265 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
import io.mockk.every
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.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class VerifyPasswordScreenTest : BitwardenComposeTest() {
private var onNavigateBackClicked: Boolean = false
private var onPasswordVerifiedClicked: Boolean = false
private val onPasswordVerifiedArgSlot = mutableListOf<String>()
private val mockStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mockEventFlow = bufferedMutableSharedFlow<VerifyPasswordEvent>()
private val viewModel = mockk<VerifyPasswordViewModel> {
every { stateFlow } returns mockStateFlow
every { eventFlow } returns mockEventFlow
every { trySendAction(any()) } just runs
}
@Before
fun verifyPasswordScreen() {
setContent {
VerifyPasswordScreen(
onNavigateBack = { onNavigateBackClicked = true },
onPasswordVerified = { userId ->
onPasswordVerifiedClicked = true
onPasswordVerifiedArgSlot.add(userId)
},
viewModel = viewModel,
)
}
}
@Test
fun `initial state should be correct`() = runTest {
composeTestRule
.onNodeWithText("Verify your master password")
.isDisplayed()
composeTestRule
.onNodeWithText("AU")
.isDisplayed()
composeTestRule
.onNodeWithText("active@bitwarden.com")
.isDisplayed()
composeTestRule
.onNodeWithText("You vault is locked. Verify your master password to continue.")
composeTestRule
.onNodeWithText("Unlock")
.assertIsNotEnabled()
}
@Test
fun `input should update based on state`() = runTest {
composeTestRule
.onNodeWithText("Master password")
.performTextInput("abc123")
composeTestRule
.onNodeWithTag("PasswordVisibilityToggle")
.performClick()
composeTestRule
.onNodeWithText("abc123")
.isDisplayed()
}
@Test
fun `input change should send PasswordInputChangeReceive action`() = runTest {
composeTestRule
.onNodeWithText("Master password")
.performTextInput("abc123")
composeTestRule
.onNodeWithTag("PasswordVisibilityToggle")
.performClick()
verify {
viewModel.trySendAction(
VerifyPasswordAction.PasswordInputChangeReceive("abc123"),
)
}
}
@Test
fun `Unlock button should should update based on input`() = runTest {
composeTestRule
.onNodeWithText("Unlock")
.assertIsNotEnabled()
mockStateFlow.emit(DEFAULT_STATE.copy(input = "abc123"))
composeTestRule
.onNodeWithText("Unlock")
.assertIsEnabled()
}
@Test
fun `Unlock button should send UnlockClick action`() = runTest {
mockStateFlow.emit(DEFAULT_STATE.copy(input = "abc123"))
composeTestRule
.onNodeWithText("Unlock")
.performClick()
verify {
viewModel.trySendAction(VerifyPasswordAction.UnlockClick)
}
}
@Test
fun `back click should send NavigateBackClick action`() = runTest {
composeTestRule
.onNodeWithContentDescription("Back")
.performClick()
verify {
viewModel.trySendAction(VerifyPasswordAction.NavigateBackClick)
}
}
@Test
fun `NavigateBack event should trigger onNavigateBack`() = runTest {
mockEventFlow.emit(VerifyPasswordEvent.NavigateBack)
assertTrue(onNavigateBackClicked)
}
@Test
fun `PasswordVerified event should call onPasswordVerified with userId`() = runTest {
mockEventFlow.emit(VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID))
assertTrue(onPasswordVerifiedClicked)
assertEquals(1, onPasswordVerifiedArgSlot.size)
assertEquals(DEFAULT_USER_ID, onPasswordVerifiedArgSlot.first())
}
@Test
fun `General dialog should display based on state`() = runTest {
mockStateFlow.emit(
DEFAULT_STATE.copy(
dialog = VerifyPasswordState.DialogState.General(
title = "title".asText(),
message = "message".asText(),
error = null,
),
),
)
composeTestRule
.onAllNodesWithText("title")
.filterToOne(hasAnyAncestor(isDialog()))
.isDisplayed()
}
@Test
fun `General dialog dismiss should send DismissDialog action`() = runTest {
mockStateFlow.emit(
DEFAULT_STATE.copy(
dialog = VerifyPasswordState.DialogState.General(
title = "title".asText(),
message = "message".asText(),
error = null,
),
),
)
composeTestRule
.onAllNodesWithText("Okay")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(VerifyPasswordAction.DismissDialog)
}
}
@Test
fun `Loading dialog should display based on state`() = runTest {
mockStateFlow.emit(
DEFAULT_STATE.copy(
dialog = VerifyPasswordState.DialogState.Loading("message".asText()),
),
)
composeTestRule
.onAllNodesWithText("message")
.filterToOne(hasAnyAncestor(isDialog()))
.isDisplayed()
}
}
private const val DEFAULT_USER_ID: String = "activeUserId"
private const val DEFAULT_ORGANIZATION_ID: String = "activeOrganizationId"
private val DEFAULT_USER_STATE = UserState(
activeUserId = DEFAULT_USER_ID,
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "Active User",
email = "active@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",
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(
userId = DEFAULT_USER_ID,
email = DEFAULT_USER_STATE.activeAccount.email,
avatarColorHex = DEFAULT_USER_STATE.activeAccount.avatarColorHex,
isItemRestricted = false,
initials = DEFAULT_USER_STATE.activeAccount.initials,
)
private val DEFAULT_STATE = VerifyPasswordState(
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM,
input = "",
dialog = null,
)

View File

@@ -0,0 +1,577 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.createMockPolicy
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
import io.mockk.awaits
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
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
class VerifyPasswordViewModelTest : BaseViewModelTest() {
private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE)
private val authRepository = mockk<AuthRepository> {
every { userStateFlow } returns mutableUserStateFlow
every { activeUserId } returns DEFAULT_USER_ID
}
private val vaultRepository = mockk<VaultRepository> {
every { isVaultUnlocked(any()) } returns true
coEvery {
unlockVaultWithMasterPassword(masterPassword = any())
} returns VaultUnlockResult.Success
}
private val policyManager = mockk<PolicyManager> {
every { getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES) } returns listOf(
createMockPolicy(
number = 1,
organizationId = DEFAULT_ORGANIZATION_ID,
isEnabled = false,
),
)
}
@BeforeEach
fun setUp() {
mockkStatic(
SavedStateHandle::toVerifyPasswordArgs,
)
}
@AfterEach
fun tearDown() {
unmockkStatic(
SavedStateHandle::toVerifyPasswordArgs,
)
}
@Test
fun `initial state should be correct when account is not restricted`() = runTest {
createViewModel()
.also {
assertEquals(
VerifyPasswordState(
AccountSelectionListItem(
userId = DEFAULT_USER_ID,
email = DEFAULT_USER_STATE.activeAccount.email,
avatarColorHex = DEFAULT_USER_STATE.activeAccount.avatarColorHex,
isItemRestricted = false,
initials = DEFAULT_USER_STATE.activeAccount.initials,
),
),
it.stateFlow.value,
)
}
}
@Test
fun `initial state should be correct when account has item restrictions`() = runTest {
every {
policyManager.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
} returns listOf(
createMockPolicy(
number = 1,
organizationId = DEFAULT_ORGANIZATION_ID,
isEnabled = true,
),
)
createViewModel()
.also {
assertEquals(
VerifyPasswordState(
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM
.copy(isItemRestricted = true),
),
it.stateFlow.value,
)
}
}
@Test
fun `NavigateBackClick should send NavigateBack event`() = runTest {
createViewModel().also {
it.trySendAction(VerifyPasswordAction.NavigateBackClick)
it.eventFlow.test {
assertEquals(
VerifyPasswordEvent.NavigateBack,
awaitItem(),
)
}
}
}
@Test
fun `UnlockClick with empty input should show error dialog`() = runTest {
createViewModel().also {
it.trySendAction(VerifyPasswordAction.UnlockClick)
it.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(
dialog = VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.validation_field_required.asText(
BitwardenString.master_password.asText(),
),
),
),
awaitItem(),
)
coVerify(exactly = 0) {
authRepository.activeUserId
authRepository.validatePassword(password = any())
authRepository.switchAccount(userId = any())
}
}
}
}
@Suppress("MaxLineLength")
@Test
fun `UnlockClick with non-empty input should show loading dialog, validate password and send validates password`() =
runTest {
val initialState = DEFAULT_STATE.copy(input = "mockInput")
coEvery { authRepository.validatePassword(password = "mockInput") } just awaits
createViewModel(state = initialState).also { viewModel ->
viewModel.trySendAction(VerifyPasswordAction.UnlockClick)
viewModel.stateFlow.test {
assertEquals(
initialState.copy(
dialog = VerifyPasswordState.DialogState.Loading(
message = BitwardenString.loading.asText(),
),
),
awaitItem(),
)
}
coVerify(exactly = 1) {
authRepository.activeUserId
authRepository.validatePassword(password = "mockInput")
}
coVerify(exactly = 0) {
authRepository.switchAccount(userId = any())
}
}
}
@Suppress("MaxLineLength")
@Test
fun `UnlockClick with non-empty input should show loading dialog, switch accounts, then validate password when selected account is not active and switch is successful`() =
runTest {
val initialState = DEFAULT_STATE.copy(
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM
.copy(userId = "otherUserId"),
input = "mockInput",
)
every {
authRepository.switchAccount("otherUserId")
} returns SwitchAccountResult.AccountSwitched
coEvery { authRepository.validatePassword(password = "mockInput") } just awaits
createViewModel(state = initialState).also { viewModel ->
viewModel.trySendAction(VerifyPasswordAction.UnlockClick)
viewModel.stateFlow.test {
assertEquals(
initialState.copy(
dialog = VerifyPasswordState.DialogState.Loading(
message = BitwardenString.loading.asText(),
),
),
awaitItem(),
)
}
coVerify {
authRepository.activeUserId
authRepository.switchAccount(userId = "otherUserId")
authRepository.validatePassword(password = "mockInput")
}
}
}
@Suppress("MaxLineLength")
@Test
fun `UnlockClick with non-empty input should show error dialog when switch account is unsuccessful`() =
runTest {
val initialState = DEFAULT_STATE.copy(
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM
.copy(userId = "otherUserId"),
input = "mockInput",
)
every {
authRepository.switchAccount("otherUserId")
} returns SwitchAccountResult.NoChange
coEvery { authRepository.validatePassword(password = "mockInput") } just awaits
createViewModel(state = initialState).also { viewModel ->
viewModel.stateFlow.test {
// Await initial state update
awaitItem()
viewModel.trySendAction(VerifyPasswordAction.UnlockClick)
coVerify {
authRepository.activeUserId
authRepository.switchAccount(userId = "otherUserId")
}
coVerify(exactly = 0) {
authRepository.validatePassword(password = any())
}
assertEquals(
initialState.copy(
dialog = VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
),
),
awaitItem(),
)
}
}
}
@Suppress("MaxLineLength")
@Test
fun `UnlockClick with non-empty input should show loading dialog, then unlock vault when vault is locked`() =
runTest {
val initialState = DEFAULT_STATE.copy(input = "mockInput")
every { vaultRepository.isVaultUnlocked(any()) } returns false
coEvery {
vaultRepository.unlockVaultWithMasterPassword(masterPassword = "mockInput")
} just awaits
createViewModel(state = initialState).also { viewModel ->
viewModel.trySendAction(VerifyPasswordAction.UnlockClick)
viewModel.stateFlow.test {
assertEquals(
initialState.copy(
dialog = VerifyPasswordState.DialogState.Loading(
message = BitwardenString.loading.asText(),
),
),
awaitItem(),
)
coVerify {
vaultRepository.unlockVaultWithMasterPassword(masterPassword = "mockInput")
}
}
}
}
@Test
fun `PasswordInputChangeReceive should update state`() = runTest {
createViewModel(state = DEFAULT_STATE).also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.PasswordInputChangeReceive("mockInput"),
)
assertEquals(
DEFAULT_STATE.copy(input = "mockInput"),
viewModel.stateFlow.value,
)
}
}
@Test
fun `DismissDialog should update state`() = runTest {
val initialState = DEFAULT_STATE.copy(
dialog = VerifyPasswordState.DialogState.Loading(
message = BitwardenString.loading.asText(),
),
)
createViewModel(state = initialState).also { viewModel ->
viewModel.trySendAction(VerifyPasswordAction.DismissDialog)
assertEquals(null, viewModel.stateFlow.value.dialog)
}
}
@Suppress("MaxLineLength")
@Test
fun `ValidatePasswordResultReceive should send PasswordVerified event when result is Success and isValid is true`() =
runTest {
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.ValidatePasswordResultReceive(
ValidatePasswordResult.Success(isValid = true),
),
)
viewModel.eventFlow.test {
assertEquals(
VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID),
awaitItem(),
)
}
}
}
@Suppress("MaxLineLength")
@Test
fun `ValidatePasswordResultReceive should show error dialog when result is Success and isValid is false`() =
runTest {
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.ValidatePasswordResultReceive(
ValidatePasswordResult.Success(isValid = false),
),
)
assertEquals(
VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.invalid_master_password.asText(),
error = null,
),
viewModel.stateFlow.value.dialog,
)
}
}
@Test
fun `ValidatePasswordResultReceive should show error dialog when result is Error`() = runTest {
val throwable = Throwable()
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.ValidatePasswordResultReceive(
ValidatePasswordResult.Error(error = throwable),
),
)
assertEquals(
VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
error = throwable,
),
viewModel.stateFlow.value.dialog,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `UnlockVaultResultReceive should send PasswordVerified event when vault unlock result is Success`() =
runTest {
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
VaultUnlockResult.Success,
),
)
viewModel.eventFlow.test {
assertEquals(
VerifyPasswordEvent.PasswordVerified(DEFAULT_USER_ID),
awaitItem(),
)
}
}
}
@Test
fun `UnlockVaultResultReceive should show error dialog when vault unlock result is Error`() =
runTest {
val throwable = Throwable()
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
VaultUnlockResult.GenericError(error = throwable),
),
)
assertEquals(
DEFAULT_STATE.copy(
dialog = VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
error = throwable,
),
),
viewModel.stateFlow.value,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `UnlockVaultResultReceive should show error dialog when vault unlock result is AuthenticationError`() =
runTest {
val throwable = Throwable()
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
VaultUnlockResult.AuthenticationError(error = throwable),
),
)
assertEquals(
DEFAULT_STATE.copy(
dialog = VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.invalid_master_password.asText(),
error = throwable,
),
),
viewModel.stateFlow.value,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `UnlockVaultResultReceive should show error dialog when vault unlock result is BiometricDecodingError`() =
runTest {
val throwable = Throwable()
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
VaultUnlockResult.BiometricDecodingError(error = throwable),
),
)
assertEquals(
DEFAULT_STATE.copy(
dialog = VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
error = throwable,
),
),
viewModel.stateFlow.value,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `UnlockVaultResultReceive should show error dialog when vault unlock result is InvalidStateError`() =
runTest {
val throwable = Throwable()
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
VaultUnlockResult.InvalidStateError(error = throwable),
),
)
assertEquals(
DEFAULT_STATE.copy(
dialog = VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
error = throwable,
),
),
viewModel.stateFlow.value,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `UnlockVaultResultReceive should show error dialog when vault unlock result is GenericError`() =
runTest {
val throwable = Throwable()
createViewModel().also { viewModel ->
viewModel.trySendAction(
VerifyPasswordAction.Internal.UnlockVaultResultReceive(
VaultUnlockResult.GenericError(error = throwable),
),
)
assertEquals(
DEFAULT_STATE.copy(
dialog = VerifyPasswordState.DialogState.General(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
error = throwable,
),
),
viewModel.stateFlow.value,
)
}
}
private fun createViewModel(
state: VerifyPasswordState? = null,
userId: String = DEFAULT_USER_ID,
): VerifyPasswordViewModel = VerifyPasswordViewModel(
authRepository = authRepository,
vaultRepository = vaultRepository,
policyManager = policyManager,
savedStateHandle = SavedStateHandle().apply {
set("state", state)
set("userId", userId)
every {
toVerifyPasswordArgs()
} returns VerifyPasswordArgs(
userId = DEFAULT_USER_ID,
)
},
)
}
private const val DEFAULT_USER_ID: String = "activeUserId"
private const val DEFAULT_ORGANIZATION_ID: String = "activeOrganizationId"
private val DEFAULT_USER_STATE = UserState(
activeUserId = DEFAULT_USER_ID,
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "Active User",
email = "active@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",
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(
userId = DEFAULT_USER_ID,
email = DEFAULT_USER_STATE.activeAccount.email,
avatarColorHex = DEFAULT_USER_STATE.activeAccount.avatarColorHex,
isItemRestricted = false,
initials = DEFAULT_USER_STATE.activeAccount.initials,
)
private val DEFAULT_STATE = VerifyPasswordState(
accountSummaryListItem = DEFAULT_ACCOUNT_SELECTION_LIST_ITEM,
input = "",
dialog = null,
)

View File

@@ -1113,4 +1113,5 @@ Do you want to switch to this account?</string>
<string name="import_from_bitwarden">Import from Bitwarden</string>
<string name="select_account">Select account</string>
<string name="import_restricted_unable_to_import_credit_cards">Import restricted, unable to import cards from this account.</string>
<string name="verify_your_master_password">Verify your master password</string>
</resources>