BIT-2234: Delete Account Confirmation Screen (functionality) (#1290)

This commit is contained in:
Ramsey Smith
2024-04-23 09:43:06 -06:00
committed by Álison Fernandes
parent d64457aa0f
commit 7b08e1abb8
13 changed files with 567 additions and 41 deletions

View File

@@ -6,10 +6,13 @@ import kotlinx.serialization.Serializable
/**
* Request body for deleting an account.
*
* @param masterPasswordHash the master password (encrypted).
* @param masterPasswordHash The master password (encrypted).
* @param oneTimePassword The one time password.
*/
@Serializable
data class DeleteAccountRequestJson(
@SerialName("MasterPasswordHash")
val masterPasswordHash: String,
val masterPasswordHash: String?,
@SerialName("otp")
val oneTimePassword: String?,
)

View File

@@ -18,7 +18,7 @@ interface AccountsService {
/**
* Make delete account request.
*/
suspend fun deleteAccount(masterPasswordHash: String): Result<Unit>
suspend fun deleteAccount(masterPasswordHash: String?, oneTimePassword: String?): Result<Unit>
/**
* Request a one-time passcode that is sent to the user's email.

View File

@@ -31,8 +31,16 @@ class AccountsServiceImpl(
),
)
override suspend fun deleteAccount(masterPasswordHash: String): Result<Unit> =
authenticatedAccountsApi.deleteAccount(DeleteAccountRequestJson(masterPasswordHash))
override suspend fun deleteAccount(
masterPasswordHash: String?,
oneTimePassword: String?,
): Result<Unit> =
authenticatedAccountsApi.deleteAccount(
DeleteAccountRequestJson(
masterPasswordHash = masterPasswordHash,
oneTimePassword = oneTimePassword,
),
)
override suspend fun requestOneTimePasscode(): Result<Unit> =
authenticatedAccountsApi.requestOtp()

View File

@@ -122,9 +122,16 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
fun clearPendingAccountDeletion()
/**
* Attempt to delete the current account and logout them out upon success.
* Attempt to delete the current account using the [masterPassword] and log them out
* upon success.
*/
suspend fun deleteAccount(password: String): DeleteAccountResult
suspend fun deleteAccountWithMasterPassword(masterPassword: String): DeleteAccountResult
/**
* Attempt to delete the current account using a [oneTimePassword] and log them out
* upon success.
*/
suspend fun deleteAccountWithOneTimePassword(oneTimePassword: String): DeleteAccountResult
/**
* Attempt to create a new user via SSO and log them into their account. Upon success the new

View File

@@ -348,18 +348,42 @@ class AuthRepositoryImpl(
mutableHasPendingAccountDeletionStateFlow.value = false
}
override suspend fun deleteAccount(password: String): DeleteAccountResult {
override suspend fun deleteAccountWithMasterPassword(
masterPassword: String,
): DeleteAccountResult {
val profile = authDiskSource.userState?.activeAccount?.profile
?: return DeleteAccountResult.Error
mutableHasPendingAccountDeletionStateFlow.value = true
return authSdkSource
.hashPassword(
email = profile.email,
password = password,
password = masterPassword,
kdf = profile.toSdkParams(),
purpose = HashPurpose.SERVER_AUTHORIZATION,
)
.flatMap { hashedPassword -> accountsService.deleteAccount(hashedPassword) }
.flatMap { hashedPassword ->
accountsService.deleteAccount(
masterPasswordHash = hashedPassword,
oneTimePassword = null,
)
}
.onSuccess { logout() }
.onFailure { clearPendingAccountDeletion() }
.fold(
onFailure = { DeleteAccountResult.Error },
onSuccess = { DeleteAccountResult.Success },
)
}
override suspend fun deleteAccountWithOneTimePassword(
oneTimePassword: String,
): DeleteAccountResult {
mutableHasPendingAccountDeletionStateFlow.value = true
return accountsService
.deleteAccount(
masterPasswordHash = null,
oneTimePassword = oneTimePassword,
)
.onSuccess { logout() }
.onFailure { clearPendingAccountDeletion() }
.fold(

View File

@@ -78,7 +78,7 @@ class DeleteAccountViewModel @Inject constructor(
it.copy(dialog = DeleteAccountState.DeleteAccountDialog.Loading)
}
viewModelScope.launch {
val result = authRepository.deleteAccount(action.masterPassword)
val result = authRepository.deleteAccountWithMasterPassword(action.masterPassword)
sendAction(DeleteAccountAction.Internal.DeleteAccountComplete(result))
}
}

View File

@@ -2,9 +2,17 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deletea
import android.widget.Toast
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.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
@@ -15,15 +23,24 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenErrorButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
/**
@@ -62,6 +79,19 @@ fun DeleteAccountConfirmationScreen(
onCloseClick = remember(viewModel) {
{ viewModel.trySendAction(DeleteAccountConfirmationAction.CloseClick) }
},
onDeleteAccountClick = remember(viewModel) {
{ viewModel.trySendAction(DeleteAccountConfirmationAction.DeleteAccountClick) }
},
onResendCodeClick = remember(viewModel) {
{ viewModel.trySendAction(DeleteAccountConfirmationAction.ResendCodeClick) }
},
onVerificationCodeTextChange = remember(viewModel) {
{
viewModel.trySendAction(
DeleteAccountConfirmationAction.VerificationCodeTextChange(it),
)
}
},
)
}
@@ -97,15 +127,83 @@ private fun DeleteAccountConfirmationDialogs(
visibilityState = LoadingDialogState.Shown(dialogState.title),
)
}
null -> Unit
}
}
@Composable
private fun DeleteAccountConfirmationContent(
state: DeleteAccountConfirmationState,
onDeleteAccountClick: () -> Unit,
onResendCodeClick: () -> Unit,
onVerificationCodeTextChange: (verificationCode: String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState()),
) {
Text(
text = stringResource(id = R.string.a_verification_code_was_sent_to_your_email),
textAlign = TextAlign.Start,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
BitwardenPasswordField(
value = state.verificationCode,
onValueChange = onVerificationCodeTextChange,
label = stringResource(id = R.string.verification_code),
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
autoFocus = true,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(id = R.string.confirm_your_identity),
textAlign = TextAlign.Start,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenErrorButton(
label = stringResource(id = R.string.delete_account),
onClick = onDeleteAccountClick,
isEnabled = state.verificationCode.isNotBlank(),
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenOutlinedButton(
label = stringResource(id = R.string.resend_code),
onClick = onResendCodeClick,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DeleteAccountConfirmationScaffold(
state: DeleteAccountConfirmationState,
onCloseClick: () -> Unit,
onDeleteAccountClick: () -> Unit,
onResendCodeClick: () -> Unit,
onVerificationCodeTextChange: (verificationCode: String) -> Unit,
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
@@ -122,8 +220,29 @@ private fun DeleteAccountConfirmationScaffold(
)
},
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
// TODO finish UI in BIT-2234
}
DeleteAccountConfirmationContent(
state = state,
onDeleteAccountClick = onDeleteAccountClick,
onResendCodeClick = onResendCodeClick,
onVerificationCodeTextChange = onVerificationCodeTextChange,
modifier = Modifier.padding(innerPadding),
)
}
}
@Preview(showBackground = true)
@Composable
private fun DeleteAccountConfirmationScreen_preview() {
BitwardenTheme {
DeleteAccountConfirmationScaffold(
state = DeleteAccountConfirmationState(
dialog = null,
verificationCode = "123456",
),
onCloseClick = {},
onDeleteAccountClick = {},
onResendCodeClick = {},
onVerificationCodeTextChange = {},
)
}
}

View File

@@ -5,6 +5,8 @@ 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.data.auth.repository.model.DeleteAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
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
@@ -12,6 +14,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@@ -27,8 +30,10 @@ class DeleteAccountConfirmationViewModel @Inject constructor(
) : BaseViewModel<
DeleteAccountConfirmationState,
DeleteAccountConfirmationEvent,
DeleteAccountConfirmationAction,>(
DeleteAccountConfirmationAction,
>(
initialState = savedStateHandle[KEY_STATE] ?: DeleteAccountConfirmationState(
verificationCode = "",
dialog = null,
),
) {
@@ -37,16 +42,25 @@ class DeleteAccountConfirmationViewModel @Inject constructor(
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
viewModelScope.launch { authRepository.requestOneTimePasscode() }
}
override fun handleAction(action: DeleteAccountConfirmationAction) {
when (action) {
DeleteAccountConfirmationAction.CloseClick -> handleCloseClick()
DeleteAccountConfirmationAction.DeleteAccountAcknowledge -> {
is DeleteAccountConfirmationAction.CloseClick -> handleCloseClick()
is DeleteAccountConfirmationAction.DeleteAccountAcknowledge -> {
handleDeleteAccountAcknowledge()
}
DeleteAccountConfirmationAction.DismissDialog -> handleDismissDialog()
is DeleteAccountConfirmationAction.DismissDialog -> handleDismissDialog()
is DeleteAccountConfirmationAction.DeleteAccountClick -> handleDeleteAccountClick()
is DeleteAccountConfirmationAction.ResendCodeClick -> handleResendCodeClick()
is DeleteAccountConfirmationAction.VerificationCodeTextChange -> {
handleVerificationCodeTextChange(action)
}
is DeleteAccountConfirmationAction.Internal -> handleInternalActions(action)
}
}
@@ -62,6 +76,95 @@ class DeleteAccountConfirmationViewModel @Inject constructor(
private fun handleDismissDialog() {
mutableStateFlow.update { it.copy(dialog = null) }
}
private fun handleDeleteAccountClick() {
mutableStateFlow.update {
it.copy(
dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Loading(),
)
}
viewModelScope.launch {
sendAction(
DeleteAccountConfirmationAction.Internal.ReceiveDeleteAccountResult(
deleteAccountResult = authRepository.deleteAccountWithOneTimePassword(
oneTimePassword = state.verificationCode,
),
),
)
}
}
private fun handleResendCodeClick() {
mutableStateFlow.update {
it.copy(
dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Loading(),
)
}
viewModelScope.launch {
trySendAction(
DeleteAccountConfirmationAction.Internal.ReceiveRequestOtpResult(
requestOtpResult = authRepository.requestOneTimePasscode(),
),
)
}
}
private fun handleVerificationCodeTextChange(
action: DeleteAccountConfirmationAction.VerificationCodeTextChange,
) {
mutableStateFlow.update { it.copy(verificationCode = action.verificationCode) }
}
private fun handleInternalActions(action: DeleteAccountConfirmationAction.Internal) {
when (action) {
is DeleteAccountConfirmationAction.Internal.ReceiveRequestOtpResult -> {
handleReceiveRequestOtpResult(action)
}
is DeleteAccountConfirmationAction.Internal.ReceiveDeleteAccountResult -> {
handleReceiveDeleteAccountResult(action)
}
}
}
private fun handleReceiveRequestOtpResult(
action: DeleteAccountConfirmationAction.Internal.ReceiveRequestOtpResult,
) {
mutableStateFlow.update {
it.copy(
dialog = when (action.requestOtpResult) {
is RequestOtpResult.Error -> {
DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error(
message = R.string.generic_error_message.asText(),
)
}
is RequestOtpResult.Success -> null
},
)
}
}
@Suppress("MaxLineLength")
private fun handleReceiveDeleteAccountResult(
action: DeleteAccountConfirmationAction.Internal.ReceiveDeleteAccountResult,
) {
mutableStateFlow.update { currentState ->
currentState.copy(
dialog = when (action.deleteAccountResult) {
DeleteAccountResult.Error -> {
DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error(
message = R.string.generic_error_message.asText(),
)
}
DeleteAccountResult.Success -> {
DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.DeleteSuccess()
}
},
)
}
}
}
/**
@@ -69,6 +172,7 @@ class DeleteAccountConfirmationViewModel @Inject constructor(
*/
@Parcelize
data class DeleteAccountConfirmationState(
val verificationCode: String,
val dialog: DeleteAccountConfirmationDialog?,
) : Parcelable {
@@ -83,8 +187,7 @@ data class DeleteAccountConfirmationState(
*/
@Parcelize
data class DeleteSuccess(
val message: Text =
R.string.your_account_has_been_permanently_deleted.asText(),
val message: Text = R.string.your_account_has_been_permanently_deleted.asText(),
) : DeleteAccountConfirmationDialog()
/**
@@ -95,8 +198,8 @@ data class DeleteAccountConfirmationState(
*/
@Parcelize
data class Error(
val title: Text = R.string.an_error_has_occurred.asText(),
val message: Text,
val title: Text = R.string.an_error_has_occurred.asText(),
val message: Text,
) : DeleteAccountConfirmationDialog()
/**
@@ -106,7 +209,7 @@ data class DeleteAccountConfirmationState(
*/
@Parcelize
data class Loading(
val title: Text = R.string.loading.asText(),
val title: Text = R.string.loading.asText(),
) : DeleteAccountConfirmationDialog()
}
}
@@ -146,4 +249,43 @@ sealed class DeleteAccountConfirmationAction {
* The user has acknowledged the account deletion.
*/
data object DeleteAccountAcknowledge : DeleteAccountConfirmationAction()
/**
* The user has clicked the delete account button.
*/
data object DeleteAccountClick : DeleteAccountConfirmationAction()
/**
* The user has clicked the resend code button.
*/
data object ResendCodeClick : DeleteAccountConfirmationAction()
/**
* The user has changed the verification code.
*
* @param verificationCode The verification code the user has entered.
*/
data class VerificationCodeTextChange(
val verificationCode: String,
) : DeleteAccountConfirmationAction()
/**
* Internal actions for the view model.
*/
sealed class Internal : DeleteAccountConfirmationAction() {
/**
* Indicates that a [RequestOtpResult] has been received.
*/
data class ReceiveRequestOtpResult(
val requestOtpResult: RequestOtpResult,
) : Internal()
/**
* Indicates that a [DeleteAccountResult] has been received.
*/
data class ReceiveDeleteAccountResult(
val deleteAccountResult: DeleteAccountResult,
) : Internal()
}
}