diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt index ba7bb60d173..850f4f618c4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditLoginItems.kt @@ -134,6 +134,7 @@ fun LazyListScope.vaultAddEditLoginItems( TotpRow( totpKey = loginState.totp, canViewTotp = loginState.canViewPassword, + isAuthenticatorKeyPremiumGated = loginState.isAuthenticatorKeyPremiumGated, loginItemTypeHandlers = loginItemTypeHandlers, onTotpSetupClick = onTotpSetupClick, modifier = Modifier.fillMaxWidth(), @@ -362,6 +363,7 @@ private fun CoachMarkScope.PasswordRow( private fun TotpRow( totpKey: String?, canViewTotp: Boolean, + isAuthenticatorKeyPremiumGated: Boolean, loginItemTypeHandlers: VaultAddEditLoginTypeHandlers, onTotpSetupClick: () -> Unit, modifier: Modifier = Modifier, @@ -393,18 +395,31 @@ private fun TotpRow( ), supportingContentPadding = PaddingValues(), supportingContent = { - BitwardenClickableText( - label = stringResource(id = BitwardenString.set_up_authenticator_key), - onClick = onTotpSetupClick, - leadingIcon = painterResource(id = BitwardenDrawable.ic_camera_small), - style = BitwardenTheme.typography.labelMedium, - innerPadding = PaddingValues(all = 16.dp), - isEnabled = canViewTotp, - cornerSize = 0.dp, - modifier = Modifier - .fillMaxWidth() - .testTag("SetupTotpButton"), - ) + if (isAuthenticatorKeyPremiumGated) { + BitwardenClickableText( + label = stringResource(id = BitwardenString.premium_subscription_required), + onClick = loginItemTypeHandlers.onTotpRequiresPremiumClick, + style = BitwardenTheme.typography.labelMedium, + innerPadding = PaddingValues(all = 16.dp), + cornerSize = 0.dp, + modifier = Modifier + .fillMaxWidth() + .testTag("LoginTotpPremiumRequired"), + ) + } else { + BitwardenClickableText( + label = stringResource(id = BitwardenString.set_up_authenticator_key), + onClick = onTotpSetupClick, + leadingIcon = painterResource(id = BitwardenDrawable.ic_camera_small), + style = BitwardenTheme.typography.labelMedium, + innerPadding = PaddingValues(all = 16.dp), + isEnabled = canViewTotp, + cornerSize = 0.dp, + modifier = Modifier + .fillMaxWidth() + .testTag("SetupTotpButton"), + ) + } }, passwordFieldTestTag = "LoginTotpEntry", cardStyle = CardStyle.Full, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt index d69b5f3890a..6523e5140b5 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt @@ -299,6 +299,9 @@ fun VaultAddEditScreen( onUpgradeToPremiumClick = { viewModel.trySendAction(VaultAddEditAction.Common.UpgradeToPremiumClick) }, + onNavigateToPlanClick = { + viewModel.trySendAction(VaultAddEditAction.Common.NavigateToPlanClick) + }, onCameraPermissionSettingsClick = { viewModel.trySendAction( VaultAddEditAction.Common.CameraPermissionSettingsClick, @@ -495,6 +498,7 @@ private fun VaultAddEditItemDialogs( onRetryPinSetUpFido2Verification: () -> Unit, onDismissFido2Verification: () -> Unit, onUpgradeToPremiumClick: () -> Unit, + onNavigateToPlanClick: () -> Unit, onCameraPermissionSettingsClick: () -> Unit, ) { when (dialogState) { @@ -504,7 +508,21 @@ private fun VaultAddEditItemDialogs( message = stringResource(id = BitwardenString.archiving_items_is_a_premium_feature), confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium), dismissButtonText = stringResource(id = BitwardenString.cancel), - onConfirmClick = onUpgradeToPremiumClick, + onConfirmClick = onNavigateToPlanClick, + onDismissClick = onDismissRequest, + onDismissRequest = onDismissRequest, + ) + } + + is VaultAddEditState.DialogState.TotpRequiresPremium -> { + BitwardenTwoButtonDialog( + title = stringResource(id = BitwardenString.premium_subscription_required), + message = stringResource( + id = BitwardenString.authenticator_key_is_a_premium_feature, + ), + confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium), + dismissButtonText = stringResource(id = BitwardenString.cancel), + onConfirmClick = onNavigateToPlanClick, onDismissClick = onDismissRequest, onDismissRequest = onDismissRequest, ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt index fbbf1debe6a..ec88416e484 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt @@ -79,6 +79,7 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.util.appendFolderAndOwnerDat import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toAvailableFolders import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toDefaultAddTypeContent import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toItemType +import com.x8bit.bitwarden.ui.vault.feature.addedit.util.withAuthenticatorKeyPremiumGate import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toViewState import com.x8bit.bitwarden.ui.vault.feature.addedit.util.validateCipherOrReturnErrorState import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.messageResourceId @@ -187,6 +188,9 @@ class VaultAddEditViewModel @Inject constructor( null } + val hasPremium = + authRepository.userStateFlow.value?.activeAccount?.isPremium == true + VaultAddEditState( isCardScannerEnabled = featureFlagManager .getFeatureFlag(FlagKey.CardScanner) && !buildInfoManager.isFdroid, @@ -194,7 +198,7 @@ class VaultAddEditViewModel @Inject constructor( cipherType = vaultCipherType, viewState = when (vaultAddEditType) { is VaultAddEditType.AddItem -> { - autofillSelectionData + (autofillSelectionData ?.toDefaultAddTypeContent(isIndividualVaultDisabled) ?: autofillSaveItem?.toDefaultAddTypeContent(isIndividualVaultDisabled) ?: providerCreateCredentialRequest?.toDefaultAddTypeContent( @@ -209,7 +213,8 @@ class VaultAddEditViewModel @Inject constructor( ), isIndividualVaultDisabled = isIndividualVaultDisabled, type = vaultCipherType.toItemType(), - ) + )) + .withAuthenticatorKeyPremiumGate(isPremium = hasPremium) } is VaultAddEditType.EditItem -> VaultAddEditState.ViewState.Loading @@ -226,7 +231,7 @@ class VaultAddEditViewModel @Inject constructor( shouldShowCoachMarkTour = false, shouldClearSpecialCircumstance = autofillSelectionData == null, defaultUriMatchType = settingsRepository.defaultUriMatchType, - hasPremium = authRepository.userStateFlow.value?.activeAccount?.isPremium == true, + hasPremium = hasPremium, ) }, ) { @@ -352,6 +357,7 @@ class VaultAddEditViewModel @Inject constructor( is VaultAddEditAction.Common.ArchiveClick -> handleArchiveClick() is VaultAddEditAction.Common.UnarchiveClick -> handleUnarchiveClick() VaultAddEditAction.Common.UpgradeToPremiumClick -> handleUpgradeToPremiumClick() + VaultAddEditAction.Common.NavigateToPlanClick -> handleNavigateToPlanClick() is VaultAddEditAction.Common.ConfirmDeleteClick -> handleConfirmDeleteClick() is VaultAddEditAction.Common.CloseClick -> handleCloseClick() is VaultAddEditAction.Common.DismissDialog -> handleDismissDialog() @@ -687,6 +693,11 @@ class VaultAddEditViewModel @Inject constructor( } } + private fun handleNavigateToPlanClick() { + mutableStateFlow.update { it.copy(dialog = null) } + sendEvent(VaultAddEditEvent.NavigateToPlanModal) + } + private fun handleConfirmDeleteClick() { mutableStateFlow.update { it.copy( @@ -1213,6 +1224,16 @@ class VaultAddEditViewModel @Inject constructor( VaultAddEditAction.ItemType.LoginType.LearnMoreClick -> { handleLearnMoreClick() } + + VaultAddEditAction.ItemType.LoginType.TotpRequiresPremiumClick -> { + handleTotpRequiresPremiumClick() + } + } + } + + private fun handleTotpRequiresPremiumClick() { + mutableStateFlow.update { + it.copy(dialog = VaultAddEditState.DialogState.TotpRequiresPremium) } } @@ -2998,6 +3019,10 @@ data class VaultAddEditState( * @property canViewPassword Indicates whether the current user can view and copy * passwords associated with the login item. * @property canEditItem Indicates whether the current user can edit the login item. + * @property isAuthenticatorKeyPremiumGated `true` when the active user lacks the + * entitlement required to use the authenticator key (TOTP) for this cipher — + * neither Premium nor an `organizationUseTotp` grant. Used solely to swap the + * authenticator key supporting content for a Premium upsell. * @property fido2CredentialCreationDateTime Date and time the FIDO 2 credential was * created. */ @@ -3008,6 +3033,7 @@ data class VaultAddEditState( val totp: String? = null, val canViewPassword: Boolean = true, val canEditItem: Boolean = true, + val isAuthenticatorKeyPremiumGated: Boolean = false, val uriList: List = listOf( UriItem( id = UUID.randomUUID().toString(), @@ -3369,6 +3395,12 @@ data class VaultAddEditState( */ data object ArchiveRequiresPremium : DialogState() + /** + * Displays a dialog to the user indicating that the authenticator key (TOTP) requires a + * Premium account. + */ + data object TotpRequiresPremium : DialogState() + /** * Displays a generic dialog to the user. */ @@ -3658,6 +3690,11 @@ sealed class VaultAddEditAction { */ data object UpgradeToPremiumClick : Common() + /** + * The user has clicked an upgrade CTA that should always navigate to the Plan screen. + */ + data object NavigateToPlanClick : Common() + /** * The user has confirmed to deleted the cipher. */ @@ -3952,6 +3989,12 @@ sealed class VaultAddEditAction { * User has clicked the call to action on the learn more help link. */ data object LearnMoreClick : LoginType() + + /** + * The user has clicked the Premium subscription required CTA shown in place of the + * authenticator key when the active account lacks the Premium entitlement. + */ + data object TotpRequiresPremiumClick : LoginType() } /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditLoginTypeHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditLoginTypeHandlers.kt index 84099035d8a..c098c19bc43 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditLoginTypeHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditLoginTypeHandlers.kt @@ -28,6 +28,8 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.model.UriItem * is clicked. * @property onAuthenticatorHelpToolTipClick Handles the action when the authenticator help tooltip * is clicked. + * @property onTotpRequiresPremiumClick Handles the action when the Premium subscription required + * CTA is clicked in place of the authenticator key for non-Premium accounts. */ @Suppress("LongParameterList") data class VaultAddEditLoginTypeHandlers( @@ -47,6 +49,7 @@ data class VaultAddEditLoginTypeHandlers( val onStartLoginCoachMarkTour: () -> Unit, val onDismissLearnAboutLoginsCard: () -> Unit, val onAuthenticatorHelpToolTipClick: () -> Unit, + val onTotpRequiresPremiumClick: () -> Unit, val onLearnMoreClick: () -> Unit, ) { @Suppress("UndocumentedPublicClass") @@ -112,6 +115,11 @@ data class VaultAddEditLoginTypeHandlers( VaultAddEditAction.ItemType.LoginType.AuthenticatorHelpToolTipClick, ) }, + onTotpRequiresPremiumClick = { + viewModel.trySendAction( + VaultAddEditAction.ItemType.LoginType.TotpRequiresPremiumClick, + ) + }, onCopyTotpKeyClick = { totpKey -> viewModel.trySendAction( VaultAddEditAction.ItemType.LoginType.CopyTotpKeyClick( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt index 643b69caad5..07b1dbd2f07 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt @@ -57,6 +57,7 @@ fun CipherView.toViewState( totp = totpData?.uri ?: login?.totp, canViewPassword = this.viewPassword, canEditItem = this.edit, + isAuthenticatorKeyPremiumGated = !isPremium && !this.organizationUseTotp, uriList = login?.uris.toUriItems(), fido2CredentialCreationDateTime = login ?.fido2Credentials diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/VaultAddEditExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/VaultAddEditExtensions.kt index ec263fbf129..76d602b573f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/VaultAddEditExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/VaultAddEditExtensions.kt @@ -36,3 +36,19 @@ fun VaultItemCipherType.toItemType(): VaultAddEditState.ViewState.Content.ItemTy VaultAddEditState.ViewState.Content.ItemType.Passport() } } + +/** + * Returns a copy of the [VaultAddEditState.ViewState] with the authenticator key Premium gate + * applied to its Login content (if any). Used to seed the gate for Add mode, where the Login + * state is constructed by factories that have no premium context. Edit and Clone modes already + * set the gate via `CipherView.toViewState`, which additionally honors `organizationUseTotp`. + */ +fun VaultAddEditState.ViewState.withAuthenticatorKeyPremiumGate( + isPremium: Boolean, +): VaultAddEditState.ViewState { + val content = this as? VaultAddEditState.ViewState.Content ?: return this + val login = content.type as? VaultAddEditState.ViewState.Content.ItemType.Login ?: return this + return content.copy( + type = login.copy(isAuthenticatorKeyPremiumGated = !isPremium), + ) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt index dbe8ba0b773..222edff302e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt @@ -147,6 +147,8 @@ fun VaultItemLoginContent( onCopyTotpClick = vaultLoginItemTypeHandlers.onCopyTotpCodeClick, onAuthenticatorHelpToolTipClick = vaultLoginItemTypeHandlers .onAuthenticatorHelpToolTipClick, + onPremiumRequiredClick = vaultLoginItemTypeHandlers + .onTotpRequiresPremiumClick, modifier = Modifier .standardHorizontalMargin() .fillMaxWidth() @@ -285,12 +287,14 @@ private fun PasswordField( } } +@Suppress("LongMethod") @Composable private fun TotpField( totpCodeItemData: TotpCodeItemData, enabled: Boolean, onCopyTotpClick: () -> Unit, onAuthenticatorHelpToolTipClick: () -> Unit, + onPremiumRequiredClick: () -> Unit, modifier: Modifier = Modifier, ) { if (enabled) { @@ -333,7 +337,19 @@ private fun TotpField( contentDescription = stringResource(id = BitwardenString.authenticator_key_help), isExternalLink = true, ), - supportingText = stringResource(id = BitwardenString.premium_subscription_required), + supportingContentPadding = PaddingValues(), + supportingContent = { + BitwardenClickableText( + label = stringResource(id = BitwardenString.premium_subscription_required), + onClick = onPremiumRequiredClick, + style = BitwardenTheme.typography.labelMedium, + innerPadding = PaddingValues(all = 16.dp), + cornerSize = 0.dp, + modifier = Modifier + .fillMaxWidth() + .testTag("LoginTotpPremiumRequired"), + ) + }, enabled = false, singleLine = false, onValueChange = { }, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt index cc461bbb5cb..37ee077d61d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt @@ -156,6 +156,9 @@ fun VaultItemScreen( onUpgradeToPremiumClick = { viewModel.trySendAction(VaultItemAction.Common.UpgradeToPremiumClick) }, + onNavigateToPlanClick = { + viewModel.trySendAction(VaultItemAction.Common.NavigateToPlanClick) + }, ) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) @@ -301,6 +304,7 @@ fun VaultItemScreen( } } +@Suppress("LongMethod") @Composable private fun VaultItemDialogs( dialog: VaultItemState.DialogState?, @@ -309,6 +313,7 @@ private fun VaultItemDialogs( onConfirmCloneWithoutFido2Credential: () -> Unit, onConfirmRestoreAction: () -> Unit, onUpgradeToPremiumClick: () -> Unit, + onNavigateToPlanClick: () -> Unit, ) { when (dialog) { is VaultItemState.DialogState.ArchiveRequiresPremium -> { @@ -317,7 +322,21 @@ private fun VaultItemDialogs( message = stringResource(id = BitwardenString.archiving_items_is_a_premium_feature), confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium), dismissButtonText = stringResource(id = BitwardenString.cancel), - onConfirmClick = onUpgradeToPremiumClick, + onConfirmClick = onNavigateToPlanClick, + onDismissClick = onDismissRequest, + onDismissRequest = onDismissRequest, + ) + } + + is VaultItemState.DialogState.TotpRequiresPremium -> { + BitwardenTwoButtonDialog( + title = stringResource(id = BitwardenString.premium_subscription_required), + message = stringResource( + id = BitwardenString.authenticator_key_is_a_premium_feature, + ), + confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium), + dismissButtonText = stringResource(id = BitwardenString.cancel), + onConfirmClick = onNavigateToPlanClick, onDismissClick = onDismissRequest, onDismissRequest = onDismissRequest, ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt index 5f6d59af3af..e361222c7b3 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt @@ -304,6 +304,7 @@ class VaultItemViewModel @Inject constructor( VaultItemAction.Common.ArchiveClick -> handleArchiveClick() VaultItemAction.Common.UnarchiveClick -> handleUnarchiveClick() VaultItemAction.Common.UpgradeToPremiumClick -> handleUpgradeToPremiumClick() + VaultItemAction.Common.NavigateToPlanClick -> handleNavigateToPlanClick() } } @@ -616,6 +617,10 @@ class VaultItemViewModel @Inject constructor( is VaultItemAction.ItemType.Login.PasswordVisibilityClicked -> { handlePasswordVisibilityClicked(action) } + + is VaultItemAction.ItemType.Login.TotpRequiresPremiumClick -> { + handleTotpRequiresPremiumClick() + } } } @@ -627,6 +632,10 @@ class VaultItemViewModel @Inject constructor( ) } + private fun handleTotpRequiresPremiumClick() { + updateDialogState(VaultItemState.DialogState.TotpRequiresPremium) + } + private fun handleCheckForBreachClick() { onLoginContent { _, login -> val password = requireNotNull(login.passwordData?.password) @@ -755,6 +764,11 @@ class VaultItemViewModel @Inject constructor( } } + private fun handleNavigateToPlanClick() { + updateDialogState(dialog = null) + sendEvent(VaultItemEvent.NavigateToPlanModal) + } + private fun handlePasswordVisibilityClicked( action: VaultItemAction.ItemType.Login.PasswordVisibilityClicked, ) { @@ -2340,6 +2354,13 @@ data class VaultItemState( @Parcelize data object ArchiveRequiresPremium : DialogState() + /** + * Displays a dialog to the user indicating that the authenticator key (TOTP) requires a + * Premium account. + */ + @Parcelize + data object TotpRequiresPremium : DialogState() + /** * Displays a generic dialog to the user. */ @@ -2505,6 +2526,11 @@ sealed class VaultItemAction { */ data object UpgradeToPremiumClick : Common() + /** + * The user has clicked an upgrade CTA that should always navigate to the Plan screen. + */ + data object NavigateToPlanClick : Common() + /** * The user has clicked the close button. */ @@ -2684,6 +2710,12 @@ sealed class VaultItemAction { data class PasswordVisibilityClicked( val isVisible: Boolean, ) : Login() + + /** + * The user has clicked the Premium subscription required CTA shown in place of the + * authenticator key when the active account lacks the Premium entitlement. + */ + data object TotpRequiresPremiumClick : Login() } /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultLoginItemTypeHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultLoginItemTypeHandlers.kt index a6c37500ca3..82ca89f7cbe 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultLoginItemTypeHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultLoginItemTypeHandlers.kt @@ -13,6 +13,7 @@ data class VaultLoginItemTypeHandlers( val onCopyPasswordClick: () -> Unit, val onCopyTotpCodeClick: () -> Unit, val onAuthenticatorHelpToolTipClick: () -> Unit, + val onTotpRequiresPremiumClick: () -> Unit, val onCopyUriClick: (String) -> Unit, val onCopyUsernameClick: () -> Unit, val onLaunchUriClick: (String) -> Unit, @@ -42,6 +43,11 @@ data class VaultLoginItemTypeHandlers( VaultItemAction.ItemType.Login.AuthenticatorHelpToolTipClick, ) }, + onTotpRequiresPremiumClick = { + viewModel.trySendAction( + VaultItemAction.ItemType.Login.TotpRequiresPremiumClick, + ) + }, onCopyUriClick = { viewModel.trySendAction(VaultItemAction.ItemType.Login.CopyUriClick(it)) }, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt index 8517f57552d..e1e0ba63833 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt @@ -321,7 +321,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { @Suppress("MaxLineLength") @Test - fun `ArchiveRequiresPremium dialog on upgrade to Premium click should emit UpgradeToPremiumClick`() { + fun `ArchiveRequiresPremium dialog on upgrade to Premium click should emit NavigateToPlanClick`() { composeTestRule.assertNoDialogExists() mutableStateFlow.value = DEFAULT_STATE_LOGIN.copy( dialog = VaultAddEditState.DialogState.ArchiveRequiresPremium, @@ -333,7 +333,58 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { .performClick() verify(exactly = 1) { - viewModel.trySendAction(VaultAddEditAction.Common.UpgradeToPremiumClick) + viewModel.trySendAction(VaultAddEditAction.Common.NavigateToPlanClick) + } + } + + @Test + fun `TotpRequiresPremium dialog should display based on state`() { + composeTestRule.assertNoDialogExists() + mutableStateFlow.value = DEFAULT_STATE_LOGIN.copy( + dialog = VaultAddEditState.DialogState.TotpRequiresPremium, + ) + + composeTestRule + .onNodeWithText(text = "Premium subscription required") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Suppress("MaxLineLength") + @Test + fun `TotpRequiresPremium dialog on upgrade to Premium click should emit NavigateToPlanClick`() { + composeTestRule.assertNoDialogExists() + mutableStateFlow.value = DEFAULT_STATE_LOGIN.copy( + dialog = VaultAddEditState.DialogState.TotpRequiresPremium, + ) + + composeTestRule + .onNodeWithText(text = "Upgrade to Premium") + .assert(hasAnyAncestor(isDialog())) + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction(VaultAddEditAction.Common.NavigateToPlanClick) + } + } + + @Suppress("MaxLineLength") + @Test + fun `in ItemType_Login state, premium-gated TOTP supporting text click should send TotpRequiresPremiumClick`() { + mutableStateFlow.update { currentState -> + updateLoginType(currentState) { + copy(isAuthenticatorKeyPremiumGated = true) + } + } + + composeTestRule + .onNodeWithTextAfterScroll(text = "Premium subscription required") + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction( + VaultAddEditAction.ItemType.LoginType.TotpRequiresPremiumClick, + ) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index 0802d7355fb..ab843f3dbc2 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -585,6 +585,37 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { } } + @Test + fun `NavigateToPlanClick should emit NavigateToPlanModal regardless of in-app upgrade`() = + runTest { + every { premiumStateManager.isInAppUpgradeAvailable() } returns false + val viewModel = createAddVaultItemViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(VaultAddEditAction.Common.NavigateToPlanClick) + assertEquals( + VaultAddEditEvent.NavigateToPlanModal, + awaitItem(), + ) + } + } + + @Test + fun `NavigateToPlanClick should clear the active dialog`() { + val viewModel = createAddVaultItemViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = createVaultAddItemState( + dialogState = VaultAddEditState.DialogState.TotpRequiresPremium, + ), + vaultAddEditType = VaultAddEditType.AddItem, + vaultItemCipherType = VaultItemCipherType.LOGIN, + ), + ) + + viewModel.trySendAction(VaultAddEditAction.Common.NavigateToPlanClick) + + assertEquals(null, viewModel.stateFlow.value.dialog) + } + @Test fun `snackbar relay emission should send ShowSnackbar`() = runTest { val viewModel = createAddVaultItemViewModel() @@ -3295,6 +3326,18 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { } } + @Test + fun `TotpRequiresPremiumClick should show TotpRequiresPremium dialog`() { + viewModel.trySendAction( + VaultAddEditAction.ItemType.LoginType.TotpRequiresPremiumClick, + ) + + assertEquals( + VaultAddEditState.DialogState.TotpRequiresPremium, + viewModel.stateFlow.value.dialog, + ) + } + @Test fun `ClearTotpKeyClick call should clear the totp code`() { val viewModel = createAddVaultItemViewModel( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt index 0e924de077a..3f68dbbd002 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt @@ -222,6 +222,7 @@ class CipherViewExtensionsTest { ), totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", canViewPassword = false, + isAuthenticatorKeyPremiumGated = true, fido2CredentialCreationDateTime = BitwardenString.created_x.asText( "Oct 27, 2023, 12:00\u202FPM", ), @@ -284,6 +285,7 @@ class CipherViewExtensionsTest { ), totp = totp, canViewPassword = false, + isAuthenticatorKeyPremiumGated = true, fido2CredentialCreationDateTime = BitwardenString.created_x.asText( "Oct 27, 2023, 12:00\u202FPM", ), diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/VaultAddEditExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/VaultAddEditExtensionsTest.kt index 483d37337a2..7f7486975ca 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/VaultAddEditExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/VaultAddEditExtensionsTest.kt @@ -47,4 +47,70 @@ class VaultAddEditExtensionsTest { result, ) } + + @Test + fun `withAuthenticatorKeyPremiumGate gates Login when user lacks Premium`() { + val viewState = VaultAddEditState.ViewState.Content( + common = VaultAddEditState.ViewState.Content.Common(), + isIndividualVaultDisabled = false, + type = VaultAddEditState.ViewState.Content.ItemType.Login(), + ) + + val result = viewState.withAuthenticatorKeyPremiumGate(isPremium = false) + + assertEquals( + VaultAddEditState.ViewState.Content( + common = VaultAddEditState.ViewState.Content.Common(), + isIndividualVaultDisabled = false, + type = VaultAddEditState.ViewState.Content.ItemType.Login( + isAuthenticatorKeyPremiumGated = true, + ), + ), + result, + ) + } + + @Test + fun `withAuthenticatorKeyPremiumGate does not gate Login when user has Premium`() { + val viewState = VaultAddEditState.ViewState.Content( + common = VaultAddEditState.ViewState.Content.Common(), + isIndividualVaultDisabled = false, + type = VaultAddEditState.ViewState.Content.ItemType.Login(), + ) + + val result = viewState.withAuthenticatorKeyPremiumGate(isPremium = true) + + assertEquals( + VaultAddEditState.ViewState.Content( + common = VaultAddEditState.ViewState.Content.Common(), + isIndividualVaultDisabled = false, + type = VaultAddEditState.ViewState.Content.ItemType.Login( + isAuthenticatorKeyPremiumGated = false, + ), + ), + result, + ) + } + + @Test + fun `withAuthenticatorKeyPremiumGate returns input unchanged for non-Login content`() { + val viewState = VaultAddEditState.ViewState.Content( + common = VaultAddEditState.ViewState.Content.Common(), + isIndividualVaultDisabled = false, + type = VaultAddEditState.ViewState.Content.ItemType.Card(), + ) + + val result = viewState.withAuthenticatorKeyPremiumGate(isPremium = false) + + assertEquals(viewState, result) + } + + @Test + fun `withAuthenticatorKeyPremiumGate returns input unchanged for non-Content view state`() { + val viewState: VaultAddEditState.ViewState = VaultAddEditState.ViewState.Loading + + val result = viewState.withAuthenticatorKeyPremiumGate(isPremium = false) + + assertEquals(viewState, result) + } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index 673804b502e..f6f12979488 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -282,7 +282,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { @Suppress("MaxLineLength") @Test - fun `ArchiveRequiresPremium dialog on upgrade to Premium click should emit UpgradeToPremiumClick`() { + fun `ArchiveRequiresPremium dialog on upgrade to Premium click should emit NavigateToPlanClick`() { composeTestRule.assertNoDialogExists() mutableStateFlow.update { it.copy(dialog = VaultItemState.DialogState.ArchiveRequiresPremium) @@ -294,7 +294,60 @@ class VaultItemScreenTest : BitwardenComposeTest() { .performClick() verify(exactly = 1) { - viewModel.trySendAction(VaultItemAction.Common.UpgradeToPremiumClick) + viewModel.trySendAction(VaultItemAction.Common.NavigateToPlanClick) + } + } + + @Test + fun `TotpRequiresPremium dialog should display based on state`() { + composeTestRule.assertNoDialogExists() + mutableStateFlow.update { + it.copy(dialog = VaultItemState.DialogState.TotpRequiresPremium) + } + + composeTestRule + .onNodeWithText(text = "Premium subscription required") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Suppress("MaxLineLength") + @Test + fun `TotpRequiresPremium dialog on upgrade to Premium click should emit NavigateToPlanClick`() { + composeTestRule.assertNoDialogExists() + mutableStateFlow.update { + it.copy(dialog = VaultItemState.DialogState.TotpRequiresPremium) + } + + composeTestRule + .onNodeWithText(text = "Upgrade to Premium") + .assert(hasAnyAncestor(isDialog())) + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction(VaultItemAction.Common.NavigateToPlanClick) + } + } + + @Suppress("MaxLineLength") + @Test + fun `in login state, premium-gated TOTP supporting text click should send TotpRequiresPremiumClick`() { + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = DEFAULT_LOGIN_VIEW_STATE.copy( + type = DEFAULT_LOGIN.copy(canViewTotpCode = false), + ), + ) + } + + composeTestRule + .onNodeWithTextAfterScroll(text = "Premium subscription required") + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction( + VaultItemAction.ItemType.Login.TotpRequiresPremiumClick, + ) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index 32857a33446..85c9cf2e383 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -265,6 +265,33 @@ class VaultItemViewModelTest : BaseViewModelTest() { } } + @Test + fun `NavigateToPlanClick should emit NavigateToPlanModal regardless of in-app upgrade`() = + runTest { + every { premiumStateManager.isInAppUpgradeAvailable() } returns false + val viewModel = createViewModel(state = null) + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemAction.Common.NavigateToPlanClick) + assertEquals( + VaultItemEvent.NavigateToPlanModal, + awaitItem(), + ) + } + } + + @Test + fun `NavigateToPlanClick should clear the active dialog`() = runTest { + val viewModel = createViewModel( + state = DEFAULT_STATE.copy( + dialog = VaultItemState.DialogState.TotpRequiresPremium, + ), + ) + + viewModel.trySendAction(VaultItemAction.Common.NavigateToPlanClick) + + assertEquals(null, viewModel.stateFlow.value.dialog) + } + @Test fun `ArchiveClick without Premium should show ArchiveRequiresPremium dialog`() = runTest { mutableUserStateFlow.update { @@ -1970,6 +1997,18 @@ class VaultItemViewModelTest : BaseViewModelTest() { } } + @Test + fun `on TotpRequiresPremiumClick should show TotpRequiresPremium dialog`() { + viewModel.trySendAction( + action = VaultItemAction.ItemType.Login.TotpRequiresPremiumClick, + ) + + assertEquals( + VaultItemState.DialogState.TotpRequiresPremium, + viewModel.stateFlow.value.dialog, + ) + } + @Test fun `on PasswordHistoryClick should emit NavigateToPasswordHistory`() = runTest { every { diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 787c71699a1..cde665c6172 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -202,6 +202,7 @@ Scanning will happen automatically. Attachment deleted Attachments unavailable Attachments are a Premium feature. Your current plan does not include access to this feature. + Authenticator key (TOTP) is a Premium feature. Your current plan does not include access to this feature. Choose file File No file chosen