BIT-926: account security UI (#193)

This commit is contained in:
David Perez
2023-11-01 20:06:22 -05:00
committed by Álison Fernandes
parent 5dbe07a2cc
commit 3d28cd1100
4 changed files with 803 additions and 163 deletions

View File

@@ -1,8 +1,7 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -24,49 +22,69 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BitwardenExternalLinkRow
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionRow
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextRow
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch
/**
* Displays the account security screen.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountSecurityScreen(
onNavigateBack: () -> Unit,
viewModel: AccountSecurityViewModel = hiltViewModel(),
intentHandler: IntentHandler = IntentHandler(context = LocalContext.current),
) {
val state by viewModel.stateFlow.collectAsState()
val context = LocalContext.current
val resources = context.resources
EventsEffect(viewModel = viewModel) { event ->
when (event) {
AccountSecurityEvent.NavigateBack -> onNavigateBack.invoke()
AccountSecurityEvent.NavigateBack -> onNavigateBack()
is AccountSecurityEvent.ShowToast -> {
Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show()
}
}
}
if (state.shouldShowConfirmLogoutDialog) {
BitwardenTwoButtonDialog(
title = R.string.log_out.asText(),
message = R.string.logout_confirmation.asText(),
confirmButtonText = R.string.yes.asText(),
when (state.dialog) {
AccountSecurityDialog.ConfirmLogout -> ConfirmLogoutDialog(
onDismiss = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.DismissDialog) }
},
onConfirmClick = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.ConfirmLogoutClick) }
},
dismissButtonText = R.string.cancel.asText(),
onDismissClick = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.DismissDialog) }
},
)
AccountSecurityDialog.SessionTimeoutAction -> SessionTimeoutActionDialog(
selectedSessionTimeoutAction = state.sessionTimeoutAction,
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.DismissDialog) }
},
onActionSelect = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.SessionTimeoutActionSelect(it)) }
},
)
null -> Unit
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
@@ -92,33 +110,188 @@ fun AccountSecurityScreen(
.background(color = MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState()),
) {
Spacer(Modifier.height(8.dp))
AccountSecurityRow(
BitwardenListHeaderText(
label = stringResource(id = R.string.approve_login_requests),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
BitwardenWideSwitch(
label = stringResource(
id = R.string.use_this_device_to_approve_login_requests_made_from_other_devices,
),
isChecked = state.isApproveLoginRequestsEnabled,
onCheckedChange = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.LoginRequestToggle(it)) }
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
BitwardenTextRow(
text = R.string.pending_log_in_requests.asText(),
onClick = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.PendingLoginRequestsClick) }
},
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.unlock_options),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
BitwardenWideSwitch(
label = stringResource(
id = R.string.unlock_with,
stringResource(id = R.string.biometrics),
),
isChecked = state.isUnlockWithBiometricsEnabled,
onCheckedChange = remember(viewModel) {
{
viewModel.trySendAction(
AccountSecurityAction.UnlockWithBiometricToggle(it),
)
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
BitwardenWideSwitch(
label = stringResource(id = R.string.unlock_with_pin),
isChecked = state.isUnlockWithPinEnabled,
onCheckedChange = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.UnlockWithPinToggle(it)) }
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(Modifier.height(16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.session_timeout),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
BitwardenTextRow(
text = R.string.session_timeout.asText(),
onClick = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.SessionTimeoutClick) }
},
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = state.sessionTimeout(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
BitwardenTextRow(
text = R.string.session_timeout_action.asText(),
onClick = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.SessionTimeoutActionClick) }
},
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = state.sessionTimeoutAction.text(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.other),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
BitwardenTextRow(
text = R.string.account_fingerprint_phrase.asText(),
onClick = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.AccountFingerprintPhraseClick) }
},
modifier = Modifier.fillMaxWidth(),
)
BitwardenExternalLinkRow(
text = R.string.two_step_login.asText(),
onClick = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.TwoStepLoginClick) }
},
withDivider = false,
modifier = Modifier.fillMaxWidth(),
)
BitwardenExternalLinkRow(
text = R.string.change_master_password.asText(),
onClick = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.ChangeMasterPasswordClick) }
},
withDivider = false,
modifier = Modifier.fillMaxWidth(),
)
BitwardenTextRow(
text = R.string.lock_now.asText(),
onClick = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.LockNowClick) }
},
modifier = Modifier.fillMaxWidth(),
)
BitwardenTextRow(
text = R.string.log_out.asText(),
onClick = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.LogoutClick) }
},
modifier = Modifier.fillMaxWidth(),
)
BitwardenTextRow(
text = R.string.delete_account.asText(),
onClick = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.DeleteAccountClick) }
},
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@Composable
private fun AccountSecurityRow(
text: Text,
onClick: () -> Unit,
private fun ConfirmLogoutDialog(
onDismiss: () -> Unit,
onConfirmClick: () -> Unit,
) {
Text(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
onClick = onClick,
)
.padding(horizontal = 16.dp, vertical = 16.dp)
.fillMaxWidth(),
text = text(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
BitwardenTwoButtonDialog(
title = R.string.log_out.asText(),
message = R.string.logout_confirmation.asText(),
confirmButtonText = R.string.yes.asText(),
onConfirmClick = onConfirmClick,
dismissButtonText = R.string.cancel.asText(),
onDismissClick = onDismiss,
onDismissRequest = onDismiss,
)
}
@Composable
private fun SessionTimeoutActionDialog(
selectedSessionTimeoutAction: SessionTimeoutAction,
onDismissRequest: () -> Unit,
onActionSelect: (SessionTimeoutAction) -> Unit,
) {
BitwardenSelectionDialog(
title = R.string.vault_timeout_action.asText(),
onDismissRequest = onDismissRequest,
) {
SessionTimeoutAction.values().forEach { option ->
BitwardenSelectionRow(
text = option.text,
isSelected = option == selectedSessionTimeoutAction,
onClick = { onActionSelect(SessionTimeoutAction.values().first { it == option }) },
)
}
}
}

View File

@@ -3,8 +3,11 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -17,6 +20,7 @@ private const val KEY_STATE = "state"
/**
* View model for the account security screen.
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class AccountSecurityViewModel @Inject constructor(
private val authRepository: AuthRepository,
@@ -24,7 +28,12 @@ class AccountSecurityViewModel @Inject constructor(
) : BaseViewModel<AccountSecurityState, AccountSecurityEvent, AccountSecurityAction>(
initialState = savedStateHandle[KEY_STATE]
?: AccountSecurityState(
shouldShowConfirmLogoutDialog = false,
dialog = null,
isApproveLoginRequestsEnabled = false,
isUnlockWithBiometricsEnabled = false,
isUnlockWithPinEnabled = false,
sessionTimeout = "15 Minutes".asText(),
sessionTimeoutAction = SessionTimeoutAction.LOCK,
),
) {
@@ -35,25 +44,115 @@ class AccountSecurityViewModel @Inject constructor(
}
override fun handleAction(action: AccountSecurityAction): Unit = when (action) {
AccountSecurityAction.LogoutClick -> handleLogoutClick()
AccountSecurityAction.AccountFingerprintPhraseClick -> handleAccountFingerprintPhraseClick()
AccountSecurityAction.BackClick -> handleBackClick()
AccountSecurityAction.ChangeMasterPasswordClick -> handleChangeMasterPasswordClick()
AccountSecurityAction.ConfirmLogoutClick -> handleConfirmLogoutClick()
AccountSecurityAction.DeleteAccountClick -> handleDeleteAccountClick()
AccountSecurityAction.DismissDialog -> handleDismissDialog()
AccountSecurityAction.LockNowClick -> handleLockNowClick()
is AccountSecurityAction.LoginRequestToggle -> handleLoginRequestToggle(action)
AccountSecurityAction.LogoutClick -> handleLogoutClick()
AccountSecurityAction.PendingLoginRequestsClick -> handlePendingLoginRequestsClick()
is AccountSecurityAction.SessionTimeoutActionSelect -> {
handleSessionTimeoutActionSelect(action)
}
AccountSecurityAction.SessionTimeoutActionClick -> handleSessionTimeoutActionClick()
AccountSecurityAction.SessionTimeoutClick -> handleSessionTimeoutClick()
AccountSecurityAction.TwoStepLoginClick -> handleTwoStepLoginClick()
is AccountSecurityAction.UnlockWithBiometricToggle -> {
handleUnlockWithBiometricToggled(action)
}
is AccountSecurityAction.UnlockWithPinToggle -> handleUnlockWithPinToggle(action)
}
private fun handleLogoutClick() {
mutableStateFlow.update { it.copy(shouldShowConfirmLogoutDialog = true) }
private fun handleAccountFingerprintPhraseClick() {
// TODO BIT-470: Display fingerprint phrase
sendEvent(AccountSecurityEvent.ShowToast("Display fingerprint phrase.".asText()))
}
private fun handleBackClick() = sendEvent(AccountSecurityEvent.NavigateBack)
private fun handleChangeMasterPasswordClick() {
// TODO BIT-971: Add Leaving app Dialog
sendEvent(AccountSecurityEvent.ShowToast("Not yet implemented.".asText()))
}
private fun handleConfirmLogoutClick() {
mutableStateFlow.update { it.copy(shouldShowConfirmLogoutDialog = false) }
mutableStateFlow.update { it.copy(dialog = null) }
authRepository.logout()
}
private fun handleDeleteAccountClick() {
// TODO BIT-1031: Navigate to delete account
sendEvent(AccountSecurityEvent.ShowToast("Not yet implemented.".asText()))
}
private fun handleDismissDialog() {
mutableStateFlow.update { it.copy(shouldShowConfirmLogoutDialog = false) }
mutableStateFlow.update { it.copy(dialog = null) }
}
private fun handleLockNowClick() {
// TODO BIT-467: Lock the app
sendEvent(AccountSecurityEvent.ShowToast("Lock the app.".asText()))
}
private fun handleLoginRequestToggle(action: AccountSecurityAction.LoginRequestToggle) {
// TODO BIT-466: Persist pending login requests state
mutableStateFlow.update { it.copy(isApproveLoginRequestsEnabled = action.enabled) }
sendEvent(AccountSecurityEvent.ShowToast("Handle Login requests on this device.".asText()))
}
private fun handleLogoutClick() {
mutableStateFlow.update { it.copy(dialog = AccountSecurityDialog.ConfirmLogout) }
}
private fun handlePendingLoginRequestsClick() {
// TODO BIT-466: Implement pending login requests UI
sendEvent(AccountSecurityEvent.ShowToast("Not yet implemented.".asText()))
}
private fun handleSessionTimeoutActionSelect(
action: AccountSecurityAction.SessionTimeoutActionSelect,
) {
// TODO BIT-746: Implement session timeout action
mutableStateFlow.update {
it.copy(
dialog = null,
sessionTimeoutAction = action.sessionTimeoutAction,
)
}
sendEvent(AccountSecurityEvent.ShowToast("Not yet implemented.".asText()))
}
private fun handleSessionTimeoutActionClick() {
mutableStateFlow.update { it.copy(dialog = AccountSecurityDialog.SessionTimeoutAction) }
}
private fun handleSessionTimeoutClick() {
// TODO BIT-462: Implement session timeout
sendEvent(AccountSecurityEvent.ShowToast("Display session timeout dialog.".asText()))
}
private fun handleTwoStepLoginClick() {
// TODO BIT-468: Implement two-step login
sendEvent(AccountSecurityEvent.ShowToast("Not yet implemented.".asText()))
}
private fun handleUnlockWithBiometricToggled(
action: AccountSecurityAction.UnlockWithBiometricToggle,
) {
// TODO Display alert
mutableStateFlow.update { it.copy(isUnlockWithBiometricsEnabled = action.enabled) }
sendEvent(AccountSecurityEvent.ShowToast("Handle unlock with biometrics.".asText()))
}
private fun handleUnlockWithPinToggle(action: AccountSecurityAction.UnlockWithPinToggle) {
// TODO BIT-974: Display alert
mutableStateFlow.update { it.copy(isUnlockWithPinEnabled = action.enabled) }
sendEvent(AccountSecurityEvent.ShowToast("Handle unlock with pin.".asText()))
}
}
@@ -62,9 +161,39 @@ class AccountSecurityViewModel @Inject constructor(
*/
@Parcelize
data class AccountSecurityState(
val shouldShowConfirmLogoutDialog: Boolean,
val dialog: AccountSecurityDialog?,
val isApproveLoginRequestsEnabled: Boolean,
val isUnlockWithBiometricsEnabled: Boolean,
val isUnlockWithPinEnabled: Boolean,
val sessionTimeout: Text,
val sessionTimeoutAction: SessionTimeoutAction,
) : Parcelable
/**
* Representation of the dialogs that can be displayed on account security screen.
*/
sealed class AccountSecurityDialog : Parcelable {
/**
* Allows the user to confirm that they want to logout.
*/
@Parcelize
data object ConfirmLogout : AccountSecurityDialog()
/**
* Allows the user to select a session timeout action.
*/
@Parcelize
data object SessionTimeoutAction : AccountSecurityDialog()
}
/**
* A representation of the Session timeout action.
*/
enum class SessionTimeoutAction(val text: Text) {
LOCK(text = R.string.lock.asText()),
LOG_OUT(text = R.string.log_out.asText()),
}
/**
* Models events for the account security screen.
*/
@@ -73,29 +202,105 @@ sealed class AccountSecurityEvent {
* Navigate back.
*/
data object NavigateBack : AccountSecurityEvent()
/**
* Displays a toast with the given [Text].
*/
data class ShowToast(
val text: Text,
) : AccountSecurityEvent()
}
/**
* Models actions for the account security screen.
*/
sealed class AccountSecurityAction {
/**
* User clicked account fingerprint phrase.
*/
data object AccountFingerprintPhraseClick : AccountSecurityAction()
/**
* User clicked back button.
*/
data object BackClick : AccountSecurityAction()
/**
* User clicked change master password.
*/
data object ChangeMasterPasswordClick : AccountSecurityAction()
/**
* User confirmed they want to logout.
*/
data object ConfirmLogoutClick : AccountSecurityAction()
/**
* User dismissed the confirm logout dialog.
* User clicked delete account.
*/
data object DeleteAccountClick : AccountSecurityAction()
/**
* User dismissed the currently displayed dialog.
*/
data object DismissDialog : AccountSecurityAction()
/**
* User clicked lock now.
*/
data object LockNowClick : AccountSecurityAction()
/**
* User toggled the login request switch.
*/
data class LoginRequestToggle(
val enabled: Boolean,
) : AccountSecurityAction()
/**
* User clicked log out.
*/
data object LogoutClick : AccountSecurityAction()
/**
* User clicked pending login requests.
*/
data object PendingLoginRequestsClick : AccountSecurityAction()
/**
* User selected a [SessionTimeoutAction].
*/
data class SessionTimeoutActionSelect(
val sessionTimeoutAction: SessionTimeoutAction,
) : AccountSecurityAction()
/**
* User clicked session timeout action.
*/
data object SessionTimeoutActionClick : AccountSecurityAction()
/**
* User clicked session timeout.
*/
data object SessionTimeoutClick : AccountSecurityAction()
/**
* User clicked two-step login.
*/
data object TwoStepLoginClick : AccountSecurityAction()
/**
* User toggled the unlock with biometrics switch.
*/
data class UnlockWithBiometricToggle(
val enabled: Boolean,
) : AccountSecurityAction()
/**
* User toggled the unlock with pin switch.
*/
data class UnlockWithPinToggle(
val enabled: Boolean,
) : AccountSecurityAction()
}