1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-13839][PM-13840] Admin Console Collections (#11649)

* allow admin console to see all collections when viewing a cipher

- When "manage all" option is selected all collections should be editable

* update cipher form service to use admin endpoints

* when saving a cipher, choose to move to collections first before saving any other edits

- This handles the case where a cipher is moving from unassigned to assigned and needs to have a collection to save any other edits

* set admin flag when the original cipher has zero collections

- handling the case where the user  un-assigns themselves from a cipher

* add check for the users ability to edit items within the collection

* save cipher edit first to handle when the user unassigns themselves from the cipher

* update filter order of collections

* use cipher returned from the collections endpoint rather than re-fetching it

* fix unit tests by adding canEditItems

* re-enable collection control when orgId is present

* fetch the updated cipher from the respective service for editing a cipher
This commit is contained in:
Nick Krantz
2024-11-07 10:22:35 -06:00
committed by GitHub
parent 05a79d58bb
commit b42741f313
10 changed files with 240 additions and 84 deletions

View File

@@ -87,7 +87,12 @@ describe("ItemDetailsSectionComponent", () => {
component.config.allowPersonalOwnership = true;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
];
component.originalCipherView = {
name: "cipher1",
@@ -116,8 +121,18 @@ describe("ItemDetailsSectionComponent", () => {
component.config.allowPersonalOwnership = true;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
{ id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView,
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
canEditItems: (_org) => false,
} as CollectionView,
{
id: "col2",
name: "Collection 2",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
];
component.originalCipherView = {
name: "cipher1",
@@ -367,9 +382,24 @@ describe("ItemDetailsSectionComponent", () => {
} as CipherView;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
{ id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView,
{ id: "col3", name: "Collection 3", organizationId: "org1" } as CollectionView,
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
{
id: "col2",
name: "Collection 2",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
{
id: "col3",
name: "Collection 3",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
];
fixture.detectChanges();
@@ -387,7 +417,12 @@ describe("ItemDetailsSectionComponent", () => {
component.config.allowPersonalOwnership = true;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
];
fixture.detectChanges();
@@ -414,13 +449,24 @@ describe("ItemDetailsSectionComponent", () => {
} as CipherView;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
{ id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView,
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
{
id: "col2",
name: "Collection 2",
organizationId: "org1",
canEditItems: (_org) => true,
} as CollectionView,
{
id: "col3",
name: "Collection 3",
organizationId: "org1",
readOnly: true,
canEditItems: (_org) => true,
} as CollectionView,
];
@@ -433,5 +479,94 @@ describe("ItemDetailsSectionComponent", () => {
expect(collectionHint).not.toBeNull();
});
it("should allow all collections to be altered when `config.admin` is true", async () => {
component.config.admin = true;
component.config.allowPersonalOwnership = true;
component.config.organizations = [{ id: "org1" } as Organization];
component.config.collections = [
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
readOnly: true,
canEditItems: (_org) => false,
} as CollectionView,
{
id: "col2",
name: "Collection 2",
organizationId: "org1",
readOnly: true,
canEditItems: (_org) => false,
} as CollectionView,
{
id: "col3",
name: "Collection 3",
organizationId: "org1",
readOnly: false,
canEditItems: (_org) => false,
} as CollectionView,
];
fixture.detectChanges();
await fixture.whenStable();
component.itemDetailsForm.controls.organizationId.setValue("org1");
expect(component["collectionOptions"].map((c) => c.id)).toEqual(["col1", "col2", "col3"]);
});
});
describe("readonlyCollections", () => {
beforeEach(() => {
component.config.mode = "edit";
component.config.admin = true;
component.config.collections = [
{
id: "col1",
name: "Collection 1",
organizationId: "org1",
readOnly: true,
canEditItems: (_org) => false,
} as CollectionView,
{
id: "col2",
name: "Collection 2",
organizationId: "org1",
canEditItems: (_org) => false,
} as CollectionView,
{
id: "col3",
name: "Collection 3",
organizationId: "org1",
readOnly: true,
canEditItems: (_org) => false,
} as CollectionView,
];
component.originalCipherView = {
name: "cipher1",
organizationId: "org1",
folderId: "folder1",
collectionIds: ["col1", "col2", "col3"],
favorite: true,
} as CipherView;
component.config.organizations = [{ id: "org1" } as Organization];
});
it("should not show collections as readonly when `config.admin` is true", async () => {
await component.ngOnInit();
fixture.detectChanges();
// Filters out all collections
expect(component["readOnlyCollections"]).toEqual([]);
// Non-admin, keep readonly collections
component.config.admin = false;
await component.ngOnInit();
fixture.detectChanges();
expect(component["readOnlyCollections"]).toEqual(["Collection 1", "Collection 3"]);
});
});
});

View File

@@ -240,7 +240,11 @@ export class ItemDetailsSectionComponent implements OnInit {
} else if (this.config.mode === "edit") {
this.readOnlyCollections = this.collections
.filter(
(c) => c.readOnly && this.originalCipherView.collectionIds.includes(c.id as CollectionId),
// When the configuration is set up for admins, they can alter read only collections
(c) =>
c.readOnly &&
!this.config.admin &&
this.originalCipherView.collectionIds.includes(c.id as CollectionId),
)
.map((c) => c.name);
}
@@ -262,12 +266,24 @@ export class ItemDetailsSectionComponent implements OnInit {
collectionsControl.disable();
this.showCollectionsControl = false;
return;
} else {
collectionsControl.enable();
this.showCollectionsControl = true;
}
const organization = this.organizations.find((o) => o.id === orgId);
this.collectionOptions = this.collections
.filter((c) => {
// If partial edit mode, show all org collections because the control is disabled.
return c.organizationId === orgId && (this.partialEdit || !c.readOnly);
// Filter criteria:
// - The collection belongs to the organization
// - When in partial edit mode, show all org collections because the control is disabled.
// - The user can edit items within the collection
// - When viewing as an admin, all collections should be shown, even readonly. When non-admin, filter out readonly collections
return (
c.organizationId === orgId &&
(this.partialEdit || c.canEditItems(organization) || this.config.admin)
);
})
.map((c) => ({
id: c.id,

View File

@@ -1,6 +1,7 @@
import { inject, Injectable } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
@@ -17,6 +18,7 @@ function isSetEqual(a: Set<string>, b: Set<string>) {
export class DefaultCipherFormService implements CipherFormService {
private cipherService: CipherService = inject(CipherService);
private accountService: AccountService = inject(AccountService);
private apiService: ApiService = inject(ApiService);
async decryptCipher(cipher: Cipher): Promise<CipherView> {
const activeUserId = await firstValueFrom(
@@ -66,11 +68,21 @@ export class DefaultCipherFormService implements CipherFormService {
// Updating a cipher with collection changes is not supported with a single request currently
// First update the cipher with the original collectionIds
encryptedCipher.collectionIds = config.originalCipher.collectionIds;
await this.cipherService.updateWithServer(encryptedCipher, config.admin);
await this.cipherService.updateWithServer(
encryptedCipher,
config.admin || originalCollectionIds.size === 0,
config.mode !== "clone",
);
// Then save the new collection changes separately
encryptedCipher.collectionIds = cipher.collectionIds;
savedCipher = await this.cipherService.saveCollectionsWithServer(encryptedCipher);
if (config.admin || originalCollectionIds.size === 0) {
// When using an admin config or the cipher was unassigned, update collections as an admin
savedCipher = await this.cipherService.saveCollectionsWithServerAdmin(encryptedCipher);
} else {
savedCipher = await this.cipherService.saveCollectionsWithServer(encryptedCipher);
}
}
// Its possible the cipher was made no longer available due to collection assignment changes

View File

@@ -98,6 +98,7 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
async loadCipherData() {
// Load collections if not provided and the cipher has collectionIds
if (
this.cipher.collectionIds &&
this.cipher.collectionIds.length > 0 &&
(!this.collections || this.collections.length === 0)
) {