mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
BIT-811 BIT-1566: Add Login Approval screen & hook up to Pending Requests (#808)
This commit is contained in:
parent
b991acd0d0
commit
ab5a35b914
14 changed files with 929 additions and 3 deletions
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -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<LoginApprovalState, LoginApprovalEvent, LoginApprovalAction>(
|
||||
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()
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<LoginApprovalEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val viewModel = mockk<LoginApprovalViewModel>(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,
|
||||
)
|
|
@ -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<UserState?>(DEFAULT_USER_STATE)
|
||||
private val mockAuthRepository = mockk<AuthRepository> {
|
||||
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<AuthRepository> {
|
||||
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,
|
||||
)
|
|
@ -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<PendingRequestsEvent>()
|
||||
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
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Add table
Reference in a new issue