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 0da630839f..935f478856 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 @@ -178,6 +178,11 @@ interface AuthRepository : AuthenticatorProvider { */ suspend fun createAuthRequest(email: String): AuthRequestResult + /** + * Get an auth request by its [fingerprint]. + */ + suspend fun getAuthRequest(fingerprint: String): AuthRequestResult + /** * Get a list of the current user's [AuthRequest]s. */ 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 8db2a217ec..bd96e503f9 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 @@ -615,6 +615,21 @@ class AuthRepositoryImpl( onSuccess = { it }, ) + override suspend fun getAuthRequest( + fingerprint: String, + ): AuthRequestResult = + when (val authRequestsResult = getAuthRequests()) { + AuthRequestsResult.Error -> AuthRequestResult.Error + is AuthRequestsResult.Success -> { + val request = authRequestsResult.authRequests + .firstOrNull { it.fingerprint == fingerprint } + + request + ?.let { AuthRequestResult.Success(it) } + ?: AuthRequestResult.Error + } + } + override suspend fun getAuthRequests(): AuthRequestsResult = authRequestsService .getAuthRequests() diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalNavigation.kt new file mode 100644 index 0000000000..94081c90b1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalNavigation.kt @@ -0,0 +1,47 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval + +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage +import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions + +private const val FINGERPRINT: String = "fingerprint" +private const val LOGIN_APPROVAL_PREFIX = "login_approval" +private const val LOGIN_APPROVAL_ROUTE = "$LOGIN_APPROVAL_PREFIX/{$FINGERPRINT}" + +/** + * Class to retrieve login approval arguments from the [SavedStateHandle]. + */ +@OmitFromCoverage +data class LoginApprovalArgs(val fingerprint: String) { + constructor(savedStateHandle: SavedStateHandle) : this( + checkNotNull(savedStateHandle[FINGERPRINT]) as String, + ) +} + +/** + * Add login approval destinations to the nav graph. + */ +fun NavGraphBuilder.loginApprovalDestination( + onNavigateBack: () -> Unit, +) { + composableWithSlideTransitions( + route = LOGIN_APPROVAL_ROUTE, + ) { + LoginApprovalScreen( + onNavigateBack = onNavigateBack, + ) + } +} + +/** + * Navigate to the Login Approval screen. + */ +fun NavController.navigateToLoginApproval( + fingerprint: String, + navOptions: NavOptions? = null, +) { + navigate("$LOGIN_APPROVAL_PREFIX/$fingerprint", navOptions) +} 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 new file mode 100644 index 0000000000..3c4256db16 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalScreen.kt @@ -0,0 +1,245 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval + +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 +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +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 com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent +import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton +import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent +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.theme.LocalNonMaterialColors +import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography + +/** + * Displays the login approval screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongMethod") +@Composable +fun LoginApprovalScreen( + viewModel: LoginApprovalViewModel = hiltViewModel(), + onNavigateBack: () -> Unit, +) { + val state by viewModel.stateFlow.collectAsState() + val context = LocalContext.current + val resources = context.resources + EventsEffect(viewModel = viewModel) { event -> + when (event) { + LoginApprovalEvent.NavigateBack -> onNavigateBack() + + is LoginApprovalEvent.ShowToast -> { + Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show() + } + } + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.log_in_requested), + scrollBehavior = scrollBehavior, + navigationIcon = painterResource(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = remember(viewModel) { + { viewModel.trySendAction(LoginApprovalAction.CloseClick) } + }, + ) + }, + ) { innerPadding -> + when (val viewState = state.viewState) { + is LoginApprovalState.ViewState.Content -> { + LoginApprovalContent( + state = viewState, + onConfirmLoginClick = remember(viewModel) { + { viewModel.trySendAction(LoginApprovalAction.ApproveRequestClick) } + }, + onDeclineLoginClick = remember(viewModel) { + { viewModel.trySendAction(LoginApprovalAction.DeclineRequestClick) } + }, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + } + + is LoginApprovalState.ViewState.Error -> { + BitwardenErrorContent( + message = stringResource(id = R.string.generic_error_message), + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + } + + is LoginApprovalState.ViewState.Loading -> { + BitwardenLoadingContent( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + } + } + } +} + +@Suppress("LongMethod") +@Composable +private fun LoginApprovalContent( + state: LoginApprovalState.ViewState.Content, + onConfirmLoginClick: () -> Unit, + onDeclineLoginClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .verticalScroll(state = rememberScrollState()), + ) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(id = R.string.are_you_trying_to_log_in), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource( + id = R.string.log_in_attempt_by_x_on_y, + state.email, + state.domainUrl, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(id = R.string.fingerprint_phrase), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = state.fingerprint, + textAlign = TextAlign.Start, + color = LocalNonMaterialColors.current.fingerprint, + style = LocalNonMaterialTypography.current.sensitiveInfoSmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + LoginApprovalInfoColumn( + label = stringResource(id = R.string.device_type), + value = state.deviceType, + ) + + LoginApprovalInfoColumn( + label = stringResource(id = R.string.ip_address), + value = state.ipAddress, + ) + + LoginApprovalInfoColumn( + label = stringResource(id = R.string.time), + value = state.time, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + BitwardenFilledButton( + label = stringResource(id = R.string.confirm_log_in), + onClick = onConfirmLoginClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + BitwardenOutlinedButton( + label = stringResource(id = R.string.deny_log_in), + onClick = onDeclineLoginClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.navigationBarsPadding()) + } +} + +/** + * A view displaying information about this login approval request. + */ +@Composable +private fun LoginApprovalInfoColumn( + label: String, + value: String, +) { + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = label, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) +} 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 new file mode 100644 index 0000000000..f4ef6534b4 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModel.kt @@ -0,0 +1,190 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +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 +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.update +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import java.time.format.DateTimeFormatter +import java.util.TimeZone +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * View model for the login approval screen. + */ +@HiltViewModel +class LoginApprovalViewModel @Inject constructor( + private val authRepository: AuthRepository, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] + ?: LoginApprovalState( + fingerprint = LoginApprovalArgs(savedStateHandle).fingerprint, + viewState = LoginApprovalState.ViewState.Loading, + ), +) { + private val dateTimeFormatter + get() = DateTimeFormatter + .ofPattern("M/d/yy hh:mm a") + .withZone(TimeZone.getDefault().toZoneId()) + + init { + viewModelScope.launch { + trySendAction( + LoginApprovalAction.Internal.AuthRequestResultReceive( + authRequestResult = authRepository.getAuthRequest(state.fingerprint), + ), + ) + } + } + + override fun handleAction(action: LoginApprovalAction) { + when (action) { + LoginApprovalAction.ApproveRequestClick -> handleApproveRequestClicked() + LoginApprovalAction.CloseClick -> handleCloseClicked() + LoginApprovalAction.DeclineRequestClick -> handleDeclineRequestClicked() + + is LoginApprovalAction.Internal.AuthRequestResultReceive -> { + handleAuthRequestResultReceived(action) + } + } + } + + private fun handleApproveRequestClicked() { + // TODO BIT-1565 implement approve login request + sendEvent(LoginApprovalEvent.ShowToast("Not yet implemented".asText())) + } + + private fun handleCloseClicked() { + sendEvent(LoginApprovalEvent.NavigateBack) + } + + private fun handleDeclineRequestClicked() { + // TODO BIT-1565 implement decline login request + sendEvent(LoginApprovalEvent.ShowToast("Not yet implemented".asText())) + } + + 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), + ) + } + + is AuthRequestResult.Error -> LoginApprovalState.ViewState.Error + }, + ) + } + } +} + +/** + * Models state for the Login Approval screen. + */ +@Parcelize +data class LoginApprovalState( + val fingerprint: String, + val viewState: ViewState, +) : Parcelable { + /** + * Represents the specific view states for the [LoginApprovalScreen]. + */ + @Parcelize + sealed class ViewState : Parcelable { + /** + * Content state for the [LoginApprovalScreen]. + */ + @Parcelize + data class Content( + val deviceType: String, + val domainUrl: String, + val email: String, + val fingerprint: String, + val ipAddress: String, + val time: String, + ) : ViewState() + + /** + * Represents a state where the [LoginApprovalScreen] is unable to display data due to an + * error retrieving it. + */ + @Parcelize + data object Error : ViewState() + + /** + * Loading state for the [LoginApprovalScreen], signifying that the content is being + * processed. + */ + @Parcelize + data object Loading : ViewState() + } +} + +/** + * Models events for the Login Approval screen. + */ +sealed class LoginApprovalEvent { + /** + * Navigates back. + */ + data object NavigateBack : LoginApprovalEvent() + + /** + * Displays the [message] in a toast. + */ + data class ShowToast( + val message: Text, + ) : LoginApprovalEvent() +} + +/** + * Models actions for the Login Approval screen. + */ +sealed class LoginApprovalAction { + /** + * The user has clicked the Confirm login button. + */ + data object ApproveRequestClick : LoginApprovalAction() + + /** + * The user has clicked the close button. + */ + data object CloseClick : LoginApprovalAction() + + /** + * The user has clicked the Decline login button. + */ + data object DeclineRequestClick : LoginApprovalAction() + + /** + * Models action the view model could send itself. + */ + sealed class Internal : LoginApprovalAction() { + /** + * An auth request result has been received to populate the data on the screen. + */ + data class AuthRequestResultReceive( + val authRequestResult: AuthRequestResult, + ) : Internal() + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsNavigation.kt index 10fc8cfd8f..b35bee7d59 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsNavigation.kt @@ -12,11 +12,15 @@ private const val PENDING_REQUESTS_ROUTE = "pending_requests" */ fun NavGraphBuilder.pendingRequestsDestination( onNavigateBack: () -> Unit, + onNavigateToLoginApproval: (fingerprintPhrase: String) -> Unit, ) { composableWithSlideTransitions( route = PENDING_REQUESTS_ROUTE, ) { - PendingRequestsScreen(onNavigateBack = onNavigateBack) + PendingRequestsScreen( + onNavigateBack = onNavigateBack, + onNavigateToLoginApproval = onNavigateToLoginApproval, + ) } } 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 9684287eb2..970515513e 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 @@ -2,6 +2,8 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pending import android.widget.Toast import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -13,6 +15,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -51,6 +54,7 @@ import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography fun PendingRequestsScreen( viewModel: PendingRequestsViewModel = hiltViewModel(), onNavigateBack: () -> Unit, + onNavigateToLoginApproval: (fingerprint: String) -> Unit, ) { val state by viewModel.stateFlow.collectAsState() val context = LocalContext.current @@ -58,6 +62,9 @@ fun PendingRequestsScreen( EventsEffect(viewModel = viewModel) { event -> when (event) { PendingRequestsEvent.NavigateBack -> onNavigateBack() + is PendingRequestsEvent.NavigateToLoginApproval -> { + onNavigateToLoginApproval(event.fingerprint) + } is PendingRequestsEvent.ShowToast -> { Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show() @@ -96,6 +103,13 @@ fun PendingRequestsScreen( ) } }, + onNavigateToLoginApproval = remember(viewModel) { + { + viewModel.trySendAction( + PendingRequestsAction.PendingRequestRowClick(it), + ) + } + }, ) } @@ -128,6 +142,7 @@ fun PendingRequestsScreen( private fun PendingRequestsContent( state: PendingRequestsState.ViewState.Content, onDeclineAllRequestsClick: () -> Unit, + onNavigateToLoginApproval: (fingerprint: String) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -142,6 +157,7 @@ private fun PendingRequestsContent( fingerprintPhrase = request.fingerprintPhrase, platform = request.platform, timestamp = request.timestamp, + onNavigateToLoginApproval = onNavigateToLoginApproval, modifier = Modifier.fillMaxWidth(), ) HorizontalDivider( @@ -171,10 +187,16 @@ private fun PendingRequestItem( fingerprintPhrase: String, platform: String, timestamp: String, + onNavigateToLoginApproval: (fingerprintPhrase: String) -> Unit, modifier: Modifier = Modifier, ) { Column( - modifier = modifier, + modifier = modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(color = MaterialTheme.colorScheme.primary), + onClick = { onNavigateToLoginApproval(fingerprintPhrase) }, + ), horizontalAlignment = Alignment.Start, ) { Spacer(modifier = Modifier.height(16.dp)) 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 b2d692b169..75008d3de4 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 @@ -49,6 +49,9 @@ class PendingRequestsViewModel @Inject constructor( when (action) { PendingRequestsAction.CloseClick -> handleCloseClicked() PendingRequestsAction.DeclineAllRequestsClick -> handleDeclineAllRequestsClicked() + is PendingRequestsAction.PendingRequestRowClick -> { + handlePendingRequestRowClicked(action) + } is PendingRequestsAction.Internal.AuthRequestsResultReceive -> { handleAuthRequestsResultReceived(action) @@ -64,6 +67,12 @@ class PendingRequestsViewModel @Inject constructor( sendEvent(PendingRequestsEvent.ShowToast("Not yet implemented.".asText())) } + private fun handlePendingRequestRowClicked( + action: PendingRequestsAction.PendingRequestRowClick, + ) { + sendEvent(PendingRequestsEvent.NavigateToLoginApproval(action.fingerprint)) + } + private fun handleAuthRequestsResultReceived( action: PendingRequestsAction.Internal.AuthRequestsResultReceive, ) { @@ -156,6 +165,13 @@ sealed class PendingRequestsEvent { */ data object NavigateBack : PendingRequestsEvent() + /** + * Navigates to the Login Approval screen with the given fingerprint. + */ + data class NavigateToLoginApproval( + val fingerprint: String, + ) : PendingRequestsEvent() + /** * Displays the [message] in a toast. */ @@ -179,6 +195,13 @@ sealed class PendingRequestsAction { */ data object DeclineAllRequestsClick : PendingRequestsAction() + /** + * The user has clicked one of the pending request rows. + */ + data class PendingRequestRowClick( + val fingerprint: String, + ) : PendingRequestsAction() + /** * Models actions sent by the view model itself. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index 723b08e712..b12f6c8f7a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -8,6 +8,8 @@ import com.x8bit.bitwarden.ui.platform.feature.search.navigateToSearch import com.x8bit.bitwarden.ui.platform.feature.search.searchDestination import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.deleteAccountDestination import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.navigateToDeleteAccount +import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval.loginApprovalDestination +import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval.navigateToLoginApproval import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests.navigateToPendingRequests import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests.pendingRequestsDestination import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.exportVaultDestination @@ -79,7 +81,11 @@ fun NavGraphBuilder.vaultUnlockedGraph( onNavigateToPasswordHistory = { navController.navigateToPasswordHistory() }, ) deleteAccountDestination(onNavigateBack = { navController.popBackStack() }) - pendingRequestsDestination(onNavigateBack = { navController.popBackStack() }) + loginApprovalDestination(onNavigateBack = { navController.popBackStack() }) + pendingRequestsDestination( + onNavigateBack = { navController.popBackStack() }, + onNavigateToLoginApproval = { navController.navigateToLoginApproval(it) }, + ) vaultAddEditDestination( onNavigateToQrCodeScanScreen = { navController.navigateToQrCodeScanScreen() 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 f27a8f5815..e58434ebe4 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 @@ -2172,6 +2172,94 @@ class AuthRepositoryTest { assertEquals(expected, result) } + @Test + fun `getAuthRequest should return failure when getAuthRequests returns failure`() = runTest { + val fingerprint = "fingerprint" + coEvery { + authRequestsService.getAuthRequests() + } returns Throwable("Fail").asFailure() + + val result = repository.getAuthRequest(fingerprint) + + coVerify(exactly = 1) { + authRequestsService.getAuthRequests() + } + assertEquals(AuthRequestResult.Error, result) + } + + @Test + fun `getAuthRequest should return success when service returns success`() = runTest { + val fingerprint = "fingerprint" + val responseJson = AuthRequestsResponseJson( + authRequests = listOf( + AuthRequestsResponseJson.AuthRequest( + id = "1", + publicKey = PUBLIC_KEY, + platform = "Android", + ipAddress = "192.168.0.1", + key = "public", + masterPasswordHash = "verySecureHash", + creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), + responseDate = null, + requestApproved = true, + originUrl = "www.bitwarden.com", + ), + ), + ) + val expected = AuthRequestResult.Success( + authRequest = AuthRequest( + id = "1", + publicKey = PUBLIC_KEY, + platform = "Android", + ipAddress = "192.168.0.1", + key = "public", + masterPasswordHash = "verySecureHash", + creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), + responseDate = null, + requestApproved = true, + originUrl = "www.bitwarden.com", + fingerprint = fingerprint, + ), + ) + coEvery { + authSdkSource.getUserFingerprint( + email = EMAIL, + publicKey = PUBLIC_KEY, + ) + } returns Result.success(fingerprint) + coEvery { + authRequestsService.getAuthRequests() + } returns responseJson.asSuccess() + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + + val result = repository.getAuthRequest(fingerprint) + + coVerify(exactly = 1) { + authRequestsService.getAuthRequests() + authSdkSource.getUserFingerprint(EMAIL, PUBLIC_KEY) + } + assertEquals(expected, result) + } + + @Test + fun `getAuthRequest should return error when no matching fingerprint exists`() = runTest { + val fingerprint = "fingerprint" + val responseJson = AuthRequestsResponseJson( + authRequests = listOf(), + ) + val expected = AuthRequestResult.Error + coEvery { + authRequestsService.getAuthRequests() + } returns responseJson.asSuccess() + + val result = repository.getAuthRequest(fingerprint) + + coVerify(exactly = 1) { + authRequestsService.getAuthRequests() + } + assertEquals(expected, result) + } + @Test fun `getAuthRequests 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 new file mode 100644 index 0000000000..c9e76b7cd1 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalScreenTest.kt @@ -0,0 +1,100 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval + +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertTrue + +class LoginApprovalScreenTest : BaseComposeTest() { + + private var onNavigateBackCalled = false + + private val mutableEventFlow = bufferedMutableSharedFlow() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val viewModel = mockk(relaxed = true) { + every { eventFlow } returns mutableEventFlow + every { stateFlow } returns mutableStateFlow + } + + @Before + fun setUp() { + composeTestRule.setContent { + LoginApprovalScreen( + onNavigateBack = { onNavigateBackCalled = true }, + viewModel = viewModel, + ) + } + } + + @Test + fun `on NavigateBack should call onNavigateBack`() { + mutableEventFlow.tryEmit(LoginApprovalEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } + + @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() + .performClick() + + verify { + viewModel.trySendAction(LoginApprovalAction.ApproveRequestClick) + } + } + + @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() + .performClick() + verify { + viewModel.trySendAction(LoginApprovalAction.DeclineRequestClick) + } + } +} + +private const val FINGERPRINT = "fingerprint" +private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState( + fingerprint = FINGERPRINT, + viewState = LoginApprovalState.ViewState.Loading, +) 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 new file mode 100644 index 0000000000..6814db9ceb --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/loginapproval/LoginApprovalViewModelTest.kt @@ -0,0 +1,156 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.AuthRequest +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 +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.ZonedDateTime +import java.util.TimeZone + +class LoginApprovalViewModelTest : BaseViewModelTest() { + + private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE) + private val mockAuthRepository = mockk { + coEvery { + getAuthRequest(FINGERPRINT) + } returns AuthRequestResult.Success(AUTH_REQUEST) + every { userStateFlow } returns mutableUserStateFlow + } + + @BeforeEach + fun setup() { + // Setting the timezone so the tests pass consistently no matter the environment. + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + } + + @AfterEach + fun tearDown() { + // Clearing the timezone after the test. + TimeZone.setDefault(null) + } + + @Test + fun `initial state should be correct and trigger a getAuthRequest call`() { + val viewModel = createViewModel(state = null) + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + coVerify { + mockAuthRepository.getAuthRequest(FINGERPRINT) + } + verify { + mockAuthRepository.userStateFlow + } + } + + @Test + fun `getAuthRequest failure should update state`() { + val authRepository = mockk { + coEvery { + getAuthRequest(FINGERPRINT) + } returns AuthRequestResult.Error + every { userStateFlow } returns mutableUserStateFlow + } + val expected = DEFAULT_STATE.copy( + viewState = LoginApprovalState.ViewState.Error, + ) + val viewModel = createViewModel(authRepository = authRepository) + assertEquals(expected, viewModel.stateFlow.value) + } + + @Test + fun `on CloseClick should emit NavigateBack`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(LoginApprovalAction.CloseClick) + assertEquals(LoginApprovalEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `on ApproveRequestClick should emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(LoginApprovalAction.ApproveRequestClick) + assertEquals(LoginApprovalEvent.ShowToast("Not yet implemented".asText()), awaitItem()) + } + } + + @Test + fun `on DeclineRequestClick should emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(LoginApprovalAction.ApproveRequestClick) + assertEquals(LoginApprovalEvent.ShowToast("Not yet implemented".asText()), awaitItem()) + } + } + + private fun createViewModel( + authRepository: AuthRepository = mockAuthRepository, + state: LoginApprovalState? = DEFAULT_STATE, + ): LoginApprovalViewModel = LoginApprovalViewModel( + authRepository = authRepository, + savedStateHandle = SavedStateHandle() + .also { it["fingerprint"] = FINGERPRINT } + .apply { set("state", state) }, + ) +} + +private const val EMAIL = "test@bitwarden.com" +private const val FINGERPRINT = "fingerprint" +private val DEFAULT_STATE: LoginApprovalState = LoginApprovalState( + fingerprint = FINGERPRINT, + viewState = LoginApprovalState.ViewState.Content( + deviceType = "Android", + domainUrl = "www.bitwarden.com", + email = EMAIL, + fingerprint = FINGERPRINT, + ipAddress = "1.0.0.1", + time = "9/13/24 12:00 AM", + ), +) +private const val USER_ID = "userID" +private val DEFAULT_USER_STATE = UserState( + activeUserId = USER_ID, + accounts = listOf( + UserState.Account( + userId = USER_ID, + name = "Active User", + email = EMAIL, + environment = Environment.Us, + avatarColorHex = "#aa00aa", + isBiometricsEnabled = false, + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = true, + organizations = emptyList(), + ), + ), +) +private val AUTH_REQUEST = AuthRequest( + id = "1", + publicKey = "2", + platform = "Android", + ipAddress = "1.0.0.1", + key = "public", + masterPasswordHash = "verySecureHash", + creationDate = ZonedDateTime.parse("2024-09-13T00:00Z"), + responseDate = null, + requestApproved = true, + originUrl = "www.bitwarden.com", + fingerprint = FINGERPRINT, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt index a248609161..c33f151375 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/pendingrequests/PendingRequestsScreenTest.kt @@ -16,6 +16,7 @@ import org.junit.jupiter.api.Assertions.assertTrue class PendingRequestsScreenTest : BaseComposeTest() { private var onNavigateBackCalled = false + private var onNavigateToLoginApprovalCalled = false private val mutableEventFlow = bufferedMutableSharedFlow() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) @@ -29,6 +30,7 @@ class PendingRequestsScreenTest : BaseComposeTest() { composeTestRule.setContent { PendingRequestsScreen( onNavigateBack = { onNavigateBackCalled = true }, + onNavigateToLoginApproval = { _ -> onNavigateToLoginApprovalCalled = true }, viewModel = viewModel, ) } @@ -40,6 +42,12 @@ class PendingRequestsScreenTest : BaseComposeTest() { assertTrue(onNavigateBackCalled) } + @Test + fun `on NavigateToLoginApproval should call onNavigateToLoginApproval`() = runTest { + mutableEventFlow.tryEmit(PendingRequestsEvent.NavigateToLoginApproval("fingerprint")) + assertTrue(onNavigateToLoginApprovalCalled) + } + @Test fun `on DeclineAllRequestsClick should send DeclineAllRequestsClick`() = runTest { // set content so the Decline all requests button appears 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 221824b580..b9a5f83696 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 @@ -142,6 +142,23 @@ class PendingRequestsViewModelTest : BaseViewModelTest() { } } + @Test + fun `on PendingRequestRowClick should emit NavigateToLoginApproval`() = runTest { + val fingerprint = "fingerprint" + coEvery { + authRepository.getAuthRequests() + } returns AuthRequestsResult.Success( + authRequests = emptyList(), + ) + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction( + PendingRequestsAction.PendingRequestRowClick(fingerprint), + ) + assertEquals(PendingRequestsEvent.NavigateToLoginApproval(fingerprint), awaitItem()) + } + } + @Test fun `on DeclineAllRequestsClick should send ShowToast event`() = runTest { coEvery {