BIT-1111: Add delete account logic (#252)

This commit is contained in:
David Perez
2024-06-20 17:08:07 +01:00
committed by Álison Fernandes
parent dd6e7639b5
commit db5c57664f
15 changed files with 622 additions and 26 deletions
@@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.auth.datasource.network.api
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountRequestJson
import retrofit2.http.Body
import retrofit2.http.HTTP
/**
* Defines raw calls under the /accounts API with authentication applied.
*/
interface AuthenticatedAccountsApi {
/**
* Deletes the current account.
*/
@HTTP(method = "DELETE", path = "/accounts", hasBody = true)
suspend fun deleteAccount(@Body body: DeleteAccountRequestJson): Result<Unit>
}
@@ -29,6 +29,7 @@ object AuthNetworkModule {
json: Json,
): AccountsService = AccountsServiceImpl(
accountsApi = retrofits.unauthenticatedApiRetrofit.create(),
authenticatedAccountsApi = retrofits.authenticatedApiRetrofit.create(),
json = json,
)
@@ -0,0 +1,15 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body for deleting an account.
*
* @param masterPasswordHash the master password (encrypted).
*/
@Serializable
data class DeleteAccountRequestJson(
@SerialName("MasterPasswordHash")
val masterPasswordHash: String,
)
@@ -9,6 +9,11 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJs
*/
interface AccountsService {
/**
* Make delete account request.
*/
suspend fun deleteAccount(masterPasswordHash: String): Result<Unit>
/**
* Make pre login request to get KDF params.
*/
@@ -1,6 +1,8 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthenticatedAccountsApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
@@ -11,9 +13,13 @@ import kotlinx.serialization.json.Json
class AccountsServiceImpl constructor(
private val accountsApi: AccountsApi,
private val authenticatedAccountsApi: AuthenticatedAccountsApi,
private val json: Json,
) : AccountsService {
override suspend fun deleteAccount(masterPasswordHash: String): Result<Unit> =
authenticatedAccountsApi.deleteAccount(DeleteAccountRequestJson(masterPasswordHash))
override suspend fun preLogin(email: String): Result<PreLoginResponseJson> =
accountsApi.preLogin(PreLoginRequestJson(email = email))
@@ -28,6 +28,11 @@ interface AuthRepository {
*/
var rememberedEmailAddress: String?
/**
* Attempt to delete the current account and logout them out upon success.
*/
suspend fun deleteAccount(password: String): Result<Unit>
/**
* Attempt to login with the given email and password. Updated access token will be reflected
* in [authStateFlow].
@@ -17,10 +17,12 @@ import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.platform.util.flatMap
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
@@ -82,6 +84,19 @@ class AuthRepositoryImpl constructor(
authDiskSource.rememberedEmailAddress = value
}
override suspend fun deleteAccount(password: String): Result<Unit> {
val profile = authDiskSource.userState?.activeAccount?.profile
?: return IllegalStateException("Not logged in.").asFailure()
return authSdkSource
.hashPassword(
email = profile.email,
password = password,
kdf = profile.toSdkParams(),
)
.flatMap { hashedPassword -> accountsService.deleteAccount(hashedPassword) }
.onSuccess { logout() }
}
override suspend fun login(
email: String,
password: String,
@@ -0,0 +1,74 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
/**
* Represents a Bitwarden-styled dialog for entering your master password.
*
* @param onConfirmClick called when the confirm button is clicked and emits the entered password.
* @param onDismissRequest called when the user attempts to dismiss the dialog (for example by
* tapping outside of it).
*/
@Composable
fun BitwardenMasterPasswordDialog(
onConfirmClick: (masterPassword: String) -> Unit,
onDismissRequest: () -> Unit,
) {
var masterPassword by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismissRequest,
dismissButton = {
BitwardenTextButton(
label = stringResource(id = R.string.cancel),
onClick = onDismissRequest,
)
},
confirmButton = {
BitwardenTextButton(
label = stringResource(id = R.string.submit),
isEnabled = masterPassword.isNotEmpty(),
onClick = { onConfirmClick(masterPassword) },
)
},
title = {
Text(
text = stringResource(id = R.string.password_confirmation),
style = MaterialTheme.typography.headlineSmall,
)
},
text = {
Column {
Text(
text = stringResource(id = R.string.password_confirmation_desc),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenPasswordField(
label = stringResource(id = R.string.master_password),
value = masterPassword,
onValueChange = { masterPassword = it },
modifier = Modifier.imePadding(),
autoFocus = true,
)
}
},
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
)
}
@@ -14,11 +14,15 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
@@ -42,6 +46,9 @@ import com.x8bit.bitwarden.R
* @param onValueChange Callback that is triggered when the password changes.
* @param modifier Modifier for the composable.
* @param hint optional hint text that will appear below the text input.
* @param showPasswordTestTag The test tag to be used on the show password button (testing tool).
* @param autoFocus When set to true, the view will request focus after the first recomposition.
* Setting this to true on multiple fields at once may have unexpected consequences.
*/
@Composable
fun BitwardenPasswordField(
@@ -53,12 +60,16 @@ fun BitwardenPasswordField(
modifier: Modifier = Modifier,
hint: String? = null,
showPasswordTestTag: String? = null,
autoFocus: Boolean = false,
) {
val focusRequester = remember { FocusRequester() }
Column(
modifier = modifier,
) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
textStyle = MaterialTheme.typography.bodyLarge,
label = { Text(text = label) },
value = value,
@@ -107,6 +118,9 @@ fun BitwardenPasswordField(
)
}
}
if (autoFocus) {
LaunchedEffect(Unit) { focusRequester.requestFocus() }
}
}
/**
@@ -120,6 +134,9 @@ fun BitwardenPasswordField(
* @param hint optional hint text that will appear below the text input.
* @param initialShowPassword The initial state of the show/hide password control. A value of
* `false` (the default) indicates that that password should begin in the hidden state.
* @param showPasswordTestTag The test tag to be used on the show password button (testing tool).
* @param autoFocus When set to true, the view will request focus after the first recomposition.
* Setting this to true on multiple fields at once may have unexpected consequences.
*/
@Composable
fun BitwardenPasswordField(
@@ -130,6 +147,7 @@ fun BitwardenPasswordField(
hint: String? = null,
initialShowPassword: Boolean = false,
showPasswordTestTag: String? = null,
autoFocus: Boolean = false,
) {
var showPassword by rememberSaveable { mutableStateOf(initialShowPassword) }
BitwardenPasswordField(
@@ -141,6 +159,7 @@ fun BitwardenPasswordField(
onValueChange = onValueChange,
hint = hint,
showPasswordTestTag = showPasswordTestTag,
autoFocus = autoFocus,
)
}
@@ -18,7 +18,11 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
@@ -28,10 +32,16 @@ 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.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenMasterPasswordDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenOutlinedButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
/**
* Displays the delete account screen.
@@ -43,6 +53,7 @@ fun DeleteAccountScreen(
viewModel: DeleteAccountViewModel = hiltViewModel(),
onNavigateBack: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsState()
val context = LocalContext.current
val resources = context.resources
EventsEffect(viewModel = viewModel) { event ->
@@ -55,6 +66,26 @@ fun DeleteAccountScreen(
}
}
when (val dialog = state.dialog) {
is DeleteAccountState.DeleteAccountDialog.Error -> BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = dialog.message,
),
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(DeleteAccountAction.DismissDialog) }
},
)
DeleteAccountState.DeleteAccountDialog.Loading,
-> BitwardenLoadingDialog(
visibilityState = LoadingDialogState.Shown(R.string.loading.asText()),
)
null -> Unit
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
@@ -105,14 +136,10 @@ fun DeleteAccountScreen(
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(24.dp))
BitwardenErrorButton(
label = stringResource(id = R.string.delete_account),
onClick = remember(viewModel) {
{ viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick) }
DeleteAccountButton(
onConfirmationClick = remember(viewModel) {
{ viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(it)) }
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenOutlinedButton(
@@ -128,3 +155,27 @@ fun DeleteAccountScreen(
}
}
}
@Composable
private fun DeleteAccountButton(
onConfirmationClick: (masterPassword: String) -> Unit,
) {
var showPasswordDialog by remember { mutableStateOf(false) }
if (showPasswordDialog) {
BitwardenMasterPasswordDialog(
onConfirmClick = {
showPasswordDialog = false
onConfirmationClick(it)
},
onDismissRequest = { showPasswordDialog = false },
)
}
BitwardenErrorButton(
label = stringResource(id = R.string.delete_account),
onClick = { showPasswordDialog = true },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
@@ -1,25 +1,51 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount
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
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* View model for the account security screen.
*/
@HiltViewModel
class DeleteAccountViewModel @Inject constructor() :
BaseViewModel<Unit, DeleteAccountEvent, DeleteAccountAction>(
initialState = Unit,
) {
class DeleteAccountViewModel @Inject constructor(
private val authRepository: AuthRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<DeleteAccountState, DeleteAccountEvent, DeleteAccountAction>(
initialState = savedStateHandle[KEY_STATE] ?: DeleteAccountState(
dialog = null,
),
) {
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: DeleteAccountAction) {
when (action) {
DeleteAccountAction.CancelClick -> handleCancelClick()
DeleteAccountAction.CloseClick -> handleCloseClick()
DeleteAccountAction.DeleteAccountClick -> handleDeleteAccountClick()
is DeleteAccountAction.DeleteAccountClick -> handleDeleteAccountClick(action)
DeleteAccountAction.DismissDialog -> handleDismissDialog()
is DeleteAccountAction.Internal.DeleteAccountComplete -> {
handleDeleteAccountComplete(action)
}
}
}
@@ -31,9 +57,66 @@ class DeleteAccountViewModel @Inject constructor() :
sendEvent(DeleteAccountEvent.NavigateBack)
}
private fun handleDeleteAccountClick() {
// TODO: Delete the users account (BIT-1111)
sendEvent(DeleteAccountEvent.ShowToast("Not yet implemented.".asText()))
private fun handleDeleteAccountClick(action: DeleteAccountAction.DeleteAccountClick) {
mutableStateFlow.update {
it.copy(dialog = DeleteAccountState.DeleteAccountDialog.Loading)
}
viewModelScope.launch {
val result = authRepository.deleteAccount(action.masterPassword)
sendAction(DeleteAccountAction.Internal.DeleteAccountComplete(result))
}
}
private fun handleDismissDialog() {
mutableStateFlow.update { it.copy(dialog = null) }
}
private fun handleDeleteAccountComplete(
action: DeleteAccountAction.Internal.DeleteAccountComplete,
) {
action.result.fold(
onSuccess = {
mutableStateFlow.update { it.copy(dialog = null) }
// TODO: Display a dialog confirming account deletion (BIT-1184)
},
onFailure = {
mutableStateFlow.update {
it.copy(
dialog = DeleteAccountState.DeleteAccountDialog.Error(
message = R.string.generic_error_message.asText(),
),
)
}
},
)
}
}
/**
* Models state for the Delete Account screen.
*/
@Parcelize
data class DeleteAccountState(
val dialog: DeleteAccountDialog?,
) : Parcelable {
/**
* Displays a dialog.
*/
sealed class DeleteAccountDialog : Parcelable {
/**
* Displays the error dialog when deleting an account fails.
*/
@Parcelize
data class Error(
val message: Text,
) : DeleteAccountDialog()
/**
* Displays the loading dialog when deleting an account.
*/
@Parcelize
data object Loading : DeleteAccountDialog()
}
}
@@ -71,5 +154,24 @@ sealed class DeleteAccountAction {
/**
* The user has clicked the delete account button.
*/
data object DeleteAccountClick : DeleteAccountAction()
data class DeleteAccountClick(
val masterPassword: String,
) : DeleteAccountAction()
/**
* The user has clicked to dismiss the dialog.
*/
data object DismissDialog : DeleteAccountAction()
/**
* Models actions that the [DeleteAccountViewModel] itself might send.
*/
sealed class Internal : DeleteAccountAction() {
/**
* Indicates that the delete account request has completed.
*/
data class DeleteAccountComplete(
val result: Result<Unit>,
) : Internal()
}
}