Fill out trusted device UI (#1142)

This commit is contained in:
David Perez 2024-03-14 11:15:43 -05:00 committed by Álison Fernandes
parent 483a10a3a7
commit 226b62a1cd
5 changed files with 382 additions and 3 deletions

View file

@ -1,7 +1,19 @@
package com.x8bit.bitwarden.ui.auth.feature.trusteddevice
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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
@ -9,8 +21,13 @@ 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.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
@ -18,7 +35,11 @@ import com.x8bit.bitwarden.ui.auth.feature.trusteddevice.handlers.TrustedDeviceH
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.text.BitwardenClickableText
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenSwitch
/**
* The top level composable for the Reset Password screen.
@ -31,9 +52,15 @@ fun TrustedDeviceScreen(
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val handlers = remember(viewModel) { TrustedDeviceHandlers.create(viewModel = viewModel) }
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event ->
when (event) {
TrustedDeviceEvent.NavigateBack -> onNavigateBack()
is TrustedDeviceEvent.ShowToast -> {
Toast
.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT)
.show()
}
}
}
@ -43,6 +70,7 @@ fun TrustedDeviceScreen(
)
}
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TrustedDeviceScaffold(
@ -66,5 +94,94 @@ private fun TrustedDeviceScaffold(
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(8.dp))
BitwardenSwitch(
label = stringResource(id = R.string.remember_this_device),
description = stringResource(id = R.string.turn_off_using_public_device),
isChecked = state.isRemembered,
onCheckedChange = handlers.onRememberToggle,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
BitwardenFilledButton(
label = stringResource(id = R.string.approve_with_my_other_device),
onClick = handlers.onApproveWithDeviceClick,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenOutlinedButton(
label = stringResource(id = R.string.request_admin_approval),
onClick = handlers.onApproveWithAdminClick,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenOutlinedButton(
label = stringResource(id = R.string.approve_with_master_password),
onClick = handlers.onApproveWithPasswordClick,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(
id = R.string.logging_in_as_x_on_y,
state.emailAddress,
state.environmentLabel,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.semantics { testTag = "LoggingInAsLabel" }
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
BitwardenClickableText(
label = stringResource(id = R.string.not_you),
onClick = handlers.onNotYouButtonClick,
style = MaterialTheme.typography.labelLarge,
innerPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp),
modifier = Modifier.semantics { testTag = "NotYouLabel" },
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
@Preview
@Composable
private fun TrustedDeviceScaffold_preview() {
TrustedDeviceScaffold(
state = TrustedDeviceState(
isRemembered = false,
emailAddress = "email@bitwarden.com",
environmentLabel = "vault.bitwarden.pw",
),
handlers = TrustedDeviceHandlers(
onBackClick = {},
onRememberToggle = {},
onApproveWithAdminClick = {},
onApproveWithDeviceClick = {},
onApproveWithPasswordClick = {},
onNotYouButtonClick = {},
),
)
}

View file

@ -1,8 +1,13 @@
package com.x8bit.bitwarden.ui.auth.feature.trusteddevice
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
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.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
@ -14,23 +19,58 @@ private const val KEY_STATE = "state"
class TrustedDeviceViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<TrustedDeviceState, TrustedDeviceEvent, TrustedDeviceAction>(
initialState = savedStateHandle[KEY_STATE] ?: TrustedDeviceState,
initialState = savedStateHandle[KEY_STATE]
?: TrustedDeviceState(
emailAddress = "",
environmentLabel = "",
isRemembered = false,
),
) {
override fun handleAction(action: TrustedDeviceAction) {
when (action) {
TrustedDeviceAction.BackClick -> handleBackClick()
is TrustedDeviceAction.RememberToggle -> handleRememberToggle(action)
TrustedDeviceAction.ApproveWithAdminClick -> handleApproveWithAdminClick()
TrustedDeviceAction.ApproveWithDeviceClick -> handleApproveWithDeviceClick()
TrustedDeviceAction.ApproveWithPasswordClick -> handleApproveWithPasswordClick()
TrustedDeviceAction.NotYouClick -> handleNotYouClick()
}
}
private fun handleBackClick() {
sendEvent(TrustedDeviceEvent.NavigateBack)
}
private fun handleRememberToggle(action: TrustedDeviceAction.RememberToggle) {
mutableStateFlow.update { it.copy(isRemembered = action.isRemembered) }
}
private fun handleApproveWithAdminClick() {
sendEvent(TrustedDeviceEvent.ShowToast("Not yet implemented".asText()))
}
private fun handleApproveWithDeviceClick() {
sendEvent(TrustedDeviceEvent.ShowToast("Not yet implemented".asText()))
}
private fun handleApproveWithPasswordClick() {
sendEvent(TrustedDeviceEvent.ShowToast("Not yet implemented".asText()))
}
private fun handleNotYouClick() {
sendEvent(TrustedDeviceEvent.ShowToast("Not yet implemented".asText()))
}
}
/**
* Models the state for the Trusted Device screen.
*/
data object TrustedDeviceState
@Parcelize
data class TrustedDeviceState(
val emailAddress: String,
val environmentLabel: String,
val isRemembered: Boolean,
) : Parcelable
/**
* Models events for the Trusted Device screen.
@ -40,6 +80,11 @@ sealed class TrustedDeviceEvent {
* Navigates back.
*/
data object NavigateBack : TrustedDeviceEvent()
/**
* Displays the [message] as a toast.
*/
data class ShowToast(val message: Text) : TrustedDeviceEvent()
}
/**
@ -50,4 +95,31 @@ sealed class TrustedDeviceAction {
* User clicked back button.
*/
data object BackClick : TrustedDeviceAction()
/**
* User toggled the remember device switch.
*/
data class RememberToggle(
val isRemembered: Boolean,
) : TrustedDeviceAction()
/**
* User clicked the "Approve with my other device" button.
*/
data object ApproveWithDeviceClick : TrustedDeviceAction()
/**
* User clicked the "Request admin approval" button.
*/
data object ApproveWithAdminClick : TrustedDeviceAction()
/**
* User clicked the "Approve with master password" button.
*/
data object ApproveWithPasswordClick : TrustedDeviceAction()
/**
* Indicates that the "Not you?" text was clicked.
*/
data object NotYouClick : TrustedDeviceAction()
}

View file

@ -9,6 +9,11 @@ import com.x8bit.bitwarden.ui.auth.feature.trusteddevice.TrustedDeviceViewModel
*/
data class TrustedDeviceHandlers(
val onBackClick: () -> Unit,
val onRememberToggle: (Boolean) -> Unit,
val onApproveWithDeviceClick: () -> Unit,
val onApproveWithAdminClick: () -> Unit,
val onApproveWithPasswordClick: () -> Unit,
val onNotYouButtonClick: () -> Unit,
) {
companion object {
/**
@ -18,6 +23,21 @@ data class TrustedDeviceHandlers(
fun create(viewModel: TrustedDeviceViewModel): TrustedDeviceHandlers =
TrustedDeviceHandlers(
onBackClick = { viewModel.trySendAction(TrustedDeviceAction.BackClick) },
onRememberToggle = {
viewModel.trySendAction(TrustedDeviceAction.RememberToggle(it))
},
onApproveWithDeviceClick = {
viewModel.trySendAction(TrustedDeviceAction.ApproveWithDeviceClick)
},
onApproveWithAdminClick = {
viewModel.trySendAction(TrustedDeviceAction.ApproveWithAdminClick)
},
onApproveWithPasswordClick = {
viewModel.trySendAction(TrustedDeviceAction.ApproveWithPasswordClick)
},
onNotYouButtonClick = {
viewModel.trySendAction(TrustedDeviceAction.NotYouClick)
},
)
}
}

View file

@ -1,13 +1,19 @@
package com.x8bit.bitwarden.ui.auth.feature.trusteddevice
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.onNodeWithContentDescription
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.flow.update
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertTrue
@ -46,6 +52,110 @@ class TrustedDeviceScreenTest : BaseComposeTest() {
viewModel.trySendAction(TrustedDeviceAction.BackClick)
}
}
@Test
fun `on remember toggle changed should send RememberToggle`() {
composeTestRule
.onNodeWithText("Remember this device")
.performScrollTo()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(TrustedDeviceAction.RememberToggle(true))
}
}
@Test
fun `on approve with device clicked should send ApproveWithDeviceClick`() {
composeTestRule
.onNodeWithText("Approve with my other device")
.performScrollTo()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(TrustedDeviceAction.ApproveWithDeviceClick)
}
}
@Test
fun `on approve with admin clicked should send ApproveWithAdminClick`() {
composeTestRule
.onNodeWithText("Request admin approval")
.performScrollTo()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(TrustedDeviceAction.ApproveWithAdminClick)
}
}
@Test
fun `on approve with master password clicked should send ApproveWithPasswordClick`() {
composeTestRule
.onNodeWithText("Approve with master password")
.performScrollTo()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(TrustedDeviceAction.ApproveWithPasswordClick)
}
}
@Test
fun `on not you clicked should send NotYouClick`() {
composeTestRule
.onNodeWithText("Not you?")
.performScrollTo()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(TrustedDeviceAction.NotYouClick)
}
}
@Test
fun `remember this device toggle should update according to state`() {
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithText("Remember this device")
.performScrollTo()
.assertIsOff()
mutableStateFlow.update { DEFAULT_STATE.copy(isRemembered = true) }
composeTestRule
.onNodeWithText("Remember this device")
.performScrollTo()
.assertIsOn()
}
@Test
fun `email and environment label should update according to state`() {
mutableStateFlow.update { DEFAULT_STATE }
composeTestRule
.onNodeWithText("Logging in as bitwarden@email.com on vault.test.pw")
.assertDoesNotExist()
composeTestRule
.onNodeWithText("Logging in as email@bitwarden.com on vault.bitwarden.pw")
.performScrollTo()
.assertIsDisplayed()
mutableStateFlow.update {
DEFAULT_STATE.copy(
emailAddress = "bitwarden@email.com",
environmentLabel = "vault.test.pw",
)
}
composeTestRule
.onNodeWithText("Logging in as email@bitwarden.com on vault.bitwarden.pw")
.assertDoesNotExist()
composeTestRule
.onNodeWithText("Logging in as bitwarden@email.com on vault.test.pw")
.performScrollTo()
.assertIsDisplayed()
}
}
private val DEFAULT_STATE: TrustedDeviceState = TrustedDeviceState
private val DEFAULT_STATE: TrustedDeviceState = TrustedDeviceState(
emailAddress = "email@bitwarden.com",
environmentLabel = "vault.bitwarden.pw",
isRemembered = false,
)

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.auth.feature.trusteddevice
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@ -19,6 +20,59 @@ class TrustedDeviceViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on RememberToggle updates the isRemembered state`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(TrustedDeviceAction.RememberToggle(isRemembered = true))
assertEquals(DEFAULT_STATE.copy(isRemembered = true), awaitItem())
viewModel.trySendAction(TrustedDeviceAction.RememberToggle(isRemembered = false))
assertEquals(DEFAULT_STATE.copy(isRemembered = false), awaitItem())
}
}
@Test
fun `on ApproveWithAdminClick emits ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(TrustedDeviceAction.ApproveWithAdminClick)
assertEquals(TrustedDeviceEvent.ShowToast("Not yet implemented".asText()), awaitItem())
}
}
@Test
fun `on ApproveWithDeviceClick emits ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(TrustedDeviceAction.ApproveWithDeviceClick)
assertEquals(TrustedDeviceEvent.ShowToast("Not yet implemented".asText()), awaitItem())
}
}
@Test
fun `on ApproveWithPasswordClick emits ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(TrustedDeviceAction.ApproveWithPasswordClick)
assertEquals(TrustedDeviceEvent.ShowToast("Not yet implemented".asText()), awaitItem())
}
}
@Test
fun `on NotYouClick emits ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(TrustedDeviceAction.NotYouClick)
assertEquals(TrustedDeviceEvent.ShowToast("Not yet implemented".asText()), awaitItem())
}
}
private fun createViewModel(
state: TrustedDeviceState? = null,
): TrustedDeviceViewModel =
@ -26,3 +80,9 @@ class TrustedDeviceViewModelTest : BaseViewModelTest() {
savedStateHandle = SavedStateHandle().apply { set("state", state) },
)
}
private val DEFAULT_STATE: TrustedDeviceState = TrustedDeviceState(
emailAddress = "",
environmentLabel = "",
isRemembered = false,
)