mirror of
https://github.com/bitwarden/android.git
synced 2024-11-24 10:25:57 +03:00
BIT-2234: Delete Account Confirmation Screen (functionality) (#1290)
This commit is contained in:
parent
d64457aa0f
commit
7b08e1abb8
13 changed files with 567 additions and 41 deletions
|
@ -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?,
|
||||
)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,10 +47,11 @@ class AccountsServiceTest : BaseServiceTest() {
|
|||
@Test
|
||||
fun `deleteAccount with empty response is success`() = runTest {
|
||||
val masterPasswordHash = "37y4d8r379r4789nt387r39k3dr87nr93"
|
||||
val oneTimePassword = null
|
||||
val json = ""
|
||||
val response = MockResponse().setBody(json)
|
||||
server.enqueue(response)
|
||||
assertTrue(service.deleteAccount(masterPasswordHash).isSuccess)
|
||||
assertTrue(service.deleteAccount(masterPasswordHash, oneTimePassword).isSuccess)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
@ -595,7 +595,10 @@ class AuthRepositoryTest {
|
|||
authSdkSource.hashPassword(EMAIL, masterPassword, kdf, HashPurpose.SERVER_AUTHORIZATION)
|
||||
} returns hashedMasterPassword.asSuccess()
|
||||
coEvery {
|
||||
accountsService.deleteAccount(hashedMasterPassword)
|
||||
accountsService.deleteAccount(
|
||||
masterPasswordHash = hashedMasterPassword,
|
||||
oneTimePassword = null,
|
||||
)
|
||||
} returns Unit.asSuccess()
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
|
||||
|
@ -603,7 +606,7 @@ class AuthRepositoryTest {
|
|||
assertEquals(originalUserState, awaitItem())
|
||||
|
||||
// Deleting the account sets the pending deletion flag
|
||||
repository.deleteAccount(password = masterPassword)
|
||||
repository.deleteAccountWithMasterPassword(masterPassword = masterPassword)
|
||||
|
||||
// Update the account. No changes are emitted because
|
||||
// the pending deletion blocks the update.
|
||||
|
@ -619,7 +622,7 @@ class AuthRepositoryTest {
|
|||
@Test
|
||||
fun `delete account fails if not logged in`() = runTest {
|
||||
val masterPassword = "hello world"
|
||||
val result = repository.deleteAccount(password = masterPassword)
|
||||
val result = repository.deleteAccountWithMasterPassword(masterPassword = masterPassword)
|
||||
assertEquals(DeleteAccountResult.Error, result)
|
||||
}
|
||||
|
||||
|
@ -632,7 +635,7 @@ class AuthRepositoryTest {
|
|||
authSdkSource.hashPassword(EMAIL, masterPassword, kdf, HashPurpose.SERVER_AUTHORIZATION)
|
||||
} returns Throwable("Fail").asFailure()
|
||||
|
||||
val result = repository.deleteAccount(password = masterPassword)
|
||||
val result = repository.deleteAccountWithMasterPassword(masterPassword = masterPassword)
|
||||
|
||||
assertEquals(DeleteAccountResult.Error, result)
|
||||
coVerify {
|
||||
|
@ -650,20 +653,26 @@ class AuthRepositoryTest {
|
|||
authSdkSource.hashPassword(EMAIL, masterPassword, kdf, HashPurpose.SERVER_AUTHORIZATION)
|
||||
} returns hashedMasterPassword.asSuccess()
|
||||
coEvery {
|
||||
accountsService.deleteAccount(hashedMasterPassword)
|
||||
accountsService.deleteAccount(
|
||||
masterPasswordHash = hashedMasterPassword,
|
||||
oneTimePassword = null,
|
||||
)
|
||||
} returns Throwable("Fail").asFailure()
|
||||
|
||||
val result = repository.deleteAccount(password = masterPassword)
|
||||
val result = repository.deleteAccountWithMasterPassword(masterPassword = masterPassword)
|
||||
|
||||
assertEquals(DeleteAccountResult.Error, result)
|
||||
coVerify {
|
||||
authSdkSource.hashPassword(EMAIL, masterPassword, kdf, HashPurpose.SERVER_AUTHORIZATION)
|
||||
accountsService.deleteAccount(hashedMasterPassword)
|
||||
accountsService.deleteAccount(
|
||||
masterPasswordHash = hashedMasterPassword,
|
||||
oneTimePassword = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete account succeeds`() = runTest {
|
||||
fun `deleteAccountWithMasterPassword succeeds`() = runTest {
|
||||
val masterPassword = "hello world"
|
||||
val hashedMasterPassword = "dlrow olleh"
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
|
@ -672,15 +681,45 @@ class AuthRepositoryTest {
|
|||
authSdkSource.hashPassword(EMAIL, masterPassword, kdf, HashPurpose.SERVER_AUTHORIZATION)
|
||||
} returns hashedMasterPassword.asSuccess()
|
||||
coEvery {
|
||||
accountsService.deleteAccount(hashedMasterPassword)
|
||||
accountsService.deleteAccount(
|
||||
masterPasswordHash = hashedMasterPassword,
|
||||
oneTimePassword = null,
|
||||
)
|
||||
} returns Unit.asSuccess()
|
||||
|
||||
val result = repository.deleteAccount(password = masterPassword)
|
||||
val result = repository.deleteAccountWithMasterPassword(masterPassword = masterPassword)
|
||||
|
||||
assertEquals(DeleteAccountResult.Success, result)
|
||||
coVerify {
|
||||
authSdkSource.hashPassword(EMAIL, masterPassword, kdf, HashPurpose.SERVER_AUTHORIZATION)
|
||||
accountsService.deleteAccount(hashedMasterPassword)
|
||||
accountsService.deleteAccount(
|
||||
masterPasswordHash = hashedMasterPassword,
|
||||
oneTimePassword = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteAccountWithOneTimePassword succeeds`() = runTest {
|
||||
val oneTimePassword = "123456"
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
coEvery {
|
||||
accountsService.deleteAccount(
|
||||
masterPasswordHash = null,
|
||||
oneTimePassword = oneTimePassword,
|
||||
)
|
||||
} returns Unit.asSuccess()
|
||||
|
||||
val result = repository.deleteAccountWithOneTimePassword(
|
||||
oneTimePassword = oneTimePassword,
|
||||
)
|
||||
|
||||
assertEquals(DeleteAccountResult.Success, result)
|
||||
coVerify {
|
||||
accountsService.deleteAccount(
|
||||
masterPasswordHash = null,
|
||||
oneTimePassword = oneTimePassword,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -81,7 +81,9 @@ class DeleteAccountViewModelTest : BaseViewModelTest() {
|
|||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
val masterPassword = "ckasb kcs ja"
|
||||
coEvery { authRepo.deleteAccount(masterPassword) } returns DeleteAccountResult.Success
|
||||
coEvery {
|
||||
authRepo.deleteAccountWithMasterPassword(masterPassword)
|
||||
} returns DeleteAccountResult.Success
|
||||
|
||||
viewModel.trySendAction(
|
||||
DeleteAccountAction.DeleteAccountConfirmDialogClick(
|
||||
|
@ -95,7 +97,7 @@ class DeleteAccountViewModelTest : BaseViewModelTest() {
|
|||
)
|
||||
|
||||
coVerify {
|
||||
authRepo.deleteAccount(masterPassword)
|
||||
authRepo.deleteAccountWithMasterPassword(masterPassword)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -122,7 +124,9 @@ class DeleteAccountViewModelTest : BaseViewModelTest() {
|
|||
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
|
||||
coEvery {
|
||||
authRepo.deleteAccountWithMasterPassword(masterPassword)
|
||||
} returns DeleteAccountResult.Error
|
||||
|
||||
viewModel.trySendAction(DeleteAccountAction.DeleteAccountConfirmDialogClick(masterPassword))
|
||||
|
||||
|
@ -136,7 +140,7 @@ class DeleteAccountViewModelTest : BaseViewModelTest() {
|
|||
)
|
||||
|
||||
coVerify {
|
||||
authRepo.deleteAccount(masterPassword)
|
||||
authRepo.deleteAccountWithMasterPassword(masterPassword)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,7 +4,10 @@ import androidx.compose.ui.test.filterToOne
|
|||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onFirst
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
|
@ -123,7 +126,52 @@ class DeleteAccountConfirmationScreenTest : BaseComposeTest() {
|
|||
viewModel.trySendAction(DeleteAccountConfirmationAction.DeleteAccountAcknowledge)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Delete account button click should emit DeleteAccountClick`() {
|
||||
mutableStateFlow.update {
|
||||
DEFAULT_STATE.copy(
|
||||
verificationCode = "123456",
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Delete account")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(DeleteAccountConfirmationAction.DeleteAccountClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Resend code button click should emit ResendCodeClick`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Resend code")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(DeleteAccountConfirmationAction.ResendCodeClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Verification code text input should emit VerificationCodeTextChange`() {
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Verification code")
|
||||
.onFirst()
|
||||
.performTextInput("123456")
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(
|
||||
DeleteAccountConfirmationAction.VerificationCodeTextChange("123456"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE: DeleteAccountConfirmationState =
|
||||
DeleteAccountConfirmationState(dialog = null)
|
||||
DeleteAccountConfirmationState(
|
||||
dialog = null,
|
||||
verificationCode = "",
|
||||
)
|
||||
|
|
|
@ -2,9 +2,14 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deletea
|
|||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
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.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
|
||||
|
@ -80,6 +85,129 @@ class DeleteAccountConfirmationViewModelTest : BaseViewModelTest() {
|
|||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `on DeleteAccountClick with DeleteAccountResult Success should set dialog to Success`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
authRepo.deleteAccountWithOneTimePassword("123456")
|
||||
} returns DeleteAccountResult.Success
|
||||
val initialState = DEFAULT_STATE.copy(
|
||||
verificationCode = "123456",
|
||||
)
|
||||
val viewModel = createViewModel(state = initialState)
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(initialState, awaitItem())
|
||||
viewModel.trySendAction(
|
||||
DeleteAccountConfirmationAction.DeleteAccountClick,
|
||||
)
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Loading(),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.DeleteSuccess(),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
coVerify { authRepo.deleteAccountWithOneTimePassword("123456") }
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `on DeleteAccountClick with DeleteAccountResult Error should set dialog to Error`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
authRepo.deleteAccountWithOneTimePassword("123456")
|
||||
} returns DeleteAccountResult.Error
|
||||
val initialState = DEFAULT_STATE.copy(
|
||||
verificationCode = "123456",
|
||||
)
|
||||
val viewModel = createViewModel(
|
||||
state = initialState,
|
||||
)
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(initialState, awaitItem())
|
||||
viewModel.trySendAction(
|
||||
DeleteAccountConfirmationAction.DeleteAccountClick,
|
||||
)
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Loading(),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error(
|
||||
message = R.string.generic_error_message.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
coVerify { authRepo.deleteAccountWithOneTimePassword("123456") }
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `on ResendCodeClick with requestOneTimePasscode Success should set dialog to null`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
authRepo.requestOneTimePasscode()
|
||||
} returns RequestOtpResult.Success
|
||||
val viewModel = createViewModel(state = DEFAULT_STATE)
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
viewModel.trySendAction(
|
||||
DeleteAccountConfirmationAction.ResendCodeClick,
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Loading(),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
}
|
||||
coVerify { authRepo.requestOneTimePasscode() }
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `on ResendCodeClick with requestOneTimePasscode Success should set dialog to Error`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
authRepo.requestOneTimePasscode()
|
||||
} returns RequestOtpResult.Error(message = "Error")
|
||||
val viewModel = createViewModel(state = DEFAULT_STATE)
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
viewModel.trySendAction(
|
||||
DeleteAccountConfirmationAction.ResendCodeClick,
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Loading(),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error(
|
||||
message = R.string.generic_error_message.asText(),
|
||||
),
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
coVerify { authRepo.requestOneTimePasscode() }
|
||||
}
|
||||
|
||||
private fun createViewModel(
|
||||
authenticationRepository: AuthRepository = authRepo,
|
||||
state: DeleteAccountConfirmationState? = null,
|
||||
|
@ -90,4 +218,7 @@ class DeleteAccountConfirmationViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
|
||||
private val DEFAULT_STATE: DeleteAccountConfirmationState =
|
||||
DeleteAccountConfirmationState(dialog = null)
|
||||
DeleteAccountConfirmationState(
|
||||
dialog = null,
|
||||
verificationCode = "",
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue