BIT-1184: Add pending deletion state to display deletion dialog (#793)

This commit is contained in:
David Perez 2024-01-25 17:22:07 -06:00 committed by Álison Fernandes
parent 317cc7396e
commit 26335bf217
7 changed files with 166 additions and 14 deletions

View file

@ -69,6 +69,11 @@ interface AuthRepository : AuthenticatorProvider {
*/
var hasPendingAccountAddition: Boolean
/**
* Clears the pending deletion state that occurs when the an account is successfully deleted.
*/
fun clearPendingAccountDeletion()
/**
* Attempt to delete the current account and logout them out upon success.
*/

View file

@ -6,8 +6,8 @@ import com.bitwarden.crypto.Kdf
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.CaptchaRequired
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.TwoFactorRequired
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.Success
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.TwoFactorRequired
import com.x8bit.bitwarden.data.auth.datasource.network.model.IdentityTokenAuthModel
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
@ -62,6 +62,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Singleton
@ -86,7 +87,8 @@ class AuthRepositoryImpl(
dispatcherManager: DispatcherManager,
private val elapsedRealtimeMillisProvider: () -> Long = { SystemClock.elapsedRealtime() },
) : AuthRepository {
private val mutableHasPendingAccountAdditionStateFlow = MutableStateFlow<Boolean>(false)
private val mutableHasPendingAccountAdditionStateFlow = MutableStateFlow(false)
private val mutableHasPendingAccountDeletionStateFlow = MutableStateFlow(false)
/**
* The auth information to make the identity token request will need to be
@ -128,11 +130,13 @@ class AuthRepositoryImpl(
authDiskSource.userOrganizationsListFlow,
vaultRepository.vaultStateFlow,
mutableHasPendingAccountAdditionStateFlow,
mutableHasPendingAccountDeletionStateFlow,
) {
userStateJson,
userOrganizationsList,
vaultState,
hasPendingAccountAddition,
_,
->
userStateJson
?.toUserState(
@ -142,6 +146,11 @@ class AuthRepositoryImpl(
vaultUnlockTypeProvider = ::getVaultUnlockType,
)
}
.filter {
// If there is a pending account deletion, continue showing
// the original UserState until it is confirmed.
!mutableHasPendingAccountDeletionStateFlow.value
}
.stateIn(
scope = collectionScope,
started = SharingStarted.Eagerly,
@ -170,9 +179,14 @@ class AuthRepositoryImpl(
override var hasPendingAccountAddition: Boolean
by mutableHasPendingAccountAdditionStateFlow::value
override fun clearPendingAccountDeletion() {
mutableHasPendingAccountDeletionStateFlow.value = false
}
override suspend fun deleteAccount(password: String): DeleteAccountResult {
val profile = authDiskSource.userState?.activeAccount?.profile
?: return DeleteAccountResult.Error
mutableHasPendingAccountDeletionStateFlow.value = true
return authSdkSource
.hashPassword(
email = profile.email,
@ -182,6 +196,7 @@ class AuthRepositoryImpl(
)
.flatMap { hashedPassword -> accountsService.deleteAccount(hashedPassword) }
.onSuccess { logout() }
.onFailure { clearPendingAccountDeletion() }
.fold(
onFailure = { DeleteAccountResult.Error },
onSuccess = { DeleteAccountResult.Success },

View file

@ -67,6 +67,16 @@ fun DeleteAccountScreen(
}
when (val dialog = state.dialog) {
DeleteAccountState.DeleteAccountDialog.DeleteSuccess -> BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = null,
message = R.string.your_account_has_been_permanently_deleted.asText(),
),
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(DeleteAccountAction.AccountDeletionConfirm) }
},
)
is DeleteAccountState.DeleteAccountDialog.Error -> BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),

View file

@ -43,6 +43,7 @@ class DeleteAccountViewModel @Inject constructor(
DeleteAccountAction.CancelClick -> handleCancelClick()
DeleteAccountAction.CloseClick -> handleCloseClick()
is DeleteAccountAction.DeleteAccountClick -> handleDeleteAccountClick(action)
DeleteAccountAction.AccountDeletionConfirm -> handleAccountDeletionConfirm()
DeleteAccountAction.DismissDialog -> handleDismissDialog()
is DeleteAccountAction.Internal.DeleteAccountComplete -> {
handleDeleteAccountComplete(action)
@ -68,6 +69,11 @@ class DeleteAccountViewModel @Inject constructor(
}
}
private fun handleAccountDeletionConfirm() {
authRepository.clearPendingAccountDeletion()
mutableStateFlow.update { it.copy(dialog = null) }
}
private fun handleDismissDialog() {
mutableStateFlow.update { it.copy(dialog = null) }
}
@ -77,8 +83,9 @@ class DeleteAccountViewModel @Inject constructor(
) {
when (action.result) {
DeleteAccountResult.Success -> {
mutableStateFlow.update { it.copy(dialog = null) }
// TODO: Display a dialog confirming account deletion (BIT-1184)
mutableStateFlow.update {
it.copy(dialog = DeleteAccountState.DeleteAccountDialog.DeleteSuccess)
}
}
DeleteAccountResult.Error -> {
@ -106,6 +113,12 @@ data class DeleteAccountState(
* Displays a dialog.
*/
sealed class DeleteAccountDialog : Parcelable {
/**
* Dialog to confirm to the user that the account has been deleted.
*/
@Parcelize
data object DeleteSuccess : DeleteAccountDialog()
/**
* Displays the error dialog when deleting an account fails.
*/
@ -160,6 +173,11 @@ sealed class DeleteAccountAction {
val masterPassword: String,
) : DeleteAccountAction()
/**
* The user has confirmed that their account has been deleted.
*/
data object AccountDeletionConfirm : DeleteAccountAction()
/**
* The user has clicked to dismiss the dialog.
*/

View file

@ -314,6 +314,48 @@ class AuthRepositoryTest {
assertNull(repository.rememberedEmailAddress)
}
@Test
fun `clear Pending Account Deletion should unblock userState updates`() = runTest {
val masterPassword = "hello world"
val hashedMasterPassword = "dlrow olleh"
val originalUserState = SINGLE_USER_STATE_1.toUserState(
vaultState = VAULT_STATE,
userOrganizationsList = emptyList(),
hasPendingAccountAddition = false,
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
)
val finalUserState = SINGLE_USER_STATE_2.toUserState(
vaultState = VAULT_STATE,
userOrganizationsList = emptyList(),
hasPendingAccountAddition = false,
vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD },
)
val kdf = SINGLE_USER_STATE_1.activeAccount.profile.toSdkParams()
coEvery {
authSdkSource.hashPassword(EMAIL, masterPassword, kdf, HashPurpose.SERVER_AUTHORIZATION)
} returns hashedMasterPassword.asSuccess()
coEvery {
accountsService.deleteAccount(hashedMasterPassword)
} returns Unit.asSuccess()
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
repository.userStateFlow.test {
assertEquals(originalUserState, awaitItem())
// Deleting the account sets the pending deletion flag
repository.deleteAccount(password = masterPassword)
// Update the account. No changes are emitted because
// the pending deletion blocks the update.
fakeAuthDiskSource.userState = SINGLE_USER_STATE_2
expectNoEvents()
// Clearing the pending deletion allows the change to go through
repository.clearPendingAccountDeletion()
assertEquals(finalUserState, awaitItem())
}
}
@Test
fun `delete account fails if not logged in`() = runTest {
val masterPassword = "hello world"

View file

@ -91,6 +91,40 @@ class DeleteAccountScreenTest : BaseComposeTest() {
.assertExists()
}
@Test
fun `delete success dialog presence should update with dialog state`() {
val message = "Your account has been permanently deleted"
composeTestRule
.onAllNodesWithText(message)
.filterToOne(hasAnyAncestor(isDialog()))
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(dialog = DeleteAccountState.DeleteAccountDialog.DeleteSuccess)
}
composeTestRule
.onAllNodesWithText(message)
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Test
fun `delete success dialog dismiss should emit DeleteAccountAction`() {
mutableStateFlow.update {
it.copy(dialog = DeleteAccountState.DeleteAccountDialog.DeleteSuccess)
}
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(DeleteAccountAction.AccountDeletionConfirm)
}
}
@Test
fun `delete account dialog should dismiss on cancel click`() {
composeTestRule

View file

@ -9,7 +9,11 @@ import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@ -52,21 +56,26 @@ class DeleteAccountViewModelTest : BaseViewModelTest() {
}
@Test
fun `on DeleteAccountClick should make the delete call`() = runTest {
val viewModel = createViewModel()
val masterPassword = "ckasb kcs ja"
coEvery { authRepo.deleteAccount(masterPassword) } returns DeleteAccountResult.Success
fun `on DeleteAccountClick should update dialog state when delete account succeeds`() =
runTest {
val viewModel = createViewModel()
val masterPassword = "ckasb kcs ja"
coEvery { authRepo.deleteAccount(masterPassword) } returns DeleteAccountResult.Success
viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(masterPassword))
viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(masterPassword))
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
coVerify {
authRepo.deleteAccount(masterPassword)
assertEquals(
DEFAULT_STATE.copy(dialog = DeleteAccountState.DeleteAccountDialog.DeleteSuccess),
viewModel.stateFlow.value,
)
coVerify {
authRepo.deleteAccount(masterPassword)
}
}
}
@Test
fun `on DeleteAccountClick should update dialog state`() = runTest {
fun `on DeleteAccountClick should update dialog state when deleteAccount fails`() = runTest {
val viewModel = createViewModel()
val masterPassword = "ckasb kcs ja"
coEvery { authRepo.deleteAccount(masterPassword) } returns DeleteAccountResult.Error
@ -87,6 +96,25 @@ class DeleteAccountViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `AccountDeletionConfirm should clear dialog state and call clearPendingAccountDeletion`() =
runTest {
every { authRepo.clearPendingAccountDeletion() } just runs
val state = DEFAULT_STATE.copy(
dialog = DeleteAccountState.DeleteAccountDialog.DeleteSuccess,
)
val viewModel = createViewModel(state = state)
viewModel.trySendAction(DeleteAccountAction.AccountDeletionConfirm)
assertEquals(
DEFAULT_STATE.copy(dialog = null),
viewModel.stateFlow.value,
)
verify {
authRepo.clearPendingAccountDeletion()
}
}
@Test
fun `on DismissDialog should clear dialog state`() = runTest {
val state = DEFAULT_STATE.copy(