diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthRequestsApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthRequestsApi.kt index ca4cefdedc..11af508339 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthRequestsApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AuthRequestsApi.kt @@ -1,10 +1,13 @@ package com.x8bit.bitwarden.data.auth.datasource.network.api import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestUpdateRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path /** * Defines raw calls under the /auth-requests API. @@ -19,6 +22,15 @@ interface AuthRequestsApi { @Body body: AuthRequestRequestJson, ): Result + /** + * Updates an authentication request. + */ + @PUT("/auth-requests/{id}") + suspend fun updateAuthRequest( + @Path("id") userId: String, + @Body body: AuthRequestUpdateRequestJson, + ): Result + /** * Gets a list of auth requests for this device. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/AuthRequestUpdateRequestJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/AuthRequestUpdateRequestJson.kt new file mode 100644 index 0000000000..fc2c3a86d0 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/AuthRequestUpdateRequestJson.kt @@ -0,0 +1,22 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Request body for updating an auth request. + */ +@Serializable +data class AuthRequestUpdateRequestJson( + @SerialName("key") + val key: String, + + @SerialName("masterPasswordHash") + val masterPasswordHash: String?, + + @SerialName("deviceIdentifier") + val deviceId: String, + + @SerialName("requestApproved") + val isApproved: Boolean, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsService.kt index 53815e13ca..409e3f8eeb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsService.kt @@ -10,4 +10,15 @@ interface AuthRequestsService { * Gets the list of auth requests for the current user. */ suspend fun getAuthRequests(): Result + + /** + * Updates an approval request. + */ + suspend fun updateAuthRequest( + requestId: String, + key: String, + masterPasswordHash: String?, + deviceId: String, + isApproved: Boolean, + ): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsServiceImpl.kt index 4eaf0168e5..bc558fc6aa 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsServiceImpl.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.auth.datasource.network.service import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthRequestsApi +import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestUpdateRequestJson import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson class AuthRequestsServiceImpl( @@ -8,4 +9,21 @@ class AuthRequestsServiceImpl( ) : AuthRequestsService { override suspend fun getAuthRequests(): Result = authRequestsApi.getAuthRequests() + + override suspend fun updateAuthRequest( + requestId: String, + key: String, + masterPasswordHash: String?, + deviceId: String, + isApproved: Boolean, + ): Result = + authRequestsApi.updateAuthRequest( + userId = requestId, + body = AuthRequestUpdateRequestJson( + key = key, + masterPasswordHash = masterPasswordHash, + deviceId = deviceId, + isApproved = isApproved, + ), + ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index 4d2637f9a0..37da6b5e82 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -196,6 +196,17 @@ interface AuthRepository : AuthenticatorProvider { */ suspend fun getAuthRequests(): AuthRequestsResult + /** + * Approves or declines the request corresponding to this [requestId] based on [publicKey] + * according to [isApproved]. + */ + suspend fun updateAuthRequest( + requestId: String, + masterPasswordHash: String?, + publicKey: String, + isApproved: Boolean, + ): AuthRequestResult + /** * Get a [Boolean] indicating whether this is a known device. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index b679ed333e..a9003cc7f4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -59,6 +59,7 @@ import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.platform.util.asFailure import com.x8bit.bitwarden.data.platform.util.flatMap +import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.data.vault.repository.VaultRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -76,7 +77,7 @@ import javax.inject.Singleton /** * Default implementation of [AuthRepository]. */ -@Suppress("LongParameterList", "TooManyFunctions") +@Suppress("LargeClass", "LongParameterList", "TooManyFunctions") @Singleton class AuthRepositoryImpl( private val accountsService: AccountsService, @@ -87,6 +88,7 @@ class AuthRepositoryImpl( private val newAuthRequestService: NewAuthRequestService, private val organizationService: OrganizationService, private val authSdkSource: AuthSdkSource, + private val vaultSdkSource: VaultSdkSource, private val authDiskSource: AuthDiskSource, private val environmentRepository: EnvironmentRepository, private val settingsRepository: SettingsRepository, @@ -693,6 +695,51 @@ class AuthRepositoryImpl( }, ) + override suspend fun updateAuthRequest( + requestId: String, + masterPasswordHash: String?, + publicKey: String, + isApproved: Boolean, + ): AuthRequestResult { + val userId = activeUserId ?: return AuthRequestResult.Error + return vaultSdkSource + .getAuthRequestKey( + publicKey = publicKey, + userId = userId, + ) + .flatMap { + authRequestsService + .updateAuthRequest( + requestId = requestId, + key = it, + deviceId = authDiskSource.uniqueAppId, + masterPasswordHash = masterPasswordHash, + isApproved = isApproved, + ) + } + .map { request -> + AuthRequestResult.Success( + authRequest = AuthRequest( + id = request.id, + publicKey = request.publicKey, + platform = request.platform, + ipAddress = request.ipAddress, + key = request.key, + masterPasswordHash = request.masterPasswordHash, + creationDate = request.creationDate, + responseDate = request.responseDate, + requestApproved = request.requestApproved ?: false, + originUrl = request.originUrl, + fingerprint = "", + ), + ) + } + .fold( + onFailure = { AuthRequestResult.Error }, + onSuccess = { it }, + ) + } + override suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult = devicesService .getIsKnownDevice( diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt index 37b019479a..f71a9b24b2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt @@ -15,6 +15,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository +import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.data.vault.repository.VaultRepository import dagger.Module import dagger.Provides @@ -41,6 +42,7 @@ object AuthRepositoryModule { newAuthRequestService: NewAuthRequestService, organizationService: OrganizationService, authSdkSource: AuthSdkSource, + vaultSdkSource: VaultSdkSource, authDiskSource: AuthDiskSource, dispatchers: DispatcherManager, environmentRepository: EnvironmentRepository, @@ -55,6 +57,7 @@ object AuthRepositoryModule { newAuthRequestService = newAuthRequestService, organizationService = organizationService, authSdkSource = authSdkSource, + vaultSdkSource = vaultSdkSource, authDiskSource = authDiskSource, haveIBeenPwnedService = haveIBeenPwnedService, dispatcherManager = dispatchers, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt index 5908980797..34ff8c3468 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt @@ -59,6 +59,14 @@ interface VaultSdkSource { encryptedPin: String, ): Result + /** + * Gets the key for an auth request that is required to approve or decline it. + */ + suspend fun getAuthRequestKey( + publicKey: String, + userId: String, + ): Result + /** * Gets the user's encryption key, which can be used to later unlock their vault via a call to * [initializeCrypto] with [InitUserCryptoMethod.DecryptedKey]. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt index ae5efc6061..1ba22b806c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt @@ -56,6 +56,16 @@ class VaultSdkSourceImpl( .derivePinUserKey(encryptedPin = encryptedPin) } + override suspend fun getAuthRequestKey( + publicKey: String, + userId: String, + ): Result = + runCatching { + getClient(userId = userId) + .auth() + .approveAuthRequest(publicKey) + } + override suspend fun getUserEncryptionKey( userId: String, ): Result = diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/LifecycleEventEffect.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/LifecycleEventEffect.kt new file mode 100644 index 0000000000..da0c30cf87 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/LifecycleEventEffect.kt @@ -0,0 +1,32 @@ +package com.x8bit.bitwarden.ui.platform.base.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner + +/** + * Creates a side effect to observe lifecycle events. + */ +@Composable +fun LivecycleEventEffect( + onEvent: (owner: LifecycleOwner, event: Lifecycle.Event) -> Unit, +) { + val eventHandler = rememberUpdatedState(onEvent) + val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) + + DisposableEffect(lifecycleOwner.value) { + val lifecycle = lifecycleOwner.value.lifecycle + val observer = LifecycleEventObserver { owner, event -> + eventHandler.value(owner, event) + } + + lifecycle.addObserver(observer) + onDispose { + lifecycle.removeObserver(observer) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalScreen.kt index 3c4256db16..ce055c597f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalScreen.kt @@ -29,6 +29,9 @@ 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.BitwardenErrorContent import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent @@ -61,6 +64,20 @@ fun LoginApprovalScreen( } } + BitwardenBasicDialog( + visibilityState = if (state.shouldShowErrorDialog) { + BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + ) + } else { + BasicDialogState.Hidden + }, + onDismissRequest = remember(viewModel) { + { viewModel.trySendAction(LoginApprovalAction.ErrorDialogDismiss) } + }, + ) + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) BitwardenScaffold( modifier = Modifier diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModel.kt index f4ef6534b4..a636282aeb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModel.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginap 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.data.auth.repository.model.AuthRequestResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModel @@ -29,6 +30,10 @@ class LoginApprovalViewModel @Inject constructor( initialState = savedStateHandle[KEY_STATE] ?: LoginApprovalState( fingerprint = LoginApprovalArgs(savedStateHandle).fingerprint, + masterPasswordHash = null, + publicKey = "", + requestId = "", + shouldShowErrorDialog = false, viewState = LoginApprovalState.ViewState.Loading, ), ) { @@ -52,16 +57,35 @@ class LoginApprovalViewModel @Inject constructor( LoginApprovalAction.ApproveRequestClick -> handleApproveRequestClicked() LoginApprovalAction.CloseClick -> handleCloseClicked() LoginApprovalAction.DeclineRequestClick -> handleDeclineRequestClicked() + LoginApprovalAction.ErrorDialogDismiss -> handleErrorDialogDismissed() + + is LoginApprovalAction.Internal.ApproveRequestResultReceive -> { + handleApproveRequestResultReceived(action) + } is LoginApprovalAction.Internal.AuthRequestResultReceive -> { handleAuthRequestResultReceived(action) } + + is LoginApprovalAction.Internal.DeclineRequestResultReceive -> { + handleDeclineRequestResultReceived(action) + } } } private fun handleApproveRequestClicked() { - // TODO BIT-1565 implement approve login request - sendEvent(LoginApprovalEvent.ShowToast("Not yet implemented".asText())) + viewModelScope.launch { + trySendAction( + LoginApprovalAction.Internal.DeclineRequestResultReceive( + result = authRepository.updateAuthRequest( + requestId = mutableStateFlow.value.requestId, + masterPasswordHash = mutableStateFlow.value.masterPasswordHash, + publicKey = mutableStateFlow.value.publicKey, + isApproved = true, + ), + ), + ) + } } private fun handleCloseClicked() { @@ -69,31 +93,85 @@ class LoginApprovalViewModel @Inject constructor( } private fun handleDeclineRequestClicked() { - // TODO BIT-1565 implement decline login request - sendEvent(LoginApprovalEvent.ShowToast("Not yet implemented".asText())) + viewModelScope.launch { + trySendAction( + LoginApprovalAction.Internal.DeclineRequestResultReceive( + result = authRepository.updateAuthRequest( + requestId = mutableStateFlow.value.requestId, + masterPasswordHash = mutableStateFlow.value.masterPasswordHash, + publicKey = mutableStateFlow.value.publicKey, + isApproved = false, + ), + ), + ) + } + } + + private fun handleErrorDialogDismissed() { + mutableStateFlow.update { + it.copy(shouldShowErrorDialog = false) + } + } + + private fun handleApproveRequestResultReceived( + action: LoginApprovalAction.Internal.ApproveRequestResultReceive, + ) { + when (action.result) { + is AuthRequestResult.Success -> { + sendEvent(LoginApprovalEvent.ShowToast(R.string.login_approved.asText())) + sendEvent(LoginApprovalEvent.NavigateBack) + } + + is AuthRequestResult.Error -> { + mutableStateFlow.update { + it.copy(shouldShowErrorDialog = true) + } + } + } } private fun handleAuthRequestResultReceived( action: LoginApprovalAction.Internal.AuthRequestResultReceive, ) { val email = authRepository.userStateFlow.value?.activeAccount?.email ?: return - mutableStateFlow.update { - it.copy( - viewState = when (val result = action.authRequestResult) { - is AuthRequestResult.Success -> { - LoginApprovalState.ViewState.Content( - deviceType = result.authRequest.platform, - domainUrl = result.authRequest.originUrl, - email = email, - fingerprint = result.authRequest.fingerprint, - ipAddress = result.authRequest.ipAddress, - time = dateTimeFormatter.format(result.authRequest.creationDate), - ) - } + when (val result = action.authRequestResult) { + is AuthRequestResult.Success -> mutableStateFlow.update { + it.copy( + masterPasswordHash = result.authRequest.masterPasswordHash, + publicKey = result.authRequest.publicKey, + requestId = result.authRequest.id, + viewState = LoginApprovalState.ViewState.Content( + deviceType = result.authRequest.platform, + domainUrl = result.authRequest.originUrl, + email = email, + fingerprint = result.authRequest.fingerprint, + ipAddress = result.authRequest.ipAddress, + time = dateTimeFormatter.format(result.authRequest.creationDate), + ), + ) + } - is AuthRequestResult.Error -> LoginApprovalState.ViewState.Error - }, - ) + is AuthRequestResult.Error -> mutableStateFlow.update { + it.copy( + viewState = LoginApprovalState.ViewState.Error, + ) + } + } + } + + private fun handleDeclineRequestResultReceived( + action: LoginApprovalAction.Internal.DeclineRequestResultReceive, + ) { + when (action.result) { + is AuthRequestResult.Success -> { + sendEvent(LoginApprovalEvent.NavigateBack) + } + + is AuthRequestResult.Error -> { + mutableStateFlow.update { + it.copy(shouldShowErrorDialog = true) + } + } } } } @@ -103,8 +181,13 @@ class LoginApprovalViewModel @Inject constructor( */ @Parcelize data class LoginApprovalState( - val fingerprint: String, val viewState: ViewState, + val shouldShowErrorDialog: Boolean, + // Internal + val fingerprint: String, + val masterPasswordHash: String?, + val publicKey: String, + val requestId: String, ) : Parcelable { /** * Represents the specific view states for the [LoginApprovalScreen]. @@ -176,15 +259,34 @@ sealed class LoginApprovalAction { */ data object DeclineRequestClick : LoginApprovalAction() + /** + * User dismissed the error dialog. + */ + data object ErrorDialogDismiss : LoginApprovalAction() + /** * Models action the view model could send itself. */ sealed class Internal : LoginApprovalAction() { + /** + * A new result for a request to approve this request has been received. + */ + data class ApproveRequestResultReceive( + val result: AuthRequestResult, + ) : Internal() + /** * An auth request result has been received to populate the data on the screen. */ data class AuthRequestResultReceive( val authRequestResult: AuthRequestResult, ) : Internal() + + /** + * A new result for a request to decline this request has been received. + */ + data class DeclineRequestResultReceive( + val result: AuthRequestResult, + ) : Internal() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt index 970515513e..5ec752578c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreen.kt @@ -35,8 +35,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.base.util.LivecycleEventEffect import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButtonWithIcon import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent @@ -72,6 +74,15 @@ fun PendingRequestsScreen( } } + LivecycleEventEffect { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + viewModel.trySendAction(PendingRequestsAction.LifecycleResume) + } + else -> Unit + } + } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) BitwardenScaffold( modifier = Modifier diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt index 75008d3de4..bd6c0241a3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModel.kt @@ -23,7 +23,7 @@ private const val KEY_STATE = "state" */ @HiltViewModel class PendingRequestsViewModel @Inject constructor( - authRepository: AuthRepository, + private val authRepository: AuthRepository, savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: PendingRequestsState( @@ -36,19 +36,14 @@ class PendingRequestsViewModel @Inject constructor( .withZone(TimeZone.getDefault().toZoneId()) init { - viewModelScope.launch { - trySendAction( - PendingRequestsAction.Internal.AuthRequestsResultReceive( - authRequestsResult = authRepository.getAuthRequests(), - ), - ) - } + updateAuthRequestList() } override fun handleAction(action: PendingRequestsAction) { when (action) { PendingRequestsAction.CloseClick -> handleCloseClicked() PendingRequestsAction.DeclineAllRequestsClick -> handleDeclineAllRequestsClicked() + PendingRequestsAction.LifecycleResume -> handleOnLifecycleResumed() is PendingRequestsAction.PendingRequestRowClick -> { handlePendingRequestRowClicked(action) } @@ -67,6 +62,10 @@ class PendingRequestsViewModel @Inject constructor( sendEvent(PendingRequestsEvent.ShowToast("Not yet implemented.".asText())) } + private fun handleOnLifecycleResumed() { + updateAuthRequestList() + } + private fun handlePendingRequestRowClicked( action: PendingRequestsAction.PendingRequestRowClick, ) { @@ -102,6 +101,17 @@ class PendingRequestsViewModel @Inject constructor( ) } } + + private fun updateAuthRequestList() { + // TODO BIT-1574: Display pull to refresh + viewModelScope.launch { + trySendAction( + PendingRequestsAction.Internal.AuthRequestsResultReceive( + authRequestsResult = authRepository.getAuthRequests(), + ), + ) + } + } } /** @@ -195,6 +205,11 @@ sealed class PendingRequestsAction { */ data object DeclineAllRequestsClick : PendingRequestsAction() + /** + * The screen has been re-opened and should be updated. + */ + data object LifecycleResume : PendingRequestsAction() + /** * The user has clicked one of the pending request rows. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsServiceTest.kt index f0a46ba2de..c7a295a2dc 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AuthRequestsServiceTest.kt @@ -1,12 +1,15 @@ package com.x8bit.bitwarden.data.auth.datasource.network.service import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthRequestsApi +import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestsResponseJson import com.x8bit.bitwarden.data.platform.base.BaseServiceTest import kotlinx.coroutines.test.runTest import okhttp3.mockwebserver.MockResponse +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import retrofit2.create +import java.time.ZonedDateTime class AuthRequestsServiceTest : BaseServiceTest() { @@ -45,4 +48,57 @@ class AuthRequestsServiceTest : BaseServiceTest() { val actual = service.getAuthRequests() assertTrue(actual.isSuccess) } + + @Test + fun `updateAuthRequest when request response is Failure should return Failure`() = runTest { + val response = MockResponse().setResponseCode(400) + server.enqueue(response) + val actual = service.updateAuthRequest( + requestId = "userId", + deviceId = "deviceId", + key = "secureKey", + masterPasswordHash = null, + isApproved = true, + ) + assertTrue(actual.isFailure) + } + + @Test + fun `updateAuthRequest when request response is Success should return Success`() = runTest { + val json = """ + { + "id": "1", + "publicKey": "2", + "requestDeviceType": "Android", + "requestIpAddress": "1.0.0.1", + "key": "key", + "masterPasswordHash": "verySecureHash", + "creationDate": "2024-09-13T01:00:00.00Z", + "requestApproved": true, + "origin": "www.bitwarden.com" + } + """ + val expected = AuthRequestsResponseJson.AuthRequest( + id = "1", + publicKey = "2", + platform = "Android", + ipAddress = "1.0.0.1", + key = "key", + masterPasswordHash = "verySecureHash", + creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"), + responseDate = null, + requestApproved = true, + originUrl = "www.bitwarden.com", + ) + val response = MockResponse().setBody(json).setResponseCode(200) + server.enqueue(response) + val actual = service.updateAuthRequest( + requestId = "userId", + deviceId = "deviceId", + key = "secureKey", + masterPasswordHash = "verySecureHash", + isApproved = true, + ) + assertEquals(Result.success(expected), actual) + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 85fe676e00..1dd5576547 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -70,6 +70,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentReposito import com.x8bit.bitwarden.data.platform.util.asFailure import com.x8bit.bitwarden.data.platform.util.asSuccess import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization +import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.VaultState import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult @@ -158,6 +159,14 @@ class AuthRepositoryTest { ), ) } + private val vaultSdkSource = mockk { + coEvery { + getAuthRequestKey( + publicKey = PUBLIC_KEY, + userId = USER_ID_1, + ) + } returns "AsymmetricEncString".asSuccess() + } private val userLogoutManager: UserLogoutManager = mockk { every { logout(any()) } just runs } @@ -173,6 +182,7 @@ class AuthRepositoryTest { newAuthRequestService = newAuthRequestService, organizationService = organizationService, authSdkSource = authSdkSource, + vaultSdkSource = vaultSdkSource, authDiskSource = fakeAuthDiskSource, environmentRepository = fakeEnvironmentRepository, settingsRepository = settingsRepository, @@ -2413,6 +2423,143 @@ class AuthRepositoryTest { assertEquals(expected, result) } + @Test + fun `updateAuthRequest should return failure when sdk returns failure`() = runTest { + coEvery { + vaultSdkSource.getAuthRequestKey( + publicKey = PUBLIC_KEY, + userId = USER_ID_1, + ) + } returns Throwable("Fail").asFailure() + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + + val result = repository.updateAuthRequest( + requestId = "requestId", + masterPasswordHash = "masterPasswordHash", + publicKey = PUBLIC_KEY, + isApproved = false, + ) + + coVerify(exactly = 1) { + vaultSdkSource.getAuthRequestKey( + publicKey = PUBLIC_KEY, + userId = USER_ID_1, + ) + } + assertEquals(AuthRequestResult.Error, result) + } + + @Test + fun `updateAuthRequest should return failure when service returns failure`() = runTest { + val requestId = "requestId" + val passwordHash = "masterPasswordHash" + val encodedKey = "encodedKey" + coEvery { + vaultSdkSource.getAuthRequestKey( + publicKey = PUBLIC_KEY, + userId = USER_ID_1, + ) + } returns encodedKey.asSuccess() + coEvery { + authRequestsService.updateAuthRequest( + requestId = requestId, + masterPasswordHash = passwordHash, + key = encodedKey, + deviceId = UNIQUE_APP_ID, + isApproved = false, + ) + } returns Throwable("Mission failed").asFailure() + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + + val result = repository.updateAuthRequest( + requestId = "requestId", + masterPasswordHash = "masterPasswordHash", + publicKey = PUBLIC_KEY, + isApproved = false, + ) + + coVerify(exactly = 1) { + vaultSdkSource.getAuthRequestKey( + publicKey = PUBLIC_KEY, + userId = USER_ID_1, + ) + } + assertEquals(AuthRequestResult.Error, result) + } + + @Suppress("LongMethod") + @Test + fun `updateAuthRequest should return success when service & sdk return success`() = runTest { + val requestId = "requestId" + val passwordHash = "masterPasswordHash" + val encodedKey = "encodedKey" + val responseJson = AuthRequestsResponseJson.AuthRequest( + id = requestId, + publicKey = PUBLIC_KEY, + platform = "Android", + ipAddress = "192.168.0.1", + key = "key", + masterPasswordHash = passwordHash, + creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), + responseDate = null, + requestApproved = true, + originUrl = "www.bitwarden.com", + ) + val expected = AuthRequestResult.Success( + authRequest = AuthRequest( + id = requestId, + publicKey = PUBLIC_KEY, + platform = "Android", + ipAddress = "192.168.0.1", + key = "key", + masterPasswordHash = passwordHash, + creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), + responseDate = null, + requestApproved = true, + originUrl = "www.bitwarden.com", + fingerprint = "", + ), + ) + coEvery { + vaultSdkSource.getAuthRequestKey( + publicKey = PUBLIC_KEY, + userId = USER_ID_1, + ) + } returns encodedKey.asSuccess() + coEvery { + authRequestsService.updateAuthRequest( + requestId = requestId, + masterPasswordHash = passwordHash, + key = encodedKey, + deviceId = UNIQUE_APP_ID, + isApproved = false, + ) + } returns responseJson.asSuccess() + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + + val result = repository.updateAuthRequest( + requestId = requestId, + masterPasswordHash = passwordHash, + publicKey = PUBLIC_KEY, + isApproved = false, + ) + + coVerify(exactly = 1) { + vaultSdkSource.getAuthRequestKey( + publicKey = PUBLIC_KEY, + userId = USER_ID_1, + ) + authRequestsService.updateAuthRequest( + requestId = requestId, + masterPasswordHash = passwordHash, + key = encodedKey, + deviceId = UNIQUE_APP_ID, + isApproved = false, + ) + } + assertEquals(expected, result) + } + @Test fun `getIsKnownDevice should return failure when service returns failure`() = runTest { coEvery { diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalScreenTest.kt index c9e76b7cd1..6c5a3329ff 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalScreenTest.kt @@ -1,5 +1,9 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo @@ -43,20 +47,6 @@ class LoginApprovalScreenTest : BaseComposeTest() { @Test fun `on Confirm login should send ApproveRequestClick`() = runTest { - // Set to content state to show appropriate buttons - mutableStateFlow.tryEmit( - LoginApprovalState( - fingerprint = FINGERPRINT, - viewState = LoginApprovalState.ViewState.Content( - deviceType = "Android", - domainUrl = "bitwarden.com", - email = "test@bitwarden.com", - fingerprint = FINGERPRINT, - ipAddress = "1.0.0.1", - time = "now", - ), - ), - ) composeTestRule .onNodeWithText("Confirm login") .performScrollTo() @@ -69,20 +59,6 @@ class LoginApprovalScreenTest : BaseComposeTest() { @Test fun `on Deny login should send DeclineRequestClick`() = runTest { - // Set to content state to show appropriate buttons - mutableStateFlow.tryEmit( - LoginApprovalState( - fingerprint = FINGERPRINT, - viewState = LoginApprovalState.ViewState.Content( - deviceType = "Android", - domainUrl = "bitwarden.com", - email = "test@bitwarden.com", - fingerprint = FINGERPRINT, - ipAddress = "1.0.0.1", - time = "now", - ), - ), - ) composeTestRule .onNodeWithText("Deny login") .performScrollTo() @@ -91,10 +67,43 @@ class LoginApprovalScreenTest : BaseComposeTest() { viewModel.trySendAction(LoginApprovalAction.DeclineRequestClick) } } + + @Test + fun `on error dialog dismiss click should send ErrorDialogDismiss`() = runTest { + mutableStateFlow.tryEmit( + DEFAULT_STATE.copy( + shouldShowErrorDialog = true, + ), + ) + + composeTestRule + .onNodeWithText("An error has occurred.") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Ok") + .performClick() + + verify { + viewModel.trySendAction(LoginApprovalAction.ErrorDialogDismiss) + } + } } private const val FINGERPRINT = "fingerprint" private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState( fingerprint = FINGERPRINT, - viewState = LoginApprovalState.ViewState.Loading, + masterPasswordHash = null, + publicKey = "publicKey", + requestId = "", + shouldShowErrorDialog = false, + viewState = LoginApprovalState.ViewState.Content( + deviceType = "Android", + domainUrl = "bitwarden.com", + email = "test@bitwarden.com", + fingerprint = FINGERPRINT, + ipAddress = "1.0.0.1", + time = "now", + ), ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModelTest.kt index 6814db9ceb..ea024427ee 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModelTest.kt @@ -8,7 +8,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.AuthRequestResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.repository.model.Environment 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 @@ -82,23 +81,72 @@ class LoginApprovalViewModelTest : BaseViewModelTest() { } @Test - fun `on ApproveRequestClick should emit ShowToast`() = runTest { + fun `on ApproveRequestClick should approve auth request`() = runTest { val viewModel = createViewModel() - viewModel.eventFlow.test { - viewModel.trySendAction(LoginApprovalAction.ApproveRequestClick) - assertEquals(LoginApprovalEvent.ShowToast("Not yet implemented".asText()), awaitItem()) + coEvery { + mockAuthRepository.updateAuthRequest( + requestId = REQUEST_ID, + masterPasswordHash = PASSWORD_HASH, + publicKey = PUBLIC_KEY, + isApproved = true, + ) + } returns AuthRequestResult.Success(AUTH_REQUEST) + + viewModel.trySendAction(LoginApprovalAction.ApproveRequestClick) + + coVerify { + mockAuthRepository.updateAuthRequest( + requestId = REQUEST_ID, + masterPasswordHash = PASSWORD_HASH, + publicKey = PUBLIC_KEY, + isApproved = true, + ) } } @Test - fun `on DeclineRequestClick should emit ShowToast`() = runTest { + fun `on DeclineRequestClick should deny auth request`() = runTest { val viewModel = createViewModel() - viewModel.eventFlow.test { - viewModel.trySendAction(LoginApprovalAction.ApproveRequestClick) - assertEquals(LoginApprovalEvent.ShowToast("Not yet implemented".asText()), awaitItem()) + coEvery { + mockAuthRepository.updateAuthRequest( + requestId = REQUEST_ID, + masterPasswordHash = PASSWORD_HASH, + publicKey = PUBLIC_KEY, + isApproved = false, + ) + } returns AuthRequestResult.Success(AUTH_REQUEST) + + viewModel.trySendAction(LoginApprovalAction.DeclineRequestClick) + + coVerify { + mockAuthRepository.updateAuthRequest( + requestId = REQUEST_ID, + masterPasswordHash = PASSWORD_HASH, + publicKey = PUBLIC_KEY, + isApproved = false, + ) } } + @Test + fun `on ErrorDialogDismiss should update state`() = runTest { + val viewModel = createViewModel() + coEvery { + mockAuthRepository.updateAuthRequest( + requestId = REQUEST_ID, + masterPasswordHash = PASSWORD_HASH, + publicKey = PUBLIC_KEY, + isApproved = false, + ) + } returns AuthRequestResult.Error + viewModel.trySendAction(LoginApprovalAction.DeclineRequestClick) + + assertEquals(viewModel.stateFlow.value, DEFAULT_STATE.copy(shouldShowErrorDialog = true)) + viewModel.trySendAction(LoginApprovalAction.ErrorDialogDismiss) + + assertEquals(viewModel.stateFlow.value, DEFAULT_STATE.copy(shouldShowErrorDialog = false)) + } + private fun createViewModel( authRepository: AuthRepository = mockAuthRepository, state: LoginApprovalState? = DEFAULT_STATE, @@ -112,8 +160,15 @@ class LoginApprovalViewModelTest : BaseViewModelTest() { private const val EMAIL = "test@bitwarden.com" private const val FINGERPRINT = "fingerprint" +private const val PASSWORD_HASH = "verySecureHash" +private const val PUBLIC_KEY = "publicKey" +private const val REQUEST_ID = "requestId" private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState( fingerprint = FINGERPRINT, + masterPasswordHash = PASSWORD_HASH, + publicKey = PUBLIC_KEY, + requestId = REQUEST_ID, + shouldShowErrorDialog = false, viewState = LoginApprovalState.ViewState.Content( deviceType = "Android", domainUrl = "www.bitwarden.com", @@ -142,12 +197,12 @@ private val DEFAULT_USER_STATE = UserState( ), ) private val AUTH_REQUEST = AuthRequest( - id = "1", - publicKey = "2", + id = REQUEST_ID, + publicKey = PUBLIC_KEY, platform = "Android", ipAddress = "1.0.0.1", key = "public", - masterPasswordHash = "verySecureHash", + masterPasswordHash = PASSWORD_HASH, creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), responseDate = null, requestApproved = true, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt index b9a5f83696..8aeea4d18a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsViewModelTest.kt @@ -177,6 +177,75 @@ class PendingRequestsViewModelTest : BaseViewModelTest() { } } + @Suppress("LongMethod") + @Test + fun `on LifecycleResume should update state`() = runTest { + coEvery { + authRepository.getAuthRequests() + } returns AuthRequestsResult.Success(emptyList()) + val viewModel = createViewModel() + + assertEquals( + DEFAULT_STATE, + viewModel.stateFlow.value, + ) + + coEvery { + authRepository.getAuthRequests() + } returns AuthRequestsResult.Success( + authRequests = listOf( + AuthRequest( + id = "1", + publicKey = "publicKey-1", + platform = "Android", + ipAddress = "192.168.0.1", + key = "publicKey", + masterPasswordHash = "verySecureHash", + creationDate = ZonedDateTime.parse("2023-08-24T17:11Z"), + responseDate = null, + requestApproved = true, + originUrl = "www.bitwarden.com", + fingerprint = "pantry-overdue-survive-sleep-jab", + ), + AuthRequest( + id = "2", + publicKey = "publicKey-2", + platform = "iOS", + ipAddress = "192.168.0.2", + key = "publicKey", + masterPasswordHash = "verySecureHash", + creationDate = ZonedDateTime.parse("2023-08-21T15:43Z"), + responseDate = null, + requestApproved = false, + originUrl = "www.bitwarden.com", + fingerprint = "erupt-anew-matchbook-disk-student", + ), + ), + ) + val expected = DEFAULT_STATE.copy( + viewState = PendingRequestsState.ViewState.Content( + requests = listOf( + PendingRequestsState.ViewState.Content.PendingLoginRequest( + fingerprintPhrase = "pantry-overdue-survive-sleep-jab", + platform = "Android", + timestamp = "8/24/23 05:11 PM", + ), + PendingRequestsState.ViewState.Content.PendingLoginRequest( + fingerprintPhrase = "erupt-anew-matchbook-disk-student", + platform = "iOS", + timestamp = "8/21/23 03:43 PM", + ), + ), + ), + ) + viewModel.trySendAction(PendingRequestsAction.LifecycleResume) + assertEquals(expected, viewModel.stateFlow.value) + + coVerify(exactly = 2) { + authRepository.getAuthRequests() + } + } + private fun createViewModel( state: PendingRequestsState? = DEFAULT_STATE, ): PendingRequestsViewModel = PendingRequestsViewModel(