mirror of
https://github.com/bitwarden/android.git
synced 2026-08-29 10:17:56 -05:00
PM-29491: Implement LeaveOrganizationScreen (#6253)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.5
parent
54ea921b25
commit
e415145c53
+1
-1
@@ -1,7 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.model
|
||||
|
||||
/**
|
||||
* Models result of deleting an account.
|
||||
* Models result of leaving an organization.
|
||||
*/
|
||||
sealed class LeaveOrganizationResult {
|
||||
/**
|
||||
|
||||
@@ -24,4 +24,5 @@ enum class SnackbarRelay {
|
||||
LOGINS_IMPORTED,
|
||||
SEND_DELETED,
|
||||
SEND_UPDATED,
|
||||
LEFT_ORGANIZATION,
|
||||
}
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.x8bit.bitwarden.ui.vault.feature.leaveorganization
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.toRoute
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The type-safe route for the leave organization screen.
|
||||
*
|
||||
* @property organizationId The ID of the organization to leave.
|
||||
* @property organizationName The name of the organization to leave.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
@Serializable
|
||||
data class LeaveOrganizationRoute(
|
||||
val organizationId: String,
|
||||
val organizationName: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Class to retrieve leave organization arguments from the [SavedStateHandle].
|
||||
*
|
||||
* @property organizationId The ID of the organization to leave.
|
||||
* @property organizationName The name of the organization to leave.
|
||||
*/
|
||||
data class LeaveOrganizationArgs(
|
||||
val organizationId: String,
|
||||
val organizationName: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Constructs a [LeaveOrganizationArgs] from the [SavedStateHandle] and internal route data.
|
||||
*/
|
||||
fun SavedStateHandle.toLeaveOrganizationArgs(): LeaveOrganizationArgs {
|
||||
val route = this.toRoute<LeaveOrganizationRoute>()
|
||||
return LeaveOrganizationArgs(
|
||||
organizationId = route.organizationId,
|
||||
organizationName = route.organizationName,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the leave organization screen to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.leaveOrganizationDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToVault: () -> Unit,
|
||||
) {
|
||||
composableWithPushTransitions<LeaveOrganizationRoute> {
|
||||
LeaveOrganizationScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToVault = onNavigateToVault,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the leave organization screen.
|
||||
*
|
||||
* @param organizationId The ID of the organization to leave.
|
||||
* @param organizationName The name of the organization to leave.
|
||||
*/
|
||||
fun NavController.navigateToLeaveOrganization(
|
||||
organizationId: String,
|
||||
organizationName: String,
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
this.navigate(
|
||||
route = LeaveOrganizationRoute(
|
||||
organizationId = organizationId,
|
||||
organizationName = organizationName,
|
||||
),
|
||||
navOptions = navOptions,
|
||||
)
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.leaveorganization
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
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.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenFilledErrorButton
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.composition.LocalIntentManager
|
||||
import com.bitwarden.ui.platform.manager.IntentManager
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.x8bit.bitwarden.ui.vault.feature.leaveorganization.handlers.rememberLeaveOrganizationHandler
|
||||
|
||||
/**
|
||||
* Top-level composable for the Leave Organization screen.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LeaveOrganizationScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToVault: () -> Unit,
|
||||
viewModel: LeaveOrganizationViewModel = hiltViewModel(),
|
||||
intentManager: IntentManager = LocalIntentManager.current,
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
val handlers = rememberLeaveOrganizationHandler(viewModel)
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
LeaveOrganizationEvent.NavigateBack -> onNavigateBack()
|
||||
LeaveOrganizationEvent.NavigateToVault -> onNavigateToVault()
|
||||
is LeaveOrganizationEvent.LaunchUri -> {
|
||||
intentManager.launchUri(event.uri.toUri())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LeaveOrganizationDialogs(
|
||||
dialogState = state.dialogState,
|
||||
onDismissRequest = handlers.onDismissDialog,
|
||||
)
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = BitwardenString.leave_organization),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
navigationIconContentDescription = stringResource(id = BitwardenString.back),
|
||||
onNavigationIconClick = handlers.onBackClick,
|
||||
)
|
||||
},
|
||||
) {
|
||||
LeaveOrganizationContent(
|
||||
state = state,
|
||||
onLeaveClick = handlers.onLeaveClick,
|
||||
onHelpLinkClick = handlers.onHelpClick,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeaveOrganizationDialogs(
|
||||
dialogState: LeaveOrganizationState.DialogState?,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
when (dialogState) {
|
||||
LeaveOrganizationState.DialogState.Loading -> {
|
||||
BitwardenLoadingDialog(
|
||||
text = stringResource(id = BitwardenString.loading),
|
||||
)
|
||||
}
|
||||
|
||||
is LeaveOrganizationState.DialogState.Error -> {
|
||||
BitwardenBasicDialog(
|
||||
title = stringResource(id = BitwardenString.an_error_has_occurred),
|
||||
message = dialogState.message(),
|
||||
throwable = dialogState.error,
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun LeaveOrganizationContent(
|
||||
state: LeaveOrganizationState,
|
||||
onLeaveClick: () -> Unit,
|
||||
onHelpLinkClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Image(
|
||||
painter = rememberVectorPainter(id = BitwardenDrawable.ill_leave_organization),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillHeight,
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin()
|
||||
.size(100.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = BitwardenString.are_you_sure_you_want_to_leave_organization,
|
||||
state.organizationName,
|
||||
),
|
||||
style = BitwardenTheme.typography.titleMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(id = BitwardenString.leave_organization_warning),
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
BitwardenFilledErrorButton(
|
||||
label = stringResource(
|
||||
id = BitwardenString.leave_organization_button,
|
||||
state.organizationName,
|
||||
),
|
||||
onClick = onLeaveClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
BitwardenTextButton(
|
||||
label = stringResource(id = BitwardenString.how_to_manage_my_vault),
|
||||
onClick = onHelpLinkClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun LeaveOrganizationScreen_preview() {
|
||||
BitwardenTheme {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = "Leave organization",
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
navigationIconContentDescription = "Back",
|
||||
onNavigationIconClick = {},
|
||||
)
|
||||
},
|
||||
) {
|
||||
LeaveOrganizationContent(
|
||||
state = LeaveOrganizationState(
|
||||
organizationId = "",
|
||||
organizationName = "Test Organization",
|
||||
dialogState = null,
|
||||
),
|
||||
onLeaveClick = {},
|
||||
onHelpLinkClick = {},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.leaveorganization
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
|
||||
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
|
||||
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
/**
|
||||
* ViewModel for the Leave Organization screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class LeaveOrganizationViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : BaseViewModel<LeaveOrganizationState, LeaveOrganizationEvent, LeaveOrganizationAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: run {
|
||||
val args = savedStateHandle.toLeaveOrganizationArgs()
|
||||
LeaveOrganizationState(
|
||||
organizationId = args.organizationId,
|
||||
organizationName = args.organizationName,
|
||||
dialogState = null,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
||||
init {
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: LeaveOrganizationAction) {
|
||||
when (action) {
|
||||
LeaveOrganizationAction.BackClick -> handleBackClick()
|
||||
LeaveOrganizationAction.LeaveOrganizationClick -> handleLeaveOrganizationClick()
|
||||
LeaveOrganizationAction.HelpLinkClick -> handleHelpLinkClick()
|
||||
LeaveOrganizationAction.DismissDialog -> handleDismissDialog()
|
||||
is LeaveOrganizationAction.Internal.LeaveOrganizationResultReceived -> {
|
||||
handleLeaveOrganizationResultReceived(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackClick() {
|
||||
sendEvent(LeaveOrganizationEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleLeaveOrganizationClick() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = LeaveOrganizationState.DialogState.Loading)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val result = authRepository.leaveOrganization(state.organizationId)
|
||||
sendAction(
|
||||
LeaveOrganizationAction.Internal.LeaveOrganizationResultReceived(result),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleHelpLinkClick() {
|
||||
sendEvent(
|
||||
LeaveOrganizationEvent.LaunchUri(
|
||||
uri = "https://bitwarden.com/help/transfer-ownership/",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleDismissDialog() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleLeaveOrganizationResultReceived(
|
||||
action: LeaveOrganizationAction.Internal.LeaveOrganizationResultReceived,
|
||||
) {
|
||||
when (val result = action.result) {
|
||||
is LeaveOrganizationResult.Success -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = null)
|
||||
}
|
||||
snackbarRelayManager.sendSnackbarData(
|
||||
relay = SnackbarRelay.LEFT_ORGANIZATION,
|
||||
data = BitwardenSnackbarData(
|
||||
message = BitwardenString.you_left_the_organization.asText(),
|
||||
),
|
||||
)
|
||||
sendEvent(LeaveOrganizationEvent.NavigateToVault)
|
||||
}
|
||||
|
||||
is LeaveOrganizationResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = LeaveOrganizationState.DialogState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
error = result.error,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the Leave Organization screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data class LeaveOrganizationState(
|
||||
val organizationId: String,
|
||||
val organizationName: String,
|
||||
val dialogState: DialogState?,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Dialog states for transient UI.
|
||||
*/
|
||||
sealed class DialogState : Parcelable {
|
||||
/**
|
||||
* Loading dialog during leave operation.
|
||||
*/
|
||||
@Parcelize
|
||||
data object Loading : DialogState()
|
||||
|
||||
/**
|
||||
* Error dialog when leave operation fails.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val message: Text,
|
||||
val error: Throwable? = null,
|
||||
) : DialogState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Events for the Leave Organization screen.
|
||||
*/
|
||||
sealed class LeaveOrganizationEvent {
|
||||
/**
|
||||
* Navigate back to previous screen.
|
||||
*/
|
||||
data object NavigateBack : LeaveOrganizationEvent()
|
||||
|
||||
/**
|
||||
* Navigate to the Vault screen.
|
||||
*/
|
||||
data object NavigateToVault : LeaveOrganizationEvent()
|
||||
|
||||
/**
|
||||
* Launch external URI.
|
||||
*/
|
||||
data class LaunchUri(val uri: String) : LeaveOrganizationEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions for the Leave Organization screen.
|
||||
*/
|
||||
sealed class LeaveOrganizationAction {
|
||||
/**
|
||||
* User clicked the back button.
|
||||
*/
|
||||
data object BackClick : LeaveOrganizationAction()
|
||||
|
||||
/**
|
||||
* User clicked the leave organization button.
|
||||
*/
|
||||
data object LeaveOrganizationClick : LeaveOrganizationAction()
|
||||
|
||||
/**
|
||||
* User clicked the help link.
|
||||
*/
|
||||
data object HelpLinkClick : LeaveOrganizationAction()
|
||||
|
||||
/**
|
||||
* User dismissed a dialog.
|
||||
*/
|
||||
data object DismissDialog : LeaveOrganizationAction()
|
||||
|
||||
/**
|
||||
* Internal actions for ViewModel processing.
|
||||
*/
|
||||
sealed class Internal : LeaveOrganizationAction() {
|
||||
/**
|
||||
* Leave organization result received from repository.
|
||||
*/
|
||||
data class LeaveOrganizationResultReceived(
|
||||
val result: LeaveOrganizationResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.leaveorganization.handlers
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.x8bit.bitwarden.ui.vault.feature.leaveorganization.LeaveOrganizationAction
|
||||
import com.x8bit.bitwarden.ui.vault.feature.leaveorganization.LeaveOrganizationViewModel
|
||||
|
||||
/**
|
||||
* A class to handle user interactions for the Leave Organization screen.
|
||||
*/
|
||||
data class LeaveOrganizationHandler(
|
||||
val onBackClick: () -> Unit,
|
||||
val onLeaveClick: () -> Unit,
|
||||
val onHelpClick: () -> Unit,
|
||||
val onDismissDialog: () -> Unit,
|
||||
) {
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
companion object {
|
||||
/**
|
||||
* Creates an instance of [LeaveOrganizationHandler] using the provided
|
||||
* [LeaveOrganizationViewModel].
|
||||
*/
|
||||
fun create(viewModel: LeaveOrganizationViewModel): LeaveOrganizationHandler =
|
||||
LeaveOrganizationHandler(
|
||||
onBackClick = {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.BackClick)
|
||||
},
|
||||
onLeaveClick = {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
|
||||
},
|
||||
onHelpClick = {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.HelpLinkClick)
|
||||
},
|
||||
onDismissDialog = {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.DismissDialog)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create and remember a [LeaveOrganizationHandler] instance.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberLeaveOrganizationHandler(
|
||||
viewModel: LeaveOrganizationViewModel,
|
||||
): LeaveOrganizationHandler = remember(viewModel) {
|
||||
LeaveOrganizationHandler.create(viewModel)
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.leaveorganization
|
||||
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
|
||||
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.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class LeaveOrganizationScreenTest : BitwardenComposeTest() {
|
||||
|
||||
private var onNavigateBackCalled = false
|
||||
private var onNavigateToVaultCalled = false
|
||||
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<LeaveOrganizationEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val viewModel = mockk<LeaveOrganizationViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
setContent {
|
||||
LeaveOrganizationScreen(
|
||||
onNavigateBack = { onNavigateBackCalled = true },
|
||||
onNavigateToVault = { onNavigateToVaultCalled = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBack event should call onNavigateBack`() {
|
||||
mutableEventFlow.tryEmit(LeaveOrganizationEvent.NavigateBack)
|
||||
assertTrue(onNavigateBackCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToVault event should call onNavigateToVault`() {
|
||||
mutableEventFlow.tryEmit(LeaveOrganizationEvent.NavigateToVault)
|
||||
assertTrue(onNavigateToVaultCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back button click should emit NavigateBack event`() {
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Back")
|
||||
.performClick()
|
||||
verify { viewModel.trySendAction(LeaveOrganizationAction.BackClick) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leave organization button click should emit LeaveOrganizationClick action`() {
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Leave $ORGANIZATION_NAME")
|
||||
.filterToOne(hasClickAction())
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
verify { viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `help link button click should emit HelpLinkClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("How to manage My vault")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
verify { viewModel.trySendAction(LeaveOrganizationAction.HelpLinkClick) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `organization name should be displayed in title`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Are you sure you want to leave $ORGANIZATION_NAME?")
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `organization name should be displayed in button`() {
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Leave $ORGANIZATION_NAME")
|
||||
.filterToOne(hasClickAction())
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `warning text should be displayed`() {
|
||||
composeTestRule
|
||||
.onNodeWithText(
|
||||
text = "By declining, your personal items will stay in your account, but you’ll " +
|
||||
"lose access to shared items and organization features.\n\nContact your " +
|
||||
"admin to regain access.",
|
||||
)
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading dialog should not be displayed by default`() {
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Loading")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading dialog should be displayed when dialogState is Loading`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = LeaveOrganizationState.DialogState.Loading)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Loading")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error dialog should not be displayed by default`() {
|
||||
composeTestRule
|
||||
.onAllNodesWithText("An error has occurred")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error dialog should be displayed when dialogState is Error`() {
|
||||
val errorMessage = "Something went wrong"
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = LeaveOrganizationState.DialogState.Error(
|
||||
message = errorMessage.asText(),
|
||||
error = Throwable("Test error"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("An error has occurred")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertExists()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText(errorMessage)
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error dialog dismiss should emit DismissDialog action`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = LeaveOrganizationState.DialogState.Error(
|
||||
message = "Error message".asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Okay")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.DismissDialog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val ORGANIZATION_ID = "organization-id-1"
|
||||
private const val ORGANIZATION_NAME = "Test Organization"
|
||||
|
||||
private val DEFAULT_STATE = LeaveOrganizationState(
|
||||
organizationId = ORGANIZATION_ID,
|
||||
organizationName = ORGANIZATION_NAME,
|
||||
dialogState = null,
|
||||
)
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.leaveorganization
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.data.repository.model.Environment
|
||||
import com.bitwarden.network.model.OrganizationType
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
|
||||
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.Organization
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
||||
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.runs
|
||||
import io.mockk.unmockkStatic
|
||||
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.Assertions.assertNull
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class LeaveOrganizationViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val mockAuthRepository: AuthRepository = mockk {
|
||||
every { userStateFlow } returns MutableStateFlow(DEFAULT_USER_STATE)
|
||||
}
|
||||
|
||||
private val mockSnackbarRelayManager: SnackbarRelayManager<SnackbarRelay> = mockk {
|
||||
every { sendSnackbarData(data = any(), relay = any()) } just runs
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkStatic(SavedStateHandle::toLeaveOrganizationArgs)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic(SavedStateHandle::toLeaveOrganizationArgs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct`() {
|
||||
val viewModel = createViewModel()
|
||||
val expectedState = LeaveOrganizationState(
|
||||
organizationId = ORGANIZATION_ID,
|
||||
organizationName = ORGANIZATION_NAME,
|
||||
dialogState = null,
|
||||
)
|
||||
assertEquals(expectedState, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BackClick should emit NavigateBack event`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.BackClick)
|
||||
assertEquals(LeaveOrganizationEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HelpLinkClick should emit LaunchUri event with help URL`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.HelpLinkClick)
|
||||
val event = awaitItem()
|
||||
assert(event is LeaveOrganizationEvent.LaunchUri)
|
||||
assertEquals(
|
||||
"https://bitwarden.com/help/transfer-ownership/",
|
||||
(event as LeaveOrganizationEvent.LaunchUri).uri,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LeaveOrganizationClick should show loading dialog`() = runTest {
|
||||
coEvery {
|
||||
mockAuthRepository.leaveOrganization(any())
|
||||
} coAnswers {
|
||||
LeaveOrganizationResult.Success
|
||||
}
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(null, awaitItem().dialogState)
|
||||
|
||||
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
|
||||
|
||||
val loadingState = awaitItem()
|
||||
assert(loadingState.dialogState is LeaveOrganizationState.DialogState.Loading)
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LeaveOrganizationClick with Success should send snackbar and navigate to vault`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
mockAuthRepository.leaveOrganization(ORGANIZATION_ID)
|
||||
} returns LeaveOrganizationResult.Success
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
|
||||
assertEquals(LeaveOrganizationEvent.NavigateToVault, awaitItem())
|
||||
}
|
||||
|
||||
verify {
|
||||
mockSnackbarRelayManager.sendSnackbarData(
|
||||
relay = SnackbarRelay.LEFT_ORGANIZATION,
|
||||
data = BitwardenSnackbarData(
|
||||
message = BitwardenString.you_left_the_organization.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LeaveOrganizationClick with Error should show error dialog`() = runTest {
|
||||
val error = Throwable("Test error")
|
||||
coEvery {
|
||||
mockAuthRepository.leaveOrganization(ORGANIZATION_ID)
|
||||
} returns LeaveOrganizationResult.Error(error)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
|
||||
|
||||
val state = viewModel.stateFlow.value
|
||||
assert(state.dialogState is LeaveOrganizationState.DialogState.Error)
|
||||
val dialogState = state.dialogState as LeaveOrganizationState.DialogState.Error
|
||||
assertEquals(BitwardenString.generic_error_message.asText(), dialogState.message)
|
||||
assertEquals(error, dialogState.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DismissDialog should clear dialog state`() = runTest {
|
||||
coEvery {
|
||||
mockAuthRepository.leaveOrganization(ORGANIZATION_ID)
|
||||
} returns LeaveOrganizationResult.Error(Throwable("Error"))
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
|
||||
|
||||
assert(viewModel.stateFlow.value.dialogState != null)
|
||||
|
||||
viewModel.trySendAction(LeaveOrganizationAction.DismissDialog)
|
||||
|
||||
assertNull(viewModel.stateFlow.value.dialogState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state should be restored from SavedStateHandle`() {
|
||||
val savedState = LeaveOrganizationState(
|
||||
organizationId = "saved-org-id",
|
||||
organizationName = "Saved Organization",
|
||||
dialogState = null,
|
||||
)
|
||||
val savedStateHandle = SavedStateHandle(mapOf("state" to savedState))
|
||||
|
||||
val viewModel = LeaveOrganizationViewModel(
|
||||
authRepository = mockAuthRepository,
|
||||
snackbarRelayManager = mockSnackbarRelayManager,
|
||||
savedStateHandle = savedStateHandle,
|
||||
)
|
||||
|
||||
assertEquals(savedState, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
private fun createViewModel(
|
||||
savedStateHandle: SavedStateHandle = SavedStateHandle(),
|
||||
): LeaveOrganizationViewModel {
|
||||
every { savedStateHandle.toLeaveOrganizationArgs() } returns LeaveOrganizationArgs(
|
||||
organizationId = ORGANIZATION_ID,
|
||||
organizationName = ORGANIZATION_NAME,
|
||||
)
|
||||
return LeaveOrganizationViewModel(
|
||||
authRepository = mockAuthRepository,
|
||||
snackbarRelayManager = mockSnackbarRelayManager,
|
||||
savedStateHandle = savedStateHandle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val ORGANIZATION_ID = "organization-id-1"
|
||||
private const val ORGANIZATION_NAME = "Test Organization"
|
||||
|
||||
private val DEFAULT_ORGANIZATION = Organization(
|
||||
id = ORGANIZATION_ID,
|
||||
name = ORGANIZATION_NAME,
|
||||
shouldManageResetPassword = false,
|
||||
shouldUseKeyConnector = false,
|
||||
role = OrganizationType.USER,
|
||||
keyConnectorUrl = null,
|
||||
userIsClaimedByOrganization = false,
|
||||
limitItemDeletion = false,
|
||||
)
|
||||
|
||||
private val DEFAULT_USER_STATE = UserState(
|
||||
activeUserId = "user-id-1",
|
||||
accounts = listOf(
|
||||
UserState.Account(
|
||||
userId = "user-id-1",
|
||||
name = "Test User",
|
||||
email = "test@example.com",
|
||||
avatarColorHex = "#175DDC",
|
||||
environment = Environment.Us,
|
||||
isPremium = false,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
needsPasswordReset = false,
|
||||
needsMasterPassword = false,
|
||||
hasMasterPassword = true,
|
||||
trustedDevice = null,
|
||||
organizations = listOf(DEFAULT_ORGANIZATION),
|
||||
isBiometricsEnabled = false,
|
||||
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
|
||||
isUsingKeyConnector = false,
|
||||
onboardingStatus = OnboardingStatus.COMPLETE,
|
||||
firstTimeState = FirstTimeState(),
|
||||
isExportable = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="100dp"
|
||||
android:height="100dp"
|
||||
android:viewportHeight="100"
|
||||
android:viewportWidth="100">
|
||||
<path
|
||||
android:name="secondary"
|
||||
android:fillColor="#AAC3EF"
|
||||
android:pathData="M56.25,8.33C56.25,3.73 59.98,0 64.58,0H72.92C77.52,0 81.25,3.73 81.25,8.33V12.5H56.25V8.33Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M72.92,2.08H64.58C61.13,2.08 58.33,4.88 58.33,8.33V10.42H79.17V8.33C79.17,4.88 76.37,2.08 72.92,2.08ZM64.58,0C59.98,0 56.25,3.73 56.25,8.33V12.5H81.25V8.33C81.25,3.73 77.52,0 72.92,0H64.58Z" />
|
||||
<path
|
||||
android:name="secondary"
|
||||
android:fillColor="#AAC3EF"
|
||||
android:pathData="M48.96,16.67C48.96,12.06 52.69,8.33 57.29,8.33H80.21C84.81,8.33 88.54,12.06 88.54,16.67V22.92H48.96V16.67Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M80.21,10.42H57.29C53.84,10.42 51.04,13.21 51.04,16.67V20.83H86.46V16.67C86.46,13.21 83.66,10.42 80.21,10.42ZM57.29,8.33C52.69,8.33 48.96,12.06 48.96,16.67V22.92H88.54V16.67C88.54,12.06 84.81,8.33 80.21,8.33H57.29Z" />
|
||||
<path
|
||||
android:name="secondary"
|
||||
android:fillColor="#AAC3EF"
|
||||
android:pathData="M41.67,27.08C41.67,22.48 45.4,18.75 50,18.75H87.5C92.1,18.75 95.83,22.48 95.83,27.08V95.83H41.67V27.08Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M87.5,20.83H50C46.55,20.83 43.75,23.63 43.75,27.08V93.75H93.75V27.08C93.75,23.63 90.95,20.83 87.5,20.83ZM50,18.75C45.4,18.75 41.67,22.48 41.67,27.08V95.83H95.83V27.08C95.83,22.48 92.1,18.75 87.5,18.75H50Z" />
|
||||
<path
|
||||
android:name="primary"
|
||||
android:fillColor="#DBE5F6"
|
||||
android:pathData="M4.17,45.83C4.17,41.23 7.9,37.5 12.5,37.5H52.08C56.69,37.5 60.42,41.23 60.42,45.83V95.83H4.17V45.83Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M52.08,39.58H12.5C9.05,39.58 6.25,42.38 6.25,45.83V93.75H58.33V45.83C58.33,42.38 55.54,39.58 52.08,39.58ZM12.5,37.5C7.9,37.5 4.17,41.23 4.17,45.83V95.83H60.42V45.83C60.42,41.23 56.69,37.5 52.08,37.5H12.5Z" />
|
||||
<path
|
||||
android:name="accent"
|
||||
android:fillColor="#FFBF00"
|
||||
android:pathData="M71.54,62.95C73.55,59.5 78.54,59.5 80.54,62.95L97.53,92.17C99.55,95.65 97.05,100 93.03,100H59.05C55.04,100 52.53,95.65 54.55,92.17L71.54,62.95Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M95.73,93.22L78.74,64C77.54,61.93 74.54,61.93 73.34,64L56.35,93.22C55.14,95.3 56.64,97.92 59.05,97.92H93.03C95.44,97.92 96.94,95.3 95.73,93.22ZM80.54,62.95C78.54,59.5 73.55,59.5 71.54,62.95L54.55,92.17C52.53,95.65 55.04,100 59.05,100H93.03C97.05,100 99.55,95.65 97.53,92.17L80.54,62.95Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:pathData="M78.13,91.67C78.13,92.82 77.19,93.75 76.04,93.75C74.89,93.75 73.96,92.82 73.96,91.67C73.96,90.52 74.89,89.58 76.04,89.58C77.19,89.58 78.13,90.52 78.13,91.67Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:pathData="M72.98,73.49C72.95,73.19 73.19,72.92 73.5,72.92H78.59C78.9,72.92 79.14,73.19 79.1,73.49L77.65,87.03C77.63,87.3 77.4,87.5 77.14,87.5H74.95C74.68,87.5 74.46,87.3 74.43,87.03L72.98,73.49Z" />
|
||||
<path
|
||||
android:name="secondary"
|
||||
android:fillColor="#AAC3EF"
|
||||
android:pathData="M21.88,83.33C21.88,78.73 25.61,75 30.21,75H34.38C38.98,75 42.71,78.73 42.71,83.33V95.83H21.88V83.33Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M34.38,77.08H30.21C26.76,77.08 23.96,79.88 23.96,83.33V93.75H40.63V83.33C40.63,79.88 37.83,77.08 34.38,77.08ZM30.21,75C25.61,75 21.88,78.73 21.88,83.33V95.83H42.71V83.33C42.71,78.73 38.98,75 34.38,75H30.21Z" />
|
||||
<path
|
||||
android:name="tertiary"
|
||||
android:fillColor="#ffffff"
|
||||
android:pathData="M13.54,47.92C13.54,47.34 14.01,46.88 14.58,46.88H22.92C23.49,46.88 23.96,47.34 23.96,47.92V56.25C23.96,56.83 23.49,57.29 22.92,57.29H14.58C14.01,57.29 13.54,56.83 13.54,56.25V47.92Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M15.63,48.96V55.21H21.88V48.96H15.63ZM14.58,46.88C14.01,46.88 13.54,47.34 13.54,47.92V56.25C13.54,56.83 14.01,57.29 14.58,57.29H22.92C23.49,57.29 23.96,56.83 23.96,56.25V47.92C23.96,47.34 23.49,46.88 22.92,46.88H14.58Z" />
|
||||
<path
|
||||
android:name="tertiary"
|
||||
android:fillColor="#ffffff"
|
||||
android:pathData="M13.54,61.46C13.54,60.88 14.01,60.42 14.58,60.42H22.92C23.49,60.42 23.96,60.88 23.96,61.46V69.79C23.96,70.37 23.49,70.83 22.92,70.83H14.58C14.01,70.83 13.54,70.37 13.54,69.79V61.46Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M15.63,62.5V68.75H21.88V62.5H15.63ZM14.58,60.42C14.01,60.42 13.54,60.88 13.54,61.46V69.79C13.54,70.37 14.01,70.83 14.58,70.83H22.92C23.49,70.83 23.96,70.37 23.96,69.79V61.46C23.96,60.88 23.49,60.42 22.92,60.42H14.58Z" />
|
||||
<path
|
||||
android:name="tertiary"
|
||||
android:fillColor="#ffffff"
|
||||
android:pathData="M27.08,47.92C27.08,47.34 27.55,46.88 28.13,46.88H36.46C37.03,46.88 37.5,47.34 37.5,47.92V56.25C37.5,56.83 37.03,57.29 36.46,57.29H28.13C27.55,57.29 27.08,56.83 27.08,56.25V47.92Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M29.17,48.96V55.21H35.42V48.96H29.17ZM28.13,46.88C27.55,46.88 27.08,47.34 27.08,47.92V56.25C27.08,56.83 27.55,57.29 28.13,57.29H36.46C37.03,57.29 37.5,56.83 37.5,56.25V47.92C37.5,47.34 37.03,46.88 36.46,46.88H28.13Z" />
|
||||
<path
|
||||
android:name="tertiary"
|
||||
android:fillColor="#ffffff"
|
||||
android:pathData="M27.08,61.46C27.08,60.88 27.55,60.42 28.13,60.42H36.46C37.03,60.42 37.5,60.88 37.5,61.46V69.79C37.5,70.37 37.03,70.83 36.46,70.83H28.13C27.55,70.83 27.08,70.37 27.08,69.79V61.46Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M29.17,62.5V68.75H35.42V62.5H29.17ZM28.13,60.42C27.55,60.42 27.08,60.88 27.08,61.46V69.79C27.08,70.37 27.55,70.83 28.13,70.83H36.46C37.03,70.83 37.5,70.37 37.5,69.79V61.46C37.5,60.88 37.03,60.42 36.46,60.42H28.13Z" />
|
||||
<path
|
||||
android:name="tertiary"
|
||||
android:fillColor="#ffffff"
|
||||
android:pathData="M40.63,61.46C40.63,60.88 41.09,60.42 41.67,60.42H50C50.58,60.42 51.04,60.88 51.04,61.46V69.79C51.04,70.37 50.58,70.83 50,70.83H41.67C41.09,70.83 40.63,70.37 40.63,69.79V61.46Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M42.71,62.5V68.75H48.96V62.5H42.71ZM41.67,60.42C41.09,60.42 40.63,60.88 40.63,61.46V69.79C40.63,70.37 41.09,70.83 41.67,70.83H50C50.58,70.83 51.04,70.37 51.04,69.79V61.46C51.04,60.88 50.58,60.42 50,60.42H41.67Z" />
|
||||
<path
|
||||
android:name="tertiary"
|
||||
android:fillColor="#ffffff"
|
||||
android:pathData="M40.63,47.92C40.63,47.34 41.09,46.88 41.67,46.88H50C50.58,46.88 51.04,47.34 51.04,47.92V56.25C51.04,56.83 50.58,57.29 50,57.29H41.67C41.09,57.29 40.63,56.83 40.63,56.25V47.92Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M42.71,48.96V55.21H48.96V48.96H42.71ZM41.67,46.88C41.09,46.88 40.63,47.34 40.63,47.92V56.25C40.63,56.83 41.09,57.29 41.67,57.29H50C50.58,57.29 51.04,56.83 51.04,56.25V47.92C51.04,47.34 50.58,46.88 50,46.88H41.67Z" />
|
||||
<path
|
||||
android:name="outline"
|
||||
android:fillColor="#020F66"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M48.96,28.13C48.96,27.55 49.42,27.08 50,27.08H56.25C56.83,27.08 57.29,27.55 57.29,28.13C57.29,28.7 56.83,29.17 56.25,29.17L50,29.17C49.42,29.17 48.96,28.7 48.96,28.13ZM60.42,27.08C59.84,27.08 59.38,27.55 59.38,28.13C59.38,28.7 59.84,29.17 60.42,29.17L66.67,29.17C67.24,29.17 67.71,28.7 67.71,28.13C67.71,27.55 67.24,27.08 66.67,27.08H60.42ZM69.79,28.13C69.79,27.55 70.26,27.08 70.83,27.08H77.08C77.66,27.08 78.13,27.55 78.13,28.13C78.13,28.7 77.66,29.17 77.08,29.17L70.83,29.17C70.26,29.17 69.79,28.7 69.79,28.13ZM80.21,28.13C80.21,27.55 80.67,27.08 81.25,27.08H87.5C88.08,27.08 88.54,27.55 88.54,28.13C88.54,28.7 88.08,29.17 87.5,29.17L81.25,29.17C80.67,29.17 80.21,28.7 80.21,28.13ZM80.21,34.38C80.21,33.8 80.67,33.33 81.25,33.33L87.5,33.33C88.08,33.33 88.54,33.8 88.54,34.38C88.54,34.95 88.08,35.42 87.5,35.42L81.25,35.42C80.67,35.42 80.21,34.95 80.21,34.38ZM81.25,39.58C80.67,39.58 80.21,40.05 80.21,40.63C80.21,41.2 80.67,41.67 81.25,41.67L87.5,41.67C88.08,41.67 88.54,41.2 88.54,40.63C88.54,40.05 88.08,39.58 87.5,39.58L81.25,39.58ZM80.21,46.88C80.21,46.3 80.67,45.83 81.25,45.83H87.5C88.08,45.83 88.54,46.3 88.54,46.88C88.54,47.45 88.08,47.92 87.5,47.92H81.25C80.67,47.92 80.21,47.45 80.21,46.88ZM81.25,52.08C80.67,52.08 80.21,52.55 80.21,53.13C80.21,53.7 80.67,54.17 81.25,54.17H87.5C88.08,54.17 88.54,53.7 88.54,53.13C88.54,52.55 88.08,52.08 87.5,52.08H81.25ZM70.83,33.33C70.26,33.33 69.79,33.8 69.79,34.38C69.79,34.95 70.26,35.42 70.83,35.42L77.08,35.42C77.66,35.42 78.13,34.95 78.13,34.38C78.13,33.8 77.66,33.33 77.08,33.33L70.83,33.33ZM69.79,40.63C69.79,40.05 70.26,39.58 70.83,39.58L77.08,39.58C77.66,39.58 78.13,40.05 78.13,40.63C78.13,41.2 77.66,41.67 77.08,41.67L70.83,41.67C70.26,41.67 69.79,41.2 69.79,40.63ZM70.83,45.83C70.26,45.83 69.79,46.3 69.79,46.88C69.79,47.45 70.26,47.92 70.83,47.92H77.08C77.66,47.92 78.13,47.45 78.13,46.88C78.13,46.3 77.66,45.83 77.08,45.83H70.83ZM69.79,53.13C69.79,52.55 70.26,52.08 70.83,52.08H77.08C77.66,52.08 78.13,52.55 78.13,53.13C78.13,53.7 77.66,54.17 77.08,54.17H70.83C70.26,54.17 69.79,53.7 69.79,53.13ZM60.42,33.33C59.84,33.33 59.38,33.8 59.38,34.38C59.38,34.95 59.84,35.42 60.42,35.42L66.67,35.42C67.24,35.42 67.71,34.95 67.71,34.38C67.71,33.8 67.24,33.33 66.67,33.33L60.42,33.33ZM48.96,34.38C48.96,33.8 49.42,33.33 50,33.33L56.25,33.33C56.83,33.33 57.29,33.8 57.29,34.38C57.29,34.95 56.83,35.42 56.25,35.42L50,35.42C49.42,35.42 48.96,34.95 48.96,34.38ZM60.42,39.58C59.84,39.58 59.38,40.05 59.38,40.63C59.38,41.2 59.84,41.67 60.42,41.67L66.67,41.67C67.24,41.67 67.71,41.2 67.71,40.63C67.71,40.05 67.24,39.58 66.67,39.58L60.42,39.58ZM59.38,46.88C59.38,46.3 59.84,45.83 60.42,45.83H66.67C67.24,45.83 67.71,46.3 67.71,46.88C67.71,47.45 67.24,47.92 66.67,47.92H60.42C59.84,47.92 59.38,47.45 59.38,46.88ZM60.42,52.08C59.84,52.08 59.38,52.55 59.38,53.13C59.38,53.7 59.84,54.17 60.42,54.17H66.67C67.24,54.17 67.71,53.7 67.71,53.13C67.71,52.55 67.24,52.08 66.67,52.08H60.42Z" />
|
||||
</vector>
|
||||
@@ -496,6 +496,10 @@ Scanning will happen automatically.</string>
|
||||
<string name="remove_master_password">Remove master password</string>
|
||||
<string name="leave_organization">Leave organization</string>
|
||||
<string name="leave_organization_name">Leave %1$s?</string>
|
||||
<string name="are_you_sure_you_want_to_leave_organization">Are you sure you want to leave %1$s?</string>
|
||||
<string name="leave_organization_button">Leave %1$s</string>
|
||||
<string name="leave_organization_warning">By declining, your personal items will stay in your account, but you’ll lose access to shared items and organization features.\n\nContact your admin to regain access.</string>
|
||||
<string name="how_to_manage_my_vault">How to manage My vault</string>
|
||||
<string name="fido2_authenticate_web_authn">Authenticate WebAuthn</string>
|
||||
<string name="fido2_return_to_app">Return to app</string>
|
||||
<string name="reset_password_auto_enroll_invite_warning">This organization has an enterprise policy that will automatically enroll you in password reset. Enrollment will allow organization administrators to change your master password.</string>
|
||||
@@ -1161,4 +1165,5 @@ Do you want to switch to this account?</string>
|
||||
<string name="use_your_devices_lock_method_to_unlock_the_app">Use your device’s lock method to unlock the app</string>
|
||||
<string name="loading_vault_data">Loading vault data…</string>
|
||||
<string name="resending">Resending</string>
|
||||
<string name="you_left_the_organization">You left the organization</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user