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:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user