Skip to content

[PM-13428] Remove ownership option #1405

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ class MockVaultRepository: VaultRepository {

var getTOTPKeyIfAllowedToCopyResult: Result<String?, Error> = .success(nil)

var hasOrganizationsCalled = false
var hasOrganizationsResult: Result<Bool, Error> = .success(false)

var isVaultEmptyCalled = false
var isVaultEmptyResult: Result<Bool, Error> = .success(false)

Expand Down Expand Up @@ -224,6 +227,11 @@ class MockVaultRepository: VaultRepository {
return try isVaultEmptyResult.get()
}

func hasOrganizations() async throws -> Bool {
hasOrganizationsCalled = true
return try hasOrganizationsResult.get()
}

func organizationsPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[Organization], Error>> {
organizationsPublisherCalled = true
if let organizationsPublisherError {
Expand Down
9 changes: 9 additions & 0 deletions BitwardenShared/Core/Vault/Repositories/VaultRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ public protocol VaultRepository: AnyObject {
/// - Returns: The TOTP if the user/org has the necessary permissions for it to be copied.
func getTOTPKeyIfAllowedToCopy(cipher: CipherView) async throws -> String?

/// Returns whether the user has organizations.
/// - Returns: Whether the user has organizations.
func hasOrganizations() async throws -> Bool

/// Returns whether the user's vault is empty.
///
/// - Returns: Whether the user's vault is empty.
Expand Down Expand Up @@ -1114,6 +1118,11 @@ extension DefaultVaultRepository: VaultRepository {
return totp
}

func hasOrganizations() async throws -> Bool {
let organizations = try await organizationService.fetchAllOrganizations()
return !organizations.isEmpty
}

func isVaultEmpty() async throws -> Bool {
try await cipherService.cipherCount() == 0
}
Expand Down
16 changes: 16 additions & 0 deletions BitwardenShared/Core/Vault/Repositories/VaultRepositoryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,22 @@ class VaultRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_b
XCTAssertTrue(syncService.needsSyncOnlyCheckLocalData)
}

/// `hasOrganizations()` returns true when there's at least one organization.
func test_hasOrganizations_hasAtLeastOne() async throws {
organizationService.fetchAllOrganizationsResult =
.success([.fixture(id: "One")])
let result = try await subject.hasOrganizations()
XCTAssertTrue(result)
}

/// `hasOrganizations()` returns false when there's no organizations.
func test_hasOrganizations_returnsFalse() async throws {
organizationService.fetchAllOrganizationsResult =
.success([])
let result = try await subject.hasOrganizations()
XCTAssertFalse(result)
}

/// `isVaultEmpty()` throws an error if one occurs.
func test_isVaultEmpty_error() async {
cipherService.cipherCountResult = .failure(BitwardenTestError.example)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ final class AddEditItemProcessor: StateProcessor<// swiftlint:disable:this type_
state.collections.contains(where: { $0.id == collectionId })
}

state.hasOrganizations = try await services.vaultRepository.hasOrganizations()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿค” Do we need to consider if any of the organizations are disabled or if the user isn't confirmed in the organization? For example VaultRepository.fetchCipherOwnershipOptions() has the following checks:

func fetchCipherOwnershipOptions(includePersonal: Bool) async throws -> [CipherOwner] {
let organizations = try await organizationService.fetchAllOrganizations()
let organizationOwners: [CipherOwner] = organizations
.filter { $0.enabled && $0.status == .confirmed }

I wonder if it would be easier to check state.ownershipOptions.contains { !$0.isPersonal } rather than do another fetch from the database?

state.isPersonalOwnershipDisabled = isPersonalOwnershipDisabled
state.ownershipOptions = ownershipOptions
if isPersonalOwnershipDisabled, state.organizationId == nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ protocol AddEditItemState: Sendable {
/// The state for guided tour view.
var guidedTourViewState: GuidedTourViewState { get set }

/// Whether the user belongs to any organization.
var hasOrganizations: Bool { get set }

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐ŸŽจ From what I can see this depends on the cipher and the ownershipOptions. Couldn't this be just readonly and calculate the value based off these other properties? Correct me if I'm wrong but something like:

Suggested change
/// Whether the user belongs to any organization.
var hasOrganizations: Bool { get set }
/// Whether the user belongs to any organization.
var hasOrganizations: Bool {
cipher.organizationId != nil || ownershipOptions.contains { !$0.isPersonal }
}

This would make the change much simpler and you avoid having to update the variable from outside the state.

/// The state for a identity type item.
var identityState: IdentityItemState { get set }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ struct AddEditItemView: View {
)
.accessibilityIdentifier("FolderPicker")

if store.state.configuration.isAdding, let owner = store.state.owner {
if let owner = store.state.owner, store.state.hasOrganizations {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โ“ I'm seeing that if I edit an item in my personal vault, I can change the owner. Is that expected, or should the ownership field be disabled? (e.g. should the disabled modifier not have the !owner.isPersonal check?). Because I thought you couldn't change the other while editing without also adding a separate API call to move the item.

And similarly, if I edit an item in an organization, should I be able to toggle the collection toggles on this screen? I think that also requires a separate API call which is why you needed to go to the edit collections screen.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you belong to an organization and the personal vault isn't disabled it will be possible to change the owner. And yes, we should be able to toggle the collection toggles.
However, as you said both cases require a separate API call, thank you for feedback.
I'm going to remove these changes as it's more complex than I originally thought and falls outside the scope of the bug.

ContentBlock(dividerLeadingPadding: 16) {
BitwardenMenuField(
title: Localizations.owner,
Expand All @@ -176,7 +176,7 @@ struct AddEditItemView: View {
get: { _ in owner },
send: AddEditItemAction.ownerChanged
)
)
).disabled(!store.state.configuration.isAdding && !owner.isPersonal)

ForEach(store.state.collectionsForOwner, id: \.id) { collection in
if let collectionId = collection.id {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ class AddEditItemViewTests: BitwardenTestCase { // swiftlint:disable:this type_b
CipherOwner.personal(email: "user@bitwarden.com"),
organizationOwner,
]
processor.state.hasOrganizations = true
let menu = try subject.inspect().find(bitwardenMenuField: Localizations.owner)
try menu.select(newValue: organizationOwner)
XCTAssertEqual(processor.dispatchedActions.last, .ownerChanged(organizationOwner))
Expand Down Expand Up @@ -681,6 +682,7 @@ class AddEditItemViewTests: BitwardenTestCase { // swiftlint:disable:this type_b
processor.state.ownershipOptions.append(.organization(id: "1", name: "Organization"))
processor.state.owner = .organization(id: "1", name: "Organization")
processor.state.collectionIds = ["2"]
processor.state.hasOrganizations = true

assertSnapshot(of: subject.navStackWrapped, as: .tallPortrait)
}
Expand All @@ -689,6 +691,7 @@ class AddEditItemViewTests: BitwardenTestCase { // swiftlint:disable:this type_b
func test_snapshot_add_login_collectionsNone() {
processor.state.ownershipOptions.append(.organization(id: "1", name: "Organization"))
processor.state.owner = .organization(id: "1", name: "Organization")
processor.state.hasOrganizations = true

assertSnapshot(of: subject.navStackWrapped, as: .tallPortrait)
}
Expand Down Expand Up @@ -723,7 +726,13 @@ class AddEditItemViewTests: BitwardenTestCase { // swiftlint:disable:this type_b

@MainActor
func test_snapshot_add_personalOwnershipPolicy() {
processor.state.ownershipOptions.append(.organization(id: "1", name: "Organization"))
processor.state.owner = .organization(id: "1", name: "Organization")
processor.state.hasOrganizations = true
processor.state.isPersonalOwnershipDisabled = true
processor.state.collections = [
.fixture(id: "1", name: "Default collection", organizationId: "1"),
]
assertSnapshot(of: subject.navStackWrapped, as: .defaultPortrait)
}

Expand All @@ -741,6 +750,41 @@ class AddEditItemViewTests: BitwardenTestCase { // swiftlint:disable:this type_b
assertSnapshot(of: subject.navStackWrapped, as: .tallPortrait)
}

@MainActor
func test_snapshot_edit_full_disabledOwnership() {
processor.state = CipherItemState(
existing: CipherView.loginFixture(
organizationId: "1"
),
hasPremium: true
)!
processor.state.loginState = .fixture(
canViewPassword: true,
fido2Credentials: [.fixture()],
isPasswordVisible: true,
password: "password1!",
uris: [
.init(uri: URL.example.absoluteString),
],
username: "username"
)
processor.state.type = .login
processor.state.name = "Name"
processor.state.isAdditionalOptionsExpanded = true
processor.state.isFavoriteOn = true
processor.state.isMasterPasswordRePromptOn = true
processor.state.notes = "Notes"
processor.state.folderId = "1"
processor.state.folders = [.custom(.fixture(id: "1", name: "Folder"))]
processor.state.ownershipOptions.append(.organization(id: "1", name: "Organization"))
processor.state.owner = .organization(id: "1", name: "Organization")
processor.state.collections = [
.fixture(id: "1", name: "Default collection", organizationId: "1"),
]

assertSnapshot(of: subject.navStackWrapped, as: .tallPortrait)
}

@MainActor
func test_snapshot_edit_full_disabledViewPassword() {
processor.state = CipherItemState(
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions BitwardenShared/UI/Vault/VaultItem/CipherItemState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ struct CipherItemState: Equatable {
]
)

/// Whether the user has organizations.
var hasOrganizations: Bool

/// The state for a identity type item.
var identityState: IdentityItemState

Expand Down Expand Up @@ -195,6 +198,7 @@ struct CipherItemState: Equatable {
configuration: Configuration,
customFields: [CustomFieldState],
folderId: String?,
hasOrganizations: Bool,
identityState: IdentityItemState,
isFavoriteOn: Bool,
isMasterPasswordRePromptOn: Bool,
Expand All @@ -213,6 +217,7 @@ struct CipherItemState: Equatable {
collections = []
customFieldsState = AddEditCustomFieldsState(cipherType: type, customFields: customFields)
self.folderId = folderId
self.hasOrganizations = hasOrganizations
self.identityState = identityState
self.isFavoriteOn = isFavoriteOn
self.isMasterPasswordRePromptOn = isMasterPasswordRePromptOn
Expand All @@ -235,6 +240,7 @@ struct CipherItemState: Equatable {
collectionIds: [String] = [],
customFields: [CustomFieldState] = [],
folderId: String? = nil,
hasOrganizations: Bool = false,
hasPremium: Bool,
name: String? = nil,
organizationId: String? = nil,
Expand All @@ -250,6 +256,7 @@ struct CipherItemState: Equatable {
configuration: .add,
customFields: customFields,
folderId: folderId,
hasOrganizations: hasOrganizations,
identityState: .init(),
isFavoriteOn: false,
isMasterPasswordRePromptOn: false,
Expand Down Expand Up @@ -278,6 +285,7 @@ struct CipherItemState: Equatable {
configuration: .add,
customFields: cipherView.customFields,
folderId: cipherView.folderId,
hasOrganizations: cipherView.organizationId != nil,
identityState: cipherView.identityItemState(),
isFavoriteOn: cipherView.favorite,
isMasterPasswordRePromptOn: cipherView.reprompt == .password,
Expand Down Expand Up @@ -305,6 +313,7 @@ struct CipherItemState: Equatable {
configuration: .existing(cipherView: cipherView),
customFields: cipherView.customFields,
folderId: cipherView.folderId,
hasOrganizations: cipherView.organizationId != nil,
identityState: cipherView.identityItemState(),
isFavoriteOn: cipherView.favorite,
isMasterPasswordRePromptOn: cipherView.reprompt == .password,
Expand Down