[PM-28470] Implement revoke from organization (#6383)

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
aj-rosado
2026-01-21 16:53:26 +00:00
committed by GitHub
parent c52910e74a
commit a7badf8b0b
21 changed files with 174 additions and 61 deletions

View File

@@ -26,6 +26,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.RevokeFromOrganizationResult
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
@@ -402,4 +403,11 @@ interface AuthRepository :
suspend fun leaveOrganization(
organizationId: String,
): LeaveOrganizationResult
/**
* Revokes self from the organization that matches the given [organizationId]
*/
suspend fun revokeFromOrganization(
organizationId: String,
): RevokeFromOrganizationResult
}

View File

@@ -79,6 +79,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.RevokeFromOrganizationResult
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
@@ -1393,6 +1394,14 @@ class AuthRepositoryImpl(
onFailure = { LeaveOrganizationResult.Error(error = it) },
)
override suspend fun revokeFromOrganization(
organizationId: String,
): RevokeFromOrganizationResult =
organizationService.revokeFromOrganization(organizationId).fold(
onSuccess = { RevokeFromOrganizationResult.Success },
onFailure = { RevokeFromOrganizationResult.Error(error = it) },
)
@Suppress("CyclomaticComplexMethod")
private suspend fun validatePasswordAgainstPolicy(
password: String,

View File

@@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of leaving an organization.
*/
sealed class RevokeFromOrganizationResult {
/**
* Revoke from organization succeeded.
*/
data object Success : RevokeFromOrganizationResult()
/**
* There was an error revoking from the organization.
*/
data class Error(
val error: Throwable?,
) : RevokeFromOrganizationResult()
}

View File

@@ -29,4 +29,10 @@ interface VaultMigrationManager {
userId: String,
organizationId: String,
): MigratePersonalVaultResult
/**
* Clears the migration state, setting it to [VaultMigrationData.NoMigrationRequired].
* This should be called when the user declines migration or leaves the organization.
*/
fun clearMigrationState()
}

View File

@@ -190,7 +190,7 @@ class VaultMigrationManagerImpl(
.getOrElse { return MigratePersonalVaultResult.Failure(it) }
if (personalCiphers.isEmpty()) {
mutableVaultMigrationDataStateFlow.update { VaultMigrationData.NoMigrationRequired }
clearMigrationState()
return MigratePersonalVaultResult.Success
}
@@ -212,10 +212,14 @@ class VaultMigrationManagerImpl(
collectionIds = listOfNotNull(defaultUserCollection.id),
).getOrElse { return MigratePersonalVaultResult.Failure(it) }
mutableVaultMigrationDataStateFlow.update { VaultMigrationData.NoMigrationRequired }
clearMigrationState()
return MigratePersonalVaultResult.Success
}
override fun clearMigrationState() {
mutableVaultMigrationDataStateFlow.update { VaultMigrationData.NoMigrationRequired }
}
private fun getDefaultUserCollection(
vaultData: VaultData,
organizationId: String,

View File

@@ -57,6 +57,8 @@ import com.x8bit.bitwarden.ui.vault.feature.importlogins.navigateToImportLoginsS
import com.x8bit.bitwarden.ui.vault.feature.item.navigateToVaultItem
import com.x8bit.bitwarden.ui.vault.feature.item.vaultItemDestination
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.vaultItemListingDestinationAsRoot
import com.x8bit.bitwarden.ui.vault.feature.leaveorganization.leaveOrganizationDestination
import com.x8bit.bitwarden.ui.vault.feature.leaveorganization.navigateToLeaveOrganization
import com.x8bit.bitwarden.ui.vault.feature.manualcodeentry.navigateToManualCodeEntryScreen
import com.x8bit.bitwarden.ui.vault.feature.manualcodeentry.vaultManualCodeEntryDestination
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.migrateToMyItemsDestination
@@ -267,7 +269,16 @@ fun NavGraphBuilder.vaultUnlockedGraph(
migrateToMyItemsDestination(
onNavigateToVault = { navController.popBackStack() },
onNavigateToLeaveOrganization = { },
onNavigateToLeaveOrganization = { organizationId, organizationName ->
navController.navigateToLeaveOrganization(
organizationId = organizationId,
organizationName = organizationName,
)
},
)
leaveOrganizationDestination(
onNavigateBack = { navController.popBackStack() },
)
}
}

View File

@@ -51,12 +51,10 @@ fun SavedStateHandle.toLeaveOrganizationArgs(): LeaveOrganizationArgs {
*/
fun NavGraphBuilder.leaveOrganizationDestination(
onNavigateBack: () -> Unit,
onNavigateToVault: () -> Unit,
) {
composableWithPushTransitions<LeaveOrganizationRoute> {
LeaveOrganizationScreen(
onNavigateBack = onNavigateBack,
onNavigateToVault = onNavigateToVault,
)
}
}

View File

@@ -50,7 +50,6 @@ import com.x8bit.bitwarden.ui.vault.feature.leaveorganization.handlers.rememberL
@Composable
fun LeaveOrganizationScreen(
onNavigateBack: () -> Unit,
onNavigateToVault: () -> Unit,
viewModel: LeaveOrganizationViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
) {
@@ -60,7 +59,6 @@ fun LeaveOrganizationScreen(
EventsEffect(viewModel = viewModel) { event ->
when (event) {
LeaveOrganizationEvent.NavigateBack -> onNavigateBack()
LeaveOrganizationEvent.NavigateToVault -> onNavigateToVault()
is LeaveOrganizationEvent.LaunchUri -> {
intentManager.launchUri(event.uri.toUri())
}

View File

@@ -10,9 +10,10 @@ 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.data.auth.repository.model.RevokeFromOrganizationResult
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
@@ -32,6 +33,7 @@ class LeaveOrganizationViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
private val organizationEventManager: OrganizationEventManager,
private val vaultMigrationManager: VaultMigrationManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<LeaveOrganizationState, LeaveOrganizationEvent, LeaveOrganizationAction>(
initialState = savedStateHandle[KEY_STATE] ?: run {
@@ -56,8 +58,8 @@ class LeaveOrganizationViewModel @Inject constructor(
LeaveOrganizationAction.LeaveOrganizationClick -> handleLeaveOrganizationClick()
LeaveOrganizationAction.HelpLinkClick -> handleHelpLinkClick()
LeaveOrganizationAction.DismissDialog -> handleDismissDialog()
is LeaveOrganizationAction.Internal.LeaveOrganizationResultReceived -> {
handleLeaveOrganizationResultReceived(action)
is LeaveOrganizationAction.Internal.RevokeFromOrganizationResultReceived -> {
handleRevokeFromOrganizationResultReceived(action)
}
}
}
@@ -71,9 +73,9 @@ class LeaveOrganizationViewModel @Inject constructor(
it.copy(dialogState = LeaveOrganizationState.DialogState.Loading)
}
viewModelScope.launch {
val result = authRepository.leaveOrganization(state.organizationId)
val result = authRepository.revokeFromOrganization(state.organizationId)
sendAction(
LeaveOrganizationAction.Internal.LeaveOrganizationResultReceived(result),
LeaveOrganizationAction.Internal.RevokeFromOrganizationResultReceived(result),
)
}
}
@@ -92,27 +94,28 @@ class LeaveOrganizationViewModel @Inject constructor(
}
}
private fun handleLeaveOrganizationResultReceived(
action: LeaveOrganizationAction.Internal.LeaveOrganizationResultReceived,
private fun handleRevokeFromOrganizationResultReceived(
action: LeaveOrganizationAction.Internal.RevokeFromOrganizationResultReceived,
) {
when (val result = action.result) {
is LeaveOrganizationResult.Success -> {
organizationEventManager.trackEvent(
event = OrganizationEvent.ItemOrganizationDeclined,
)
mutableStateFlow.update {
it.copy(dialogState = null)
}
is RevokeFromOrganizationResult.Success -> {
snackbarRelayManager.sendSnackbarData(
relay = SnackbarRelay.LEFT_ORGANIZATION,
data = BitwardenSnackbarData(
message = BitwardenString.you_left_the_organization.asText(),
),
)
sendEvent(LeaveOrganizationEvent.NavigateToVault)
organizationEventManager.trackEvent(
event = OrganizationEvent.ItemOrganizationDeclined,
)
mutableStateFlow.update {
it.copy(dialogState = null)
}
// Navigation will be handled on RootNavViewModel by migration state change
vaultMigrationManager.clearMigrationState()
}
is LeaveOrganizationResult.Error -> {
is RevokeFromOrganizationResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = LeaveOrganizationState.DialogState.Error(
@@ -166,11 +169,6 @@ sealed class LeaveOrganizationEvent {
*/
data object NavigateBack : LeaveOrganizationEvent()
/**
* Navigate to the Vault screen.
*/
data object NavigateToVault : LeaveOrganizationEvent()
/**
* Launch external URI.
*/
@@ -206,10 +204,10 @@ sealed class LeaveOrganizationAction {
*/
sealed class Internal : LeaveOrganizationAction() {
/**
* Leave organization result received from repository.
* Revoke from organization result received from repository.
*/
data class LeaveOrganizationResultReceived(
val result: LeaveOrganizationResult,
data class RevokeFromOrganizationResultReceived(
val result: RevokeFromOrganizationResult,
) : Internal()
}
}

View File

@@ -71,7 +71,7 @@ fun NavController.navigateToMigrateToMyItems(
*/
fun NavGraphBuilder.migrateToMyItemsDestination(
onNavigateToVault: () -> Unit,
onNavigateToLeaveOrganization: () -> Unit,
onNavigateToLeaveOrganization: (organizationId: String, organizationName: String) -> Unit,
) {
composableWithSlideTransitions<MigrateToMyItemsRoute> {
MigrateToMyItemsScreen(

View File

@@ -45,7 +45,7 @@ import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.handler.rememberMig
@Composable
fun MigrateToMyItemsScreen(
onNavigateToVault: () -> Unit,
onNavigateToLeaveOrganization: () -> Unit,
onNavigateToLeaveOrganization: (organizationId: String, organizationName: String) -> Unit,
viewModel: MigrateToMyItemsViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
) {
@@ -55,7 +55,9 @@ fun MigrateToMyItemsScreen(
EventsEffect(viewModel = viewModel) { event ->
when (event) {
MigrateToMyItemsEvent.NavigateToVault -> onNavigateToVault()
MigrateToMyItemsEvent.NavigateToLeaveOrganization -> onNavigateToLeaveOrganization()
is MigrateToMyItemsEvent.NavigateToLeaveOrganization -> {
onNavigateToLeaveOrganization(event.organizationId, event.organizationName)
}
is MigrateToMyItemsEvent.LaunchUri -> intentManager.launchUri(event.uri.toUri())
}
}

View File

@@ -105,7 +105,12 @@ class MigrateToMyItemsViewModel @Inject constructor(
}
private fun handleDeclineAndLeaveClicked() {
sendEvent(MigrateToMyItemsEvent.NavigateToLeaveOrganization)
sendEvent(
MigrateToMyItemsEvent.NavigateToLeaveOrganization(
organizationId = state.organizationId,
organizationName = state.organizationName,
),
)
}
private fun handleHelpLinkClicked() {
@@ -207,7 +212,10 @@ sealed class MigrateToMyItemsEvent {
/**
* Navigate to the leave organization flow after declining.
*/
data object NavigateToLeaveOrganization : MigrateToMyItemsEvent()
data class NavigateToLeaveOrganization(
val organizationId: String,
val organizationName: String,
) : MigrateToMyItemsEvent()
/**
* Launch a URI in the browser or appropriate handler.

View File

@@ -205,6 +205,7 @@ class VaultViewModel @Inject constructor(
SnackbarRelay.CIPHER_UPDATED,
SnackbarRelay.FOLDER_CREATED,
SnackbarRelay.LOGINS_IMPORTED,
SnackbarRelay.LEFT_ORGANIZATION,
),
)
.map { VaultAction.Internal.SnackbarDataReceive(it) }

View File

@@ -1164,6 +1164,26 @@ class VaultMigrationManagerTest {
vaultRepository.migrateAttachments(userId, match { it.id == "mockId-2" })
}
}
@Test
fun `clearMigrationState should set migration state to NoMigrationRequired`() = runTest {
val vaultMigrationManager = createVaultMigrationManager()
// Initially state should be NoMigrationRequired
assertEquals(
VaultMigrationData.NoMigrationRequired,
vaultMigrationManager.vaultMigrationDataStateFlow.value,
)
// Call clearMigrationState (should remain NoMigrationRequired)
vaultMigrationManager.clearMigrationState()
// Verify state is still NoMigrationRequired
assertEquals(
VaultMigrationData.NoMigrationRequired,
vaultMigrationManager.vaultMigrationDataStateFlow.value,
)
}
}
private fun createVaultData(

View File

@@ -24,7 +24,6 @@ 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)
@@ -38,7 +37,6 @@ class LeaveOrganizationScreenTest : BitwardenComposeTest() {
setContent {
LeaveOrganizationScreen(
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToVault = { onNavigateToVaultCalled = true },
viewModel = viewModel,
)
}
@@ -50,12 +48,6 @@ class LeaveOrganizationScreenTest : BitwardenComposeTest() {
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

View File

@@ -11,13 +11,14 @@ 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.RevokeFromOrganizationResult
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.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
import io.mockk.coEvery
import io.mockk.every
@@ -49,6 +50,10 @@ class LeaveOrganizationViewModelTest : BaseViewModelTest() {
every { trackEvent(any()) } just runs
}
private val mockVaultMigrationManager: VaultMigrationManager = mockk {
every { clearMigrationState() } just runs
}
@BeforeEach
fun setup() {
mockkStatic(SavedStateHandle::toLeaveOrganizationArgs)
@@ -96,9 +101,9 @@ class LeaveOrganizationViewModelTest : BaseViewModelTest() {
@Test
fun `LeaveOrganizationClick should show loading dialog`() = runTest {
coEvery {
mockAuthRepository.leaveOrganization(any())
mockAuthRepository.revokeFromOrganization(any())
} coAnswers {
LeaveOrganizationResult.Success
RevokeFromOrganizationResult.Success
}
val viewModel = createViewModel()
@@ -116,17 +121,14 @@ class LeaveOrganizationViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength")
@Test
fun `LeaveOrganizationClick with Success should track ItemOrganizationDeclined event, send snackbar, and navigate to vault`() =
fun `LeaveOrganizationClick with Success should track ItemOrganizationDeclined event, send snackbar, and clear migration state`() =
runTest {
coEvery {
mockAuthRepository.leaveOrganization(ORGANIZATION_ID)
} returns LeaveOrganizationResult.Success
mockAuthRepository.revokeFromOrganization(ORGANIZATION_ID)
} returns RevokeFromOrganizationResult.Success
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
assertEquals(LeaveOrganizationEvent.NavigateToVault, awaitItem())
}
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
verify {
mockSnackbarRelayManager.sendSnackbarData(
@@ -138,15 +140,18 @@ class LeaveOrganizationViewModelTest : BaseViewModelTest() {
mockOrganizationEventManager.trackEvent(
event = OrganizationEvent.ItemOrganizationDeclined,
)
mockVaultMigrationManager.clearMigrationState()
}
assertNull(viewModel.stateFlow.value.dialogState)
}
@Test
fun `LeaveOrganizationClick with Error should show error dialog`() = runTest {
val error = Throwable("Test error")
coEvery {
mockAuthRepository.leaveOrganization(ORGANIZATION_ID)
} returns LeaveOrganizationResult.Error(error)
mockAuthRepository.revokeFromOrganization(ORGANIZATION_ID)
} returns RevokeFromOrganizationResult.Error(error)
val viewModel = createViewModel()
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
@@ -161,8 +166,8 @@ class LeaveOrganizationViewModelTest : BaseViewModelTest() {
@Test
fun `DismissDialog should clear dialog state`() = runTest {
coEvery {
mockAuthRepository.leaveOrganization(ORGANIZATION_ID)
} returns LeaveOrganizationResult.Error(Throwable("Error"))
mockAuthRepository.revokeFromOrganization(ORGANIZATION_ID)
} returns RevokeFromOrganizationResult.Error(Throwable("Error"))
val viewModel = createViewModel()
viewModel.trySendAction(LeaveOrganizationAction.LeaveOrganizationClick)
@@ -187,6 +192,7 @@ class LeaveOrganizationViewModelTest : BaseViewModelTest() {
authRepository = mockAuthRepository,
snackbarRelayManager = mockSnackbarRelayManager,
organizationEventManager = mockOrganizationEventManager,
vaultMigrationManager = mockVaultMigrationManager,
savedStateHandle = savedStateHandle,
)
@@ -204,6 +210,7 @@ class LeaveOrganizationViewModelTest : BaseViewModelTest() {
authRepository = mockAuthRepository,
snackbarRelayManager = mockSnackbarRelayManager,
organizationEventManager = mockOrganizationEventManager,
vaultMigrationManager = mockVaultMigrationManager,
savedStateHandle = savedStateHandle,
)
}

View File

@@ -48,7 +48,9 @@ class MigrateToMyItemsScreenTest : BitwardenComposeTest() {
MigrateToMyItemsScreen(
viewModel = viewModel,
onNavigateToVault = { onNavigateToVaultCalled = true },
onNavigateToLeaveOrganization = { onNavigateToLeaveOrganizationCalled = true },
onNavigateToLeaveOrganization = { _, _ ->
onNavigateToLeaveOrganizationCalled = true
},
)
}
}
@@ -107,7 +109,12 @@ class MigrateToMyItemsScreenTest : BitwardenComposeTest() {
@Test
fun `NavigateToLeaveOrganization event should trigger navigation callback`() {
mutableEventFlow.tryEmit(MigrateToMyItemsEvent.NavigateToLeaveOrganization)
mutableEventFlow.tryEmit(
MigrateToMyItemsEvent.NavigateToLeaveOrganization(
organizationId = "test-org-id",
organizationName = "Test Organization",
),
)
assertTrue(onNavigateToLeaveOrganizationCalled)
}

View File

@@ -192,7 +192,13 @@ class MigrateToMyItemsViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(MigrateToMyItemsAction.DeclineAndLeaveClicked)
assertEquals(MigrateToMyItemsEvent.NavigateToLeaveOrganization, awaitItem())
assertEquals(
MigrateToMyItemsEvent.NavigateToLeaveOrganization(
organizationId = ORGANIZATION_ID,
organizationName = ORGANIZATION_NAME,
),
awaitItem(),
)
}
}

View File

@@ -47,4 +47,12 @@ internal interface AuthenticatedOrganizationApi {
suspend fun leaveOrganization(
@Path("id") organizationId: String,
): NetworkResult<Unit>
/**
* Revokes self from organization
*/
@PUT("/organizations/{orgId}/users/revoke-self")
suspend fun revokeFromOrganization(
@Path("orgId") organizationId: String,
): NetworkResult<Unit>
}

View File

@@ -46,4 +46,11 @@ interface OrganizationService {
suspend fun leaveOrganization(
organizationId: String,
): Result<Unit>
/**
* Make a request to revoke self from the organization
*/
suspend fun revokeFromOrganization(
organizationId: String,
): Result<Unit>
}

View File

@@ -62,4 +62,9 @@ internal class OrganizationServiceImpl(
authenticatedOrganizationApi
.leaveOrganization(organizationId = organizationId)
.toResult()
override suspend fun revokeFromOrganization(organizationId: String): Result<Unit> =
authenticatedOrganizationApi
.revokeFromOrganization(organizationId = organizationId)
.toResult()
}