Minor clean up for the Account Security Screen (#6076)

This commit is contained in:
David Perez 2025-10-24 10:55:50 -05:00 committed by GitHub
parent 7d7951d4ca
commit 51c23ec464
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 162 additions and 97 deletions

View File

@ -40,7 +40,6 @@ import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.badge.NotificationBadge
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.card.BitwardenActionCard
import com.bitwarden.ui.platform.components.card.BitwardenInfoCalloutCard
import com.bitwarden.ui.platform.components.card.actionCardExitAnimation
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
@ -52,6 +51,7 @@ import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.row.BitwardenExternalLinkRow
import com.bitwarden.ui.platform.components.row.BitwardenTextRow
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.support.BitwardenSupportingText
import com.bitwarden.ui.platform.components.toggle.BitwardenSwitch
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.composition.LocalIntentManager
@ -60,8 +60,6 @@ import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenUnlockWithBiometricsSwitch
@ -72,6 +70,7 @@ import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager
import com.x8bit.bitwarden.ui.platform.manager.utils.startApplicationDetailsSettingsActivity
import com.x8bit.bitwarden.ui.platform.util.displayLabel
import com.x8bit.bitwarden.ui.platform.util.minutes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.time.LocalTime
import javax.crypto.Cipher
@ -297,15 +296,8 @@ fun AccountSecurityScreen(
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(height = 8.dp))
SessionTimeoutPolicyRow(
vaultTimeoutPolicyMinutes = state.vaultTimeoutPolicyMinutes,
vaultTimeoutPolicyAction = state.vaultTimeoutPolicyAction,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
SessionTimeoutRow(
vaultTimeoutPolicyMinutes = state.vaultTimeoutPolicyMinutes,
vaultTimeoutPolicy = state.vaultTimeoutPolicy,
selectedVaultTimeoutType = state.vaultTimeout.type,
onVaultTimeoutTypeSelect = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.VaultTimeoutTypeSelect(it)) }
@ -317,7 +309,7 @@ fun AccountSecurityScreen(
)
(state.vaultTimeout as? VaultTimeout.Custom)?.let { customTimeout ->
SessionCustomTimeoutRow(
vaultTimeoutPolicyMinutes = state.vaultTimeoutPolicyMinutes,
vaultTimeoutPolicy = state.vaultTimeoutPolicy,
customVaultTimeout = customTimeout,
onCustomVaultTimeoutSelect = remember(viewModel) {
{
@ -331,13 +323,28 @@ fun AccountSecurityScreen(
.standardHorizontalMargin(),
)
}
state.sessionTimeoutSupportText?.let { text ->
BitwardenSupportingText(
text = text(),
cardStyle = CardStyle.Bottom,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
}
SessionTimeoutActionRow(
isEnabled = state.hasUnlockMechanism,
vaultTimeoutPolicyAction = state.vaultTimeoutPolicyAction,
isEnabled = state.isSessionTimeoutActionEnabled,
selectedVaultTimeoutAction = state.vaultTimeoutAction,
onVaultTimeoutActionSelect = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.VaultTimeoutActionSelect(it)) }
},
supportingText = state.sessionTimeoutActionSupportingText?.invoke(),
cardStyle = if (state.sessionTimeoutSupportText == null) {
CardStyle.Bottom
} else {
CardStyle.Full
},
modifier = Modifier
.testTag("VaultTimeoutActionChooser")
.fillMaxWidth()
@ -471,57 +478,16 @@ private fun AccountSecurityDialogs(
}
}
@Composable
private fun SessionTimeoutPolicyRow(
vaultTimeoutPolicyMinutes: Int?,
vaultTimeoutPolicyAction: PolicyInformation.VaultTimeout.Action?,
modifier: Modifier = Modifier,
) {
// Show the policy warning if applicable.
if (vaultTimeoutPolicyMinutes != null || vaultTimeoutPolicyAction != null) {
// Calculate the hours and minutes to show in the policy label.
val hours = vaultTimeoutPolicyMinutes?.floorDiv(MINUTES_PER_HOUR)
val minutes = vaultTimeoutPolicyMinutes?.mod(MINUTES_PER_HOUR)
// Get the localized version of the action.
val action = when (vaultTimeoutPolicyAction) {
PolicyInformation.VaultTimeout.Action.LOCK -> BitwardenString.lock.asText()
PolicyInformation.VaultTimeout.Action.LOGOUT -> BitwardenString.log_out.asText()
null -> BitwardenString.log_out.asText()
}
val policyText = if (hours == null || minutes == null) {
BitwardenString.vault_timeout_action_policy_in_effect.asText(action)
} else if (vaultTimeoutPolicyAction == null) {
BitwardenString.vault_timeout_policy_in_effect.asText(hours, minutes)
} else {
BitwardenString.vault_timeout_policy_with_action_in_effect.asText(
hours,
minutes,
action,
)
}
BitwardenInfoCalloutCard(
text = policyText(),
modifier = modifier,
)
Spacer(modifier = Modifier.height(height = 8.dp))
}
}
@Composable
private fun SessionTimeoutRow(
vaultTimeoutPolicyMinutes: Int?,
vaultTimeoutPolicy: VaultTimeoutPolicy?,
selectedVaultTimeoutType: VaultTimeout.Type,
onVaultTimeoutTypeSelect: (VaultTimeout.Type) -> Unit,
modifier: Modifier = Modifier,
resources: Resources = LocalResources.current,
) {
var shouldShowNeverTimeoutConfirmationDialog by remember { mutableStateOf(false) }
val vaultTimeoutOptions = VaultTimeout.Type
.entries
.filter { it.minutes <= (vaultTimeoutPolicyMinutes ?: Int.MAX_VALUE) }
val vaultTimeoutOptions = rememberSessionTimeoutOptions(vaultTimeoutPolicy)
BitwardenMultiSelectButton(
label = stringResource(id = BitwardenString.session_timeout),
options = vaultTimeoutOptions.map { it.displayLabel() }.toImmutableList(),
@ -557,10 +523,9 @@ private fun SessionTimeoutRow(
}
}
@Suppress("LongMethod")
@Composable
private fun SessionCustomTimeoutRow(
vaultTimeoutPolicyMinutes: Int?,
vaultTimeoutPolicy: VaultTimeoutPolicy?,
customVaultTimeout: VaultTimeout.Custom,
onCustomVaultTimeoutSelect: (VaultTimeout.Custom) -> Unit,
modifier: Modifier = Modifier,
@ -574,7 +539,6 @@ private fun SessionCustomTimeoutRow(
cardStyle = CardStyle.Middle(),
modifier = modifier,
) {
Text(
text = LocalTime
.ofSecondOfDay(vaultTimeoutInMinutes * MINUTES_PER_HOUR.toLong())
@ -592,8 +556,8 @@ private fun SessionCustomTimeoutRow(
shouldShowTimePickerDialog = false
val totalMinutes = (hour * MINUTES_PER_HOUR) + minute
if (vaultTimeoutPolicyMinutes != null &&
totalMinutes > vaultTimeoutPolicyMinutes
if (vaultTimeoutPolicy?.minutes != null &&
totalMinutes > vaultTimeoutPolicy.minutes
) {
shouldShowViolatesPoliciesDialog = true
} else {
@ -615,7 +579,7 @@ private fun SessionCustomTimeoutRow(
message = stringResource(id = BitwardenString.vault_timeout_to_large),
onDismissRequest = {
shouldShowViolatesPoliciesDialog = false
vaultTimeoutPolicyMinutes?.let {
vaultTimeoutPolicy?.minutes?.let {
onCustomVaultTimeoutSelect(
VaultTimeout.Custom(
vaultTimeoutInMinutes = it,
@ -630,9 +594,10 @@ private fun SessionCustomTimeoutRow(
@Composable
private fun SessionTimeoutActionRow(
isEnabled: Boolean,
vaultTimeoutPolicyAction: PolicyInformation.VaultTimeout.Action?,
selectedVaultTimeoutAction: VaultTimeoutAction,
onVaultTimeoutActionSelect: (VaultTimeoutAction) -> Unit,
supportingText: String?,
cardStyle: CardStyle,
modifier: Modifier = Modifier,
resources: Resources = LocalResources.current,
) {
@ -643,8 +608,6 @@ private fun SessionTimeoutActionRow(
options = VaultTimeoutAction.entries.map { it.displayLabel() }.toImmutableList(),
selectedOption = selectedVaultTimeoutAction.displayLabel(),
onOptionSelected = { action ->
// The option is not selectable if there's a policy in place.
if (vaultTimeoutPolicyAction != null) return@BitwardenMultiSelectButton
val selectedAction = VaultTimeoutAction.entries.first {
it.displayLabel.toString(resources) == action
}
@ -654,12 +617,9 @@ private fun SessionTimeoutActionRow(
onVaultTimeoutActionSelect(selectedAction)
}
},
supportingText = stringResource(
id = BitwardenString.set_up_an_unlock_option_to_change_your_vault_timeout_action,
)
.takeUnless { isEnabled },
supportingText = supportingText,
textFieldTestTag = "SessionTimeoutActionStatusLabel",
cardStyle = CardStyle.Bottom,
cardStyle = cardStyle,
modifier = modifier,
)
@ -760,3 +720,18 @@ private fun FingerPrintPhraseDialog(
textContentColor = BitwardenTheme.colorScheme.text.primary,
)
}
@Composable
private fun rememberSessionTimeoutOptions(
vaultTimeoutPolicy: VaultTimeoutPolicy?,
): ImmutableList<VaultTimeout.Type> = remember(vaultTimeoutPolicy) {
VaultTimeout.Type
.entries
.filter { timeoutType ->
vaultTimeoutPolicy
?.minutes
?.let { minutes -> timeoutType.minutes <= minutes }
?: true
}
.toImmutableList()
}

View File

@ -8,8 +8,10 @@ import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.resource.BitwardenPlurals
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asPluralsText
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
@ -37,6 +39,7 @@ import javax.crypto.Cipher
import javax.inject.Inject
private const val KEY_STATE = "state"
private const val MINUTES_PER_HOUR = 60
/**
* View model for the account security screen.
@ -75,8 +78,7 @@ class AccountSecurityViewModel @Inject constructor(
userId = userId,
vaultTimeout = settingsRepository.vaultTimeout,
vaultTimeoutAction = settingsRepository.vaultTimeoutAction,
vaultTimeoutPolicyMinutes = null,
vaultTimeoutPolicyAction = null,
vaultTimeoutPolicy = null,
shouldShowUnlockActionCard = false,
removeUnlockWithPinPolicyEnabled = false,
)
@ -476,10 +478,15 @@ class AccountSecurityViewModel @Inject constructor(
// The vault timeout policy can only be implemented in organizations that have
// the single organization policy, meaning that if this is enabled, the user is
// only in one organization and hence there is only one result in the list.
val vaultTimeoutPolicy = action.vaultTimeoutPolicies?.firstOrNull()
mutableStateFlow.update {
it.copy(
vaultTimeoutPolicyMinutes = action.vaultTimeoutPolicies?.firstOrNull()?.minutes,
vaultTimeoutPolicyAction = action.vaultTimeoutPolicies?.firstOrNull()?.action,
vaultTimeoutPolicy = vaultTimeoutPolicy?.let { policy ->
VaultTimeoutPolicy(
minutes = policy.minutes,
action = policy.action,
)
},
)
}
}
@ -518,8 +525,7 @@ data class AccountSecurityState(
val userId: String,
val vaultTimeout: VaultTimeout,
val vaultTimeoutAction: VaultTimeoutAction,
val vaultTimeoutPolicyMinutes: Int?,
val vaultTimeoutPolicyAction: PolicyInformation.VaultTimeout.Action?,
val vaultTimeoutPolicy: VaultTimeoutPolicy?,
val shouldShowUnlockActionCard: Boolean,
val removeUnlockWithPinPolicyEnabled: Boolean,
) : Parcelable {
@ -530,8 +536,74 @@ data class AccountSecurityState(
get() = isUnlockWithPasswordEnabled ||
isUnlockWithPinEnabled ||
isUnlockWithBiometricsEnabled
/**
* Indicates that the vault timeout action is enabled.
*/
val isSessionTimeoutActionEnabled: Boolean
get() = hasUnlockMechanism && vaultTimeoutPolicy?.action == null
/**
* The text to display for the session timeout.
*/
val sessionTimeoutSupportText: Text?
get() = vaultTimeoutPolicy?.let { policy ->
// Calculate the hours and minutes to show in the policy label.
val hours = policy.minutes?.floorDiv(MINUTES_PER_HOUR).takeUnless { it == 0 }
val minutes = policy.minutes?.mod(MINUTES_PER_HOUR).takeUnless { it == 0 }
if (hours != null && minutes != null) {
if (hours == 1 && minutes == 1) {
BitwardenString
.vault_timeout_policy_in_effect_no_plural
.asText(hours, minutes)
} else if (hours == 1) {
BitwardenString
.vault_timeout_policy_in_effect_minutes_plural
.asText(hours, minutes)
} else if (minutes == 1) {
BitwardenString
.vault_timeout_policy_in_effect_hours_plural
.asText(hours, minutes)
} else {
BitwardenString
.vault_timeout_policy_in_effect_both_plural
.asText(hours, minutes)
}
} else if (hours != null) {
BitwardenPlurals
.vault_timeout_policy_in_effect_hours
.asPluralsText(hours, hours)
} else if (minutes != null) {
BitwardenPlurals
.vault_timeout_policy_in_effect_minutes
.asPluralsText(minutes, minutes)
} else {
null
}
}
/**
* The text to display for the session timeout action.
*/
val sessionTimeoutActionSupportingText: Text?
get() = if (vaultTimeoutPolicy?.action != null) {
BitwardenString.this_setting_is_managed_by_your_organization.asText()
} else if (!hasUnlockMechanism) {
BitwardenString.set_up_an_unlock_option_to_change_your_vault_timeout_action.asText()
} else {
null
}
}
/**
* Models the vault timeout policy.
*/
@Parcelize
data class VaultTimeoutPolicy(
val minutes: Int?,
val action: PolicyInformation.VaultTimeout.Action?,
) : Parcelable
/**
* Representation of the dialogs that can be displayed on account security screen.
*/

View File

@ -590,11 +590,14 @@ class AccountSecurityScreenTest : BitwardenComposeTest() {
fun `session timeout policy warning should update according to state`() {
mutableStateFlow.update {
it.copy(
vaultTimeoutPolicyMinutes = 100,
vaultTimeoutPolicy = VaultTimeoutPolicy(
minutes = 100,
action = null,
),
)
}
val timeOnlyText = "Your organization policies have set your maximum allowed " +
"vault timeout to 1 hour(s) and 40 minute(s)."
val timeOnlyText = "Your organization has set the maximum session timeout " +
"to 1 hour and 40 minutes."
composeTestRule
.onNodeWithText(timeOnlyText)
.performScrollTo()
@ -602,15 +605,14 @@ class AccountSecurityScreenTest : BitwardenComposeTest() {
mutableStateFlow.update {
it.copy(
vaultTimeoutPolicyMinutes = 100,
vaultTimeoutPolicyAction = PolicyInformation.VaultTimeout.Action.LOCK,
vaultTimeoutPolicy = VaultTimeoutPolicy(
minutes = 100,
action = PolicyInformation.VaultTimeout.Action.LOCK,
),
)
}
val bothText = "Your organization policies are affecting your vault timeout. " +
"Maximum allowed vault timeout is 1 hour(s) and 40 minute(s). Your vault " +
"timeout action is set to Lock."
composeTestRule
.onNodeWithText(bothText)
.onNodeWithText(text = "This setting is managed by your organization.")
.performScrollTo()
.assertIsDisplayed()
}
@ -692,7 +694,10 @@ class AccountSecurityScreenTest : BitwardenComposeTest() {
mutableStateFlow.update {
it.copy(
vaultTimeoutPolicyMinutes = 100,
vaultTimeoutPolicy = VaultTimeoutPolicy(
minutes = 100,
action = null,
),
)
}
@ -1000,7 +1005,10 @@ class AccountSecurityScreenTest : BitwardenComposeTest() {
mutableStateFlow.update {
it.copy(
vaultTimeout = VaultTimeout.Custom(vaultTimeoutInMinutes = 123),
vaultTimeoutPolicyMinutes = 100,
vaultTimeoutPolicy = VaultTimeoutPolicy(
minutes = 100,
action = null,
),
)
}
composeTestRule
@ -1606,8 +1614,7 @@ private val DEFAULT_STATE = AccountSecurityState(
shouldShowEnableAuthenticatorSync = false,
vaultTimeout = VaultTimeout.ThirtyMinutes,
vaultTimeoutAction = VaultTimeoutAction.LOCK,
vaultTimeoutPolicyMinutes = null,
vaultTimeoutPolicyAction = null,
vaultTimeoutPolicy = null,
shouldShowUnlockActionCard = false,
removeUnlockWithPinPolicyEnabled = false,
)

View File

@ -158,8 +158,10 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(
vaultTimeoutPolicyMinutes = 10,
vaultTimeoutPolicyAction = PolicyInformation.VaultTimeout.Action.LOCK,
vaultTimeoutPolicy = VaultTimeoutPolicy(
minutes = 10,
action = PolicyInformation.VaultTimeout.Action.LOCK,
),
),
awaitItem(),
)
@ -991,8 +993,7 @@ private val DEFAULT_STATE: AccountSecurityState = AccountSecurityState(
userId = DEFAULT_USER_ID,
vaultTimeout = VaultTimeout.ThirtyMinutes,
vaultTimeoutAction = VaultTimeoutAction.LOCK,
vaultTimeoutPolicyMinutes = null,
vaultTimeoutPolicyAction = null,
vaultTimeoutPolicy = null,
shouldShowEnableAuthenticatorSync = false,
shouldShowUnlockActionCard = false,
removeUnlockWithPinPolicyEnabled = false,

View File

@ -497,9 +497,19 @@ Scanning will happen automatically.</string>
<string name="fido2_authenticate_web_authn">Authenticate WebAuthn</string>
<string name="fido2_return_to_app">Return to app</string>
<string name="reset_password_auto_enroll_invite_warning">This organization has an enterprise policy that will automatically enroll you in password reset. Enrollment will allow organization administrators to change your master password.</string>
<string name="vault_timeout_policy_in_effect">Your organization policies have set your maximum allowed vault timeout to %1$s hour(s) and %2$s minute(s).</string>
<string name="vault_timeout_policy_with_action_in_effect">Your organization policies are affecting your vault timeout. Maximum allowed vault timeout is %1$s hour(s) and %2$s minute(s). Your vault timeout action is set to %3$s.</string>
<string name="vault_timeout_action_policy_in_effect">Your organization policies have set your vault timeout action to %1$s.</string>
<string name="vault_timeout_policy_in_effect_both_plural">Your organization has set the maximum session timeout to %1$d hours and %2$d minutes.</string>
<string name="vault_timeout_policy_in_effect_hours_plural">Your organization has set the maximum session timeout to %1$d hours and %2$d minute.</string>
<string name="vault_timeout_policy_in_effect_minutes_plural">Your organization has set the maximum session timeout to %1$d hour and %2$d minutes.</string>
<string name="vault_timeout_policy_in_effect_no_plural">Your organization has set the maximum session timeout to %1$d hour and %2$d minute.</string>
<plurals name="vault_timeout_policy_in_effect_hours">
<item quantity="one">Your organization has set the maximum session timeout to %1$d hour.</item>
<item quantity="other">Your organization has set the maximum session timeout to %1$d hours.</item>
</plurals>
<plurals name="vault_timeout_policy_in_effect_minutes">
<item quantity="one">Your organization has set the maximum session timeout to %1$d minute.</item>
<item quantity="other">Your organization has set the maximum session timeout to %1$d minutes.</item>
</plurals>
<string name="this_setting_is_managed_by_your_organization">This setting is managed by your organization.</string>
<string name="vault_timeout_to_large">Your vault timeout exceeds the restrictions set by your organization.</string>
<string name="disable_personal_vault_export_policy_in_effect">One or more organization policies prevents your from exporting your individual vault.</string>
<string name="add_account">Add account</string>