mirror of
https://github.com/bitwarden/android.git
synced 2025-03-16 19:28:44 +03:00
BIT-1111: Add delete account logic (#252)
This commit is contained in:
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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
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.KdfTypeJson.PBKDF2_SHA256
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
|
||||
|
@ -17,13 +18,24 @@ import retrofit2.create
|
|||
class AccountsServiceTest : BaseServiceTest() {
|
||||
|
||||
private val accountsApi: AccountsApi = retrofit.create()
|
||||
private val authenticatedAccountsApi: AuthenticatedAccountsApi = retrofit.create()
|
||||
private val service = AccountsServiceImpl(
|
||||
accountsApi = accountsApi,
|
||||
authenticatedAccountsApi = authenticatedAccountsApi,
|
||||
json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
},
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `deleteAccount with empty response is success`() = runTest {
|
||||
val masterPasswordHash = "37y4d8r379r4789nt387r39k3dr87nr93"
|
||||
val json = ""
|
||||
val response = MockResponse().setBody(json)
|
||||
server.enqueue(response)
|
||||
assertTrue(service.deleteAccount(masterPasswordHash).isSuccess)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preLogin with unknown kdf type be failure`() = runTest {
|
||||
val json = """
|
||||
|
|
|
@ -26,10 +26,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.toSdkParams
|
||||
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
|
||||
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.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||
|
@ -44,6 +46,7 @@ import kotlinx.coroutines.test.runTest
|
|||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
|
@ -118,6 +121,74 @@ class AuthRepositoryTest {
|
|||
assertNull(repository.rememberedEmailAddress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete account fails if not logged in`() = runTest {
|
||||
val masterPassword = "hello world"
|
||||
val result = repository.deleteAccount(password = masterPassword)
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete account fails if hashPassword fails`() = runTest {
|
||||
val masterPassword = "hello world"
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
val kdf = SINGLE_USER_STATE_1.activeAccount.profile.toSdkParams()
|
||||
coEvery {
|
||||
authSdkSource.hashPassword(EMAIL, masterPassword, kdf)
|
||||
} returns Throwable("Fail").asFailure()
|
||||
|
||||
val result = repository.deleteAccount(password = masterPassword)
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
coVerify {
|
||||
authSdkSource.hashPassword(EMAIL, masterPassword, kdf)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete account fails if deleteAccount fails`() = runTest {
|
||||
val masterPassword = "hello world"
|
||||
val hashedMasterPassword = "dlrow olleh"
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
val kdf = SINGLE_USER_STATE_1.activeAccount.profile.toSdkParams()
|
||||
coEvery {
|
||||
authSdkSource.hashPassword(EMAIL, masterPassword, kdf)
|
||||
} returns hashedMasterPassword.asSuccess()
|
||||
coEvery {
|
||||
accountsService.deleteAccount(hashedMasterPassword)
|
||||
} returns Throwable("Fail").asFailure()
|
||||
|
||||
val result = repository.deleteAccount(password = masterPassword)
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
coVerify {
|
||||
authSdkSource.hashPassword(EMAIL, masterPassword, kdf)
|
||||
accountsService.deleteAccount(hashedMasterPassword)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete account succeeds`() = runTest {
|
||||
val masterPassword = "hello world"
|
||||
val hashedMasterPassword = "dlrow olleh"
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
|
||||
val kdf = SINGLE_USER_STATE_1.activeAccount.profile.toSdkParams()
|
||||
coEvery {
|
||||
authSdkSource.hashPassword(EMAIL, masterPassword, kdf)
|
||||
} returns hashedMasterPassword.asSuccess()
|
||||
coEvery {
|
||||
accountsService.deleteAccount(hashedMasterPassword)
|
||||
} returns Unit.asSuccess()
|
||||
|
||||
val result = repository.deleteAccount(password = masterPassword)
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
coVerify {
|
||||
authSdkSource.hashPassword(EMAIL, masterPassword, kdf)
|
||||
accountsService.deleteAccount(hashedMasterPassword)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login when pre login fails should return Error with no message`() = runTest {
|
||||
coEvery {
|
||||
|
|
|
@ -1,10 +1,24 @@
|
|||
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount
|
||||
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
@ -16,7 +30,7 @@ class DeleteAccountScreenTest : BaseComposeTest() {
|
|||
private val mutableEventFlow = MutableSharedFlow<DeleteAccountEvent>(
|
||||
extraBufferCapacity = Int.MAX_VALUE,
|
||||
)
|
||||
private val mutableStateFlow = MutableStateFlow(Unit)
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val viewModel = mockk<DeleteAccountViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
|
@ -37,4 +51,123 @@ class DeleteAccountScreenTest : BaseComposeTest() {
|
|||
mutableEventFlow.tryEmit(DeleteAccountEvent.NavigateBack)
|
||||
assertTrue(onNavigateBackCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancel click should emit CancelClick`() {
|
||||
composeTestRule.onNodeWithText("Cancel").performScrollTo().performClick()
|
||||
verify { viewModel.trySendAction(DeleteAccountAction.CancelClick) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading dialog presence should update with dialog state`() {
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Loading")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = DeleteAccountState.DeleteAccountDialog.Loading)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Loading")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error dialog presence should update with dialog state`() {
|
||||
val message = "hello world"
|
||||
composeTestRule
|
||||
.onAllNodesWithText(message)
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertDoesNotExist()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = DeleteAccountState.DeleteAccountDialog.Error(message.asText()))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText(message)
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete account dialog should dismiss on cancel click`() {
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Master password confirmation")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertDoesNotExist()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Delete account")
|
||||
.filterToOne(hasClickAction())
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Master password confirmation")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertExists()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Cancel")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Master password confirmation")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete account dialog should emit DeleteAccountClick on submit click`() {
|
||||
val password = "hello world"
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Master password confirmation")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertDoesNotExist()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Delete account")
|
||||
.filterToOne(hasClickAction())
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Master password confirmation")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertExists()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Submit")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsNotEnabled()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Master password")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performTextInput(password)
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Submit")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsEnabled()
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Master password confirmation")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertDoesNotExist()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(password))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE: DeleteAccountState = DeleteAccountState(
|
||||
dialog = null,
|
||||
)
|
||||
|
|
|
@ -1,14 +1,39 @@
|
|||
package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount
|
||||
|
||||
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.platform.util.asFailure
|
||||
import com.x8bit.bitwarden.data.platform.util.asSuccess
|
||||
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.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class DeleteAccountViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val authRepo: AuthRepository = mockk(relaxed = true)
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when not set`() {
|
||||
val viewModel = createViewModel(state = null)
|
||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when set`() {
|
||||
val state = DEFAULT_STATE.copy(
|
||||
dialog = DeleteAccountState.DeleteAccountDialog.Error("Hello".asText()),
|
||||
)
|
||||
val viewModel = createViewModel(state = state)
|
||||
assertEquals(state, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on CancelClick should emit NavigateBack`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
@ -28,16 +53,61 @@ class DeleteAccountViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `on DeleteAccountClick should emit ShowToast`() = runTest {
|
||||
fun `on DeleteAccountClick should make the delete call`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick)
|
||||
assertEquals(
|
||||
DeleteAccountEvent.ShowToast("Not yet implemented.".asText()),
|
||||
awaitItem(),
|
||||
)
|
||||
val masterPassword = "ckasb kcs ja"
|
||||
coEvery { authRepo.deleteAccount(masterPassword) } returns Unit.asSuccess()
|
||||
|
||||
viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(masterPassword))
|
||||
|
||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||
coVerify {
|
||||
authRepo.deleteAccount(masterPassword)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(): DeleteAccountViewModel = DeleteAccountViewModel()
|
||||
@Test
|
||||
fun `on DeleteAccountClick should update dialog state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val masterPassword = "ckasb kcs ja"
|
||||
coEvery { authRepo.deleteAccount(masterPassword) } returns Throwable("Fail").asFailure()
|
||||
|
||||
viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(masterPassword))
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialog = DeleteAccountState.DeleteAccountDialog.Error(
|
||||
message = R.string.generic_error_message.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
coVerify {
|
||||
authRepo.deleteAccount(masterPassword)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on DismissDialog should clear dialog state`() = runTest {
|
||||
val state = DEFAULT_STATE.copy(
|
||||
dialog = DeleteAccountState.DeleteAccountDialog.Error("Hello".asText()),
|
||||
)
|
||||
val viewModel = createViewModel(state = state)
|
||||
|
||||
viewModel.trySendAction(DeleteAccountAction.DismissDialog)
|
||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
private fun createViewModel(
|
||||
authenticationRepository: AuthRepository = authRepo,
|
||||
state: DeleteAccountState? = DEFAULT_STATE,
|
||||
): DeleteAccountViewModel = DeleteAccountViewModel(
|
||||
authRepository = authenticationRepository,
|
||||
savedStateHandle = SavedStateHandle().apply { set("state", state) },
|
||||
)
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE: DeleteAccountState = DeleteAccountState(
|
||||
dialog = null,
|
||||
)
|
||||
|
|
Loading…
Add table
Reference in a new issue