From 0c3b569d0e23dbdced735d3447653fa9b18b463a Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Wed, 1 Nov 2023 19:30:59 +1000 Subject: [PATCH] [AC-1373] Flexible Collections (#6336) * [AC-1117] Add manage permission (#5910) * Add 'manage' option to collection access permissions * Add 'manage' to collection permissions * remove service accidentally committed from another branch * Update CLI commands * update message casing to be consistent * access selector model updates * [AC-1374] Limit collection create/delete (#5963) * feat: udate request/response/data/domain models for new column, refs AC-1374 * feat: create collection management ui, refs AC-1374 * fix: remove limitCollectionCdOwnerAdmin boolean from org update request, refs AC-1374 * fix: moved collection management UI, removed comments, refs AC-1374 * fix: observable chaining now properly calls API when local org updated, refs AC-1374 * fix: remove unused form template variables, refs AC-1374 * fix: clean up observable chain, refs AC-1374 * fix: remove parent.parent route, refs AC-1374 * fix: add cd explaination, refs AC-1374 * [AC-1649] Remove organizationId from collection-bulk-delete.request (#6343) * refactor: remove organizationId from collection-bulk-delete-request, refs AC-1649 * refactor: remove request model from dialog component, refs AC-1649 * [AC-1174] Bulk collection management (#6133) * [AC-1174] Add bulk edit collection access event type * [AC-1174] Add bulk edit collection access menu option * [AC-1174] Add initial bulk collections access dialog * [AC-1174] Add logic to open bulk edit collections dialog * [AC-1174] Move AccessItemView helper methods to access selector model to be shared * [AC-1174] Add access selector to bulk collections dialog * [AC-1174] Add bulk assign access method to collection-admin service * [AC-1174] Introduce strongly typed BulkCollectionAccessRequest model * [AC-1174] Update vault item event type name * Update DialogService dependency --------- Co-authored-by: Thomas Rittson * Rename LimitCollectionCdOwnerAdmin -> LimitCollectionCreationDeletion (#6409) * Add manage property to synced Collection data * Revert "Add manage property to synced Collection data" Pushed to feature branch instead of a new one This reverts commit 65cd39589cbeca154e0d469be46c006ba4fab0d7. * Add manage property to synced Collection data * Revert "Add manage property to synced Collection data" This reverts commit f7fa30b79a83a0193398e31bf353b41985b41e23. * [AC-1680] Add manage property to collection view and response models (#6417) * Add manage property to synced Collection data * Update tests * feat: add LimitCollectionCreationDeletion conditional to canCreateNewCollections logic, refs AC-1659 (#6429) * [AC-1669] Enforce Can Manage permission on Collection dialog (#6493) * [AC-1669] Cleanup unhandled promise warnings * [AC-1669] Force change detection to ensure AccessSelector has the most recent items * [AC-1669] Initially select acting member when creating a new collection * [AC-1669] Add validator to ensure manage permission is selected * [AC-1669] Update error toast logic to support access tab errors * [AC-1669] Add error icon * [AC-1713] [Flexible collections] Add feature flags to clients (#6486) * Add FlexibleCollections and BulkCollectionAccess flags * Flag Collection Management settings * Flag bulk collection access dialog * Flag collection access modal changes * [AC-1662] Add LimitCollecitonCreationDeletion conditional to CanDelete logic (#6526) * feat: implement limitCollectionCreationDeletion into canDelete logic, refs AC-1662 * feat: make canDelete functions backwards compatible with feature flag, refs AC-1662 * feat: update vault-items.component for async getter, refs AC-1662 * feat: update configService injection, refs AC-1662 * feat: add config service to canDelete reference, refs AC-1662 * fix: remove configservice dependency from views, refs AC-1757 (#6686) * Add missing provider to vault-items.stories (#6690) * Fix imports after update from master --------- Co-authored-by: Robyn MacCallum Co-authored-by: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Co-authored-by: Vincent Salucci Co-authored-by: Shane Melton --- .../models/selection-read-only.ts | 6 +- apps/cli/src/commands/edit.command.ts | 4 +- apps/cli/src/commands/get.command.ts | 4 +- apps/cli/src/vault/create.command.ts | 4 +- .../core/services/group/group.service.ts | 2 +- .../core/services/user-admin.service.ts | 1 + .../views/collection-access-selection.view.ts | 3 + .../manage/group-add-edit.component.html | 2 + .../manage/group-add-edit.component.ts | 10 +- .../member-dialog.component.html | 2 + .../member-dialog/member-dialog.component.ts | 10 +- .../settings/account.component.html | 23 ++- .../settings/account.component.ts | 72 +++++--- .../access-selector.component.ts | 15 +- .../access-selector/access-selector.models.ts | 39 +++- .../collection-dialog.component.html | 8 + .../collection-dialog.component.ts | 172 +++++++++++------- .../vault-items/vault-item-event.ts | 1 + .../vault-items/vault-items.component.html | 9 + .../vault-items/vault-items.component.ts | 26 ++- .../vault-items/vault-items.stories.ts | 11 ++ .../core/bulk-collection-access.request.ts | 7 + .../vault/core/collection-admin.service.ts | 33 +++- .../vault/core/views/collection-admin.view.ts | 8 +- .../bulk-delete-dialog.component.ts | 12 +- .../vault-header/vault-header.component.ts | 17 +- .../individual-vault/vault.component.html | 2 + .../vault/individual-vault/vault.component.ts | 5 + .../bulk-collections-dialog.component.html | 43 +++++ .../bulk-collections-dialog.component.ts | 137 ++++++++++++++ .../bulk-collections-dialog/index.ts | 1 + .../vault-header/vault-header.component.ts | 15 +- .../app/vault/org-vault/vault.component.html | 2 + .../app/vault/org-vault/vault.component.ts | 38 +++- apps/web/src/locales/en/messages.json | 30 +++ libs/common/src/abstractions/api.service.ts | 3 +- .../organization-api.service.abstraction.ts | 5 + .../models/data/organization.data.ts | 2 + .../models/domain/organization.ts | 9 +- ...on-collection-management-update.request.ts | 3 + .../request/selection-read-only.request.ts | 4 +- .../models/response/organization.response.ts | 4 + .../response/profile-organization.response.ts | 4 + .../response/selection-read-only.response.ts | 2 + .../organization/organization-api.service.ts | 17 ++ libs/common/src/enums/feature-flag.enum.ts | 2 + .../request/collection-bulk-delete.request.ts | 4 +- libs/common/src/services/api.service.ts | 6 +- .../src/vault/models/data/collection.data.ts | 2 + .../vault/models/domain/collection.spec.ts | 5 + .../src/vault/models/domain/collection.ts | 4 +- .../models/response/collection.response.ts | 2 + .../src/vault/models/view/collection.view.ts | 11 +- 53 files changed, 725 insertions(+), 138 deletions(-) create mode 100644 apps/web/src/app/vault/core/bulk-collection-access.request.ts create mode 100644 apps/web/src/app/vault/org-vault/bulk-collections-dialog/bulk-collections-dialog.component.html create mode 100644 apps/web/src/app/vault/org-vault/bulk-collections-dialog/bulk-collections-dialog.component.ts create mode 100644 apps/web/src/app/vault/org-vault/bulk-collections-dialog/index.ts create mode 100644 libs/common/src/admin-console/models/request/organization-collection-management-update.request.ts diff --git a/apps/cli/src/admin-console/models/selection-read-only.ts b/apps/cli/src/admin-console/models/selection-read-only.ts index 48c4399120..8b003c05b5 100644 --- a/apps/cli/src/admin-console/models/selection-read-only.ts +++ b/apps/cli/src/admin-console/models/selection-read-only.ts @@ -1,15 +1,17 @@ export class SelectionReadOnly { static template(): SelectionReadOnly { - return new SelectionReadOnly("00000000-0000-0000-0000-000000000000", false, false); + return new SelectionReadOnly("00000000-0000-0000-0000-000000000000", false, false, false); } id: string; readOnly: boolean; hidePasswords: boolean; + manage: boolean; - constructor(id: string, readOnly: boolean, hidePasswords: boolean) { + constructor(id: string, readOnly: boolean, hidePasswords: boolean, manage: boolean) { this.id = id; this.readOnly = readOnly; this.hidePasswords = hidePasswords || false; + this.manage = manage; } } diff --git a/apps/cli/src/commands/edit.command.ts b/apps/cli/src/commands/edit.command.ts index 60e5ee7936..bee1c6849f 100644 --- a/apps/cli/src/commands/edit.command.ts +++ b/apps/cli/src/commands/edit.command.ts @@ -169,7 +169,9 @@ export class EditCommand { const groups = req.groups == null ? null - : req.groups.map((g) => new SelectionReadOnlyRequest(g.id, g.readOnly, g.hidePasswords)); + : req.groups.map( + (g) => new SelectionReadOnlyRequest(g.id, g.readOnly, g.hidePasswords, g.manage) + ); const request = new CollectionRequest(); request.name = (await this.cryptoService.encrypt(req.name, orgKey)).encryptedString; request.externalId = req.externalId; diff --git a/apps/cli/src/commands/get.command.ts b/apps/cli/src/commands/get.command.ts index 4a84f1efef..5e1552066a 100644 --- a/apps/cli/src/commands/get.command.ts +++ b/apps/cli/src/commands/get.command.ts @@ -427,7 +427,9 @@ export class GetCommand extends DownloadCommand { const groups = response.groups == null ? null - : response.groups.map((g) => new SelectionReadOnly(g.id, g.readOnly, g.hidePasswords)); + : response.groups.map( + (g) => new SelectionReadOnly(g.id, g.readOnly, g.hidePasswords, g.manage) + ); const res = new OrganizationCollectionResponse(decCollection, groups); return Response.success(res); } catch (e) { diff --git a/apps/cli/src/vault/create.command.ts b/apps/cli/src/vault/create.command.ts index 01217dbc30..fcef368d53 100644 --- a/apps/cli/src/vault/create.command.ts +++ b/apps/cli/src/vault/create.command.ts @@ -184,7 +184,9 @@ export class CreateCommand { const groups = req.groups == null ? null - : req.groups.map((g) => new SelectionReadOnlyRequest(g.id, g.readOnly, g.hidePasswords)); + : req.groups.map( + (g) => new SelectionReadOnlyRequest(g.id, g.readOnly, g.hidePasswords, g.manage) + ); const request = new CollectionRequest(); request.name = (await this.cryptoService.encrypt(req.name, orgKey)).encryptedString; request.externalId = req.externalId; diff --git a/apps/web/src/app/admin-console/organizations/core/services/group/group.service.ts b/apps/web/src/app/admin-console/organizations/core/services/group/group.service.ts index 680c358b1f..65687bdf75 100644 --- a/apps/web/src/app/admin-console/organizations/core/services/group/group.service.ts +++ b/apps/web/src/app/admin-console/organizations/core/services/group/group.service.ts @@ -76,7 +76,7 @@ export class InternalGroupService extends GroupService { request.accessAll = group.accessAll; request.users = group.members; request.collections = group.collections.map( - (c) => new SelectionReadOnlyRequest(c.id, c.readOnly, c.hidePasswords) + (c) => new SelectionReadOnlyRequest(c.id, c.readOnly, c.hidePasswords, c.manage) ); if (group.id == undefined) { diff --git a/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts b/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts index 4b1cb6467d..9e1889f7fa 100644 --- a/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts +++ b/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts @@ -80,6 +80,7 @@ export class UserAdminService { id: c.id, hidePasswords: c.hidePasswords, readOnly: c.readOnly, + manage: c.manage, })); view.groups = u.groups; view.accessSecretsManager = u.accessSecretsManager; diff --git a/apps/web/src/app/admin-console/organizations/core/views/collection-access-selection.view.ts b/apps/web/src/app/admin-console/organizations/core/views/collection-access-selection.view.ts index 38191605fd..e7dd3df882 100644 --- a/apps/web/src/app/admin-console/organizations/core/views/collection-access-selection.view.ts +++ b/apps/web/src/app/admin-console/organizations/core/views/collection-access-selection.view.ts @@ -4,12 +4,14 @@ interface SelectionResponseLike { id: string; readOnly: boolean; hidePasswords: boolean; + manage: boolean; } export class CollectionAccessSelectionView extends View { readonly id: string; readonly readOnly: boolean; readonly hidePasswords: boolean; + readonly manage: boolean; constructor(response?: SelectionResponseLike) { super(); @@ -21,5 +23,6 @@ export class CollectionAccessSelectionView extends View { this.id = response.id; this.readOnly = response.readOnly; this.hidePasswords = response.hidePasswords; + this.manage = response.manage; } } diff --git a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.html b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.html index b6175d8029..66aac8dc4d 100644 --- a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.html +++ b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.html @@ -40,6 +40,7 @@ [columnHeader]="'member' | i18n" [selectorLabelText]="'selectMembers' | i18n" [emptySelectionText]="'noMembersAdded' | i18n" + [flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async" > @@ -60,6 +61,7 @@ [columnHeader]="'collection' | i18n" [selectorLabelText]="'selectCollections' | i18n" [emptySelectionText]="'noCollectionsAdded' | i18n" + [flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async" > diff --git a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts index 86fc5dcee3..b81ec06711 100644 --- a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts @@ -5,7 +5,9 @@ import { catchError, combineLatest, from, map, of, Subject, switchMap, takeUntil import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -78,6 +80,11 @@ export const openGroupAddEditDialog = ( templateUrl: "group-add-edit.component.html", }) export class GroupAddEditComponent implements OnInit, OnDestroy { + protected flexibleCollectionsEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.FlexibleCollections, + false + ); + protected PermissionMode = PermissionMode; protected ResultType = GroupAddEditDialogResultType; @@ -181,7 +188,8 @@ export class GroupAddEditComponent implements OnInit, OnDestroy { private logService: LogService, private formBuilder: FormBuilder, private changeDetectorRef: ChangeDetectorRef, - private dialogService: DialogService + private dialogService: DialogService, + private configService: ConfigServiceAbstraction ) { this.tabIndex = params.initialTab ?? GroupAddEditTabType.Info; } diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html index 8c50683788..34d407e7b2 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html @@ -289,6 +289,7 @@ [columnHeader]="'groups' | i18n" [selectorLabelText]="'selectGroups' | i18n" [emptySelectionText]="'noGroupsAdded' | i18n" + [flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async" > @@ -321,6 +322,7 @@ [columnHeader]="'collection' | i18n" [selectorLabelText]="'selectCollections' | i18n" [emptySelectionText]="'noCollectionsAdded' | i18n" + [flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async" > diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts index aa9a211eed..f885a05453 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts @@ -11,6 +11,8 @@ import { } from "@bitwarden/common/admin-console/enums"; import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; @@ -64,6 +66,11 @@ export enum MemberDialogResult { templateUrl: "member-dialog.component.html", }) export class MemberDialogComponent implements OnInit, OnDestroy { + protected flexibleCollectionsEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.FlexibleCollections, + false + ); + loading = true; editMode = false; isRevoked = false; @@ -134,7 +141,8 @@ export class MemberDialogComponent implements OnInit, OnDestroy { private groupService: GroupService, private userService: UserAdminService, private organizationUserService: OrganizationUserService, - private dialogService: DialogService + private dialogService: DialogService, + private configService: ConfigServiceAbstraction ) {} async ngOnInit() { diff --git a/apps/web/src/app/admin-console/organizations/settings/account.component.html b/apps/web/src/app/admin-console/organizations/settings/account.component.html index c7ac9910ac..bbce9b2e51 100644 --- a/apps/web/src/app/admin-console/organizations/settings/account.component.html +++ b/apps/web/src/app/admin-console/organizations/settings/account.component.html @@ -7,7 +7,7 @@ > {{ "loading" | i18n }} -
+
@@ -52,6 +52,27 @@ {{ "rotateApiKey" | i18n }} + +

{{ "collectionManagement" | i18n }}

+

{{ "collectionManagementDesc" | i18n }}

+ + {{ "limitCollectionCreationDeletionDesc" | i18n }} + + + +

{{ "dangerZone" | i18n }}

{{ "dangerZoneDesc" | i18n }}

diff --git a/apps/web/src/app/admin-console/organizations/settings/account.component.ts b/apps/web/src/app/admin-console/organizations/settings/account.component.ts index 8900e626a3..693e718231 100644 --- a/apps/web/src/app/admin-console/organizations/settings/account.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/account.component.ts @@ -1,17 +1,19 @@ import { Component, ViewChild, ViewContainerRef } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { combineLatest, lastValueFrom, Subject, switchMap, takeUntil, from } from "rxjs"; +import { combineLatest, lastValueFrom, Subject, switchMap, takeUntil, from, of } from "rxjs"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationCollectionManagementUpdateRequest } from "@bitwarden/common/admin-console/models/request/organization-collection-management-update.request"; import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request"; import { OrganizationUpdateRequest } from "@bitwarden/common/admin-console/models/request/organization-update.request"; import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { DialogService } from "@bitwarden/components"; @@ -38,8 +40,11 @@ export class AccountComponent { loading = true; canUseApi = false; org: OrganizationResponse; - formPromise: Promise; taxFormPromise: Promise; + showCollectionManagementSettings$ = this.configService.getFeatureFlag$( + FeatureFlag.FlexibleCollections, + false + ); // FormGroup validators taken from server Organization domain object protected formGroup = this.formBuilder.group({ @@ -60,6 +65,10 @@ export class AccountComponent { ), }); + protected collectionManagementFormGroup = this.formBuilder.group({ + limitCollectionCreationDeletion: [false], + }); + protected organizationId: string; protected publicKeyBuffer: Uint8Array; @@ -71,27 +80,27 @@ export class AccountComponent { private route: ActivatedRoute, private platformUtilsService: PlatformUtilsService, private cryptoService: CryptoService, - private logService: LogService, private router: Router, private organizationService: OrganizationService, private organizationApiService: OrganizationApiServiceAbstraction, private dialogService: DialogService, - private formBuilder: FormBuilder + private formBuilder: FormBuilder, + private configService: ConfigServiceAbstraction ) {} async ngOnInit() { this.selfHosted = this.platformUtilsService.isSelfHost(); - this.route.parent.parent.params + this.route.params .pipe( - switchMap((params) => { + switchMap((params) => this.organizationService.get$(params.organizationId)), + switchMap((organization) => { return combineLatest([ - // Organization domain - this.organizationService.get$(params.organizationId), + of(organization), // OrganizationResponse for form population - from(this.organizationApiService.get(params.organizationId)), + from(this.organizationApiService.get(organization.id)), // Organization Public Key - from(this.organizationApiService.getKeys(params.organizationId)), + from(this.organizationApiService.getKeys(organization.id)), ]); }), takeUntil(this.destroy$) @@ -102,6 +111,16 @@ export class AccountComponent { this.canEditSubscription = organization.canEditSubscription; this.canUseApi = organization.useApi; + // Update disabled states - reactive forms prefers not using disabled attribute + if (!this.selfHosted) { + this.formGroup.get("orgName").enable(); + } + + if (!this.selfHosted || this.canEditSubscription) { + this.formGroup.get("billingEmail").enable(); + this.formGroup.get("businessName").enable(); + } + // Org Response this.org = orgResponse; @@ -114,16 +133,9 @@ export class AccountComponent { billingEmail: this.org.billingEmail, businessName: this.org.businessName, }); - - // Update disabled states - reactive forms prefers not using disabled attribute - if (!this.selfHosted) { - this.formGroup.get("orgName").enable(); - } - - if (!this.selfHosted || this.canEditSubscription) { - this.formGroup.get("billingEmail").enable(); - this.formGroup.get("businessName").enable(); - } + this.collectionManagementFormGroup.patchValue({ + limitCollectionCreationDeletion: this.org.limitCollectionCreationDeletion, + }); this.loading = false; }); @@ -153,11 +165,25 @@ export class AccountComponent { request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString); } - this.formPromise = this.organizationApiService.save(this.organizationId, request); - await this.formPromise; + await this.organizationApiService.save(this.organizationId, request); + this.platformUtilsService.showToast("success", null, this.i18nService.t("organizationUpdated")); }; + submitCollectionManagement = async () => { + const request = new OrganizationCollectionManagementUpdateRequest(); + request.limitCreateDeleteOwnerAdmin = + this.collectionManagementFormGroup.value.limitCollectionCreationDeletion; + + await this.organizationApiService.updateCollectionManagement(this.organizationId, request); + + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("collectionManagementUpdated") + ); + }; + async deleteOrganization() { const dialog = openDeleteOrganizationDialog(this.dialogService, { data: { diff --git a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.ts index d2d1768a67..2b954063b2 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.ts @@ -122,6 +122,10 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On { perm: CollectionPermission.Edit, labelId: "canEdit" }, { perm: CollectionPermission.EditExceptPass, labelId: "canEditExceptPass" }, ]; + private canManagePermissionListItem = { + perm: CollectionPermission.Manage, + labelId: "canManage", + }; protected initialPermission = CollectionPermission.View; disabled: boolean; @@ -192,6 +196,11 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On */ @Input() showGroupColumn: boolean; + /** + * Enable Flexible Collections changes (feature flag) + */ + @Input() flexibleCollectionsEnabled: boolean; + constructor( private readonly formBuilder: FormBuilder, private readonly i18nService: I18nService @@ -254,7 +263,7 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On this.pauseChangeNotification = false; } - ngOnInit() { + async ngOnInit() { // Watch the internal formArray for changes and propagate them this.selectionList.formArray.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((v) => { if (!this.notifyOnChange || this.pauseChangeNotification) { @@ -268,6 +277,10 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On } this.notifyOnChange(v); }); + + if (this.flexibleCollectionsEnabled) { + this.permissionList.push(this.canManagePermissionListItem); + } } ngOnDestroy() { diff --git a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts index f4ba620ed0..4e434a05c3 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts @@ -1,19 +1,21 @@ +import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-user/responses"; import { OrganizationUserStatusType, OrganizationUserType, } from "@bitwarden/common/admin-console/enums"; import { SelectItemView } from "@bitwarden/components"; -import { CollectionAccessSelectionView } from "../../../core"; +import { CollectionAccessSelectionView, GroupView } from "../../../core"; /** - * Permission options that replace/correspond with readOnly and hidePassword server fields. + * Permission options that replace/correspond with manage, readOnly, and hidePassword server fields. */ export enum CollectionPermission { View = "view", ViewExceptPass = "viewExceptPass", Edit = "edit", EditExceptPass = "editExceptPass", + Manage = "manage", } export enum AccessItemType { @@ -82,7 +84,9 @@ export type AccessItemValue = { * @param value */ export const convertToPermission = (value: CollectionAccessSelectionView) => { - if (value.readOnly) { + if (value.manage) { + return CollectionPermission.Manage; + } else if (value.readOnly) { return value.hidePasswords ? CollectionPermission.ViewExceptPass : CollectionPermission.View; } else { return value.hidePasswords ? CollectionPermission.EditExceptPass : CollectionPermission.Edit; @@ -91,7 +95,7 @@ export const convertToPermission = (value: CollectionAccessSelectionView) => { /** * Converts an AccessItemValue back into a CollectionAccessView class using the CollectionPermission - * to determine the values for `readOnly` and `hidePassword` + * to determine the values for `manage`, `readOnly`, and `hidePassword` * @param value */ export const convertToSelectionView = (value: AccessItemValue) => { @@ -99,6 +103,7 @@ export const convertToSelectionView = (value: AccessItemValue) => { id: value.id, readOnly: readOnly(value.permission), hidePasswords: hidePassword(value.permission), + manage: value.permission === CollectionPermission.Manage, }); }; @@ -107,3 +112,29 @@ const readOnly = (perm: CollectionPermission) => const hidePassword = (perm: CollectionPermission) => [CollectionPermission.ViewExceptPass, CollectionPermission.EditExceptPass].includes(perm); + +export function mapGroupToAccessItemView(group: GroupView): AccessItemView { + return { + id: group.id, + type: AccessItemType.Group, + listName: group.name, + labelName: group.name, + accessAllItems: group.accessAll, + readonly: group.accessAll, + }; +} + +// TODO: Use view when user apis are migrated to a service +export function mapUserToAccessItemView(user: OrganizationUserUserDetailsResponse): AccessItemView { + return { + id: user.id, + type: AccessItemType.Member, + email: user.email, + role: user.type, + listName: user.name?.length > 0 ? `${user.name} (${user.email})` : user.email, + labelName: user.name ?? user.email, + status: user.status, + accessAllItems: user.accessAll, + readonly: user.accessAll, + }; +} diff --git a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.html b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.html index e39dcb12ef..41e785a6eb 100644 --- a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.html +++ b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.html @@ -64,6 +64,12 @@ +
+ {{ "managePermissionRequired" | i18n }} +
diff --git a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts index 7a0240ab62..898831c220 100644 --- a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts @@ -1,8 +1,10 @@ import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; -import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; -import { FormBuilder, Validators } from "@angular/forms"; +import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from "@angular/core"; +import { AbstractControl, FormBuilder, Validators } from "@angular/forms"; import { combineLatest, + firstValueFrom, + from, map, Observable, of, @@ -14,23 +16,27 @@ import { import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; -import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-user/responses"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CollectionResponse } from "@bitwarden/common/vault/models/response/collection.response"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; -import { DialogService, BitValidators } from "@bitwarden/components"; +import { BitValidators, DialogService } from "@bitwarden/components"; import { GroupService, GroupView } from "../../../admin-console/organizations/core"; import { PermissionMode } from "../../../admin-console/organizations/shared/components/access-selector/access-selector.component"; import { - AccessItemView, - AccessItemValue, AccessItemType, - convertToSelectionView, + AccessItemValue, + AccessItemView, + CollectionPermission, convertToPermission, + convertToSelectionView, + mapGroupToAccessItemView, + mapUserToAccessItemView, } from "../../../admin-console/organizations/shared/components/access-selector/access-selector.models"; import { CollectionAdminService } from "../../core/collection-admin.service"; import { CollectionAdminView } from "../../core/views/collection-admin.view"; @@ -64,6 +70,11 @@ export enum CollectionDialogAction { templateUrl: "collection-dialog.component.html", }) export class CollectionDialogComponent implements OnInit, OnDestroy { + protected flexibleCollectionsEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.FlexibleCollections, + false + ); + private destroy$ = new Subject(); protected organizations$: Observable; @@ -94,7 +105,9 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, private organizationUserService: OrganizationUserService, - private dialogService: DialogService + private dialogService: DialogService, + private changeDetectorRef: ChangeDetectorRef, + private configService: ConfigServiceAbstraction ) { this.tabIndex = params.initialTab ?? CollectionDialogTabType.Info; } @@ -118,7 +131,11 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { } else { // Opened from the org vault this.formGroup.patchValue({ selectedOrg: this.params.organizationId }); - this.loadOrg(this.params.organizationId, this.params.collectionIds); + await this.loadOrg(this.params.organizationId, this.params.collectionIds); + } + + if (await firstValueFrom(this.flexibleCollectionsEnabled$)) { + this.formGroup.controls.access.addValidators(validateCanManagePermission); } } @@ -139,51 +156,76 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { organization: organization$, collections: this.collectionService.getAll(orgId), collectionDetails: this.params.collectionId - ? this.collectionService.get(orgId, this.params.collectionId) + ? from(this.collectionService.get(orgId, this.params.collectionId)) : of(null), groups: groups$, users: this.organizationUserService.getAllUsers(orgId), + flexibleCollections: this.flexibleCollectionsEnabled$, }) .pipe(takeUntil(this.formGroup.controls.selectedOrg.valueChanges), takeUntil(this.destroy$)) - .subscribe(({ organization, collections, collectionDetails, groups, users }) => { - this.organization = organization; - this.accessItems = [].concat( - groups.map(mapGroupToAccessItemView), - users.data.map(mapUserToAccessItemView) - ); + .subscribe( + ({ organization, collections, collectionDetails, groups, users, flexibleCollections }) => { + this.organization = organization; + this.accessItems = [].concat( + groups.map(mapGroupToAccessItemView), + users.data.map(mapUserToAccessItemView) + ); - if (collectionIds) { - collections = collections.filter((c) => collectionIds.includes(c.id)); - } + // Force change detection to update the access selector's items + this.changeDetectorRef.detectChanges(); - if (this.params.collectionId) { - this.collection = collections.find((c) => c.id === this.collectionId); - this.nestOptions = collections.filter((c) => c.id !== this.collectionId); - - if (!this.collection) { - throw new Error("Could not find collection to edit."); + if (collectionIds) { + collections = collections.filter((c) => collectionIds.includes(c.id)); } - const { name, parent } = parseName(this.collection); - if (parent !== undefined && !this.nestOptions.find((c) => c.name === parent)) { - this.deletedParentName = parent; + if (this.params.collectionId) { + this.collection = collections.find((c) => c.id === this.collectionId); + this.nestOptions = collections.filter((c) => c.id !== this.collectionId); + + if (!this.collection) { + throw new Error("Could not find collection to edit."); + } + + const { name, parent } = parseName(this.collection); + if (parent !== undefined && !this.nestOptions.find((c) => c.name === parent)) { + this.deletedParentName = parent; + } + + const accessSelections = mapToAccessSelections(collectionDetails); + this.formGroup.patchValue({ + name, + externalId: this.collection.externalId, + parent, + access: accessSelections, + }); + } else { + this.nestOptions = collections; + const parent = collections.find((c) => c.id === this.params.parentCollectionId); + const currentOrgUserId = users.data.find( + (u) => u.userId === this.organization?.userId + )?.id; + const initialSelection: AccessItemValue[] = + currentOrgUserId !== undefined + ? [ + { + id: currentOrgUserId, + type: AccessItemType.Member, + permission: flexibleCollections + ? CollectionPermission.Manage + : CollectionPermission.Edit, + }, + ] + : []; + + this.formGroup.patchValue({ + parent: parent?.name ?? undefined, + access: initialSelection, + }); } - const accessSelections = mapToAccessSelections(collectionDetails); - this.formGroup.patchValue({ - name, - externalId: this.collection.externalId, - parent, - access: accessSelections, - }); - } else { - this.nestOptions = collections; - const parent = collections.find((c) => c.id === this.params.parentCollectionId); - this.formGroup.patchValue({ parent: parent?.name ?? undefined }); + this.loading = false; } - - this.loading = false; - }); + ); } protected get collectionId() { @@ -202,12 +244,20 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { this.formGroup.markAllAsTouched(); if (this.formGroup.invalid) { - if (this.tabIndex === CollectionDialogTabType.Access) { + const accessTabError = this.formGroup.controls.access.hasError("managePermissionRequired"); + + if (this.tabIndex === CollectionDialogTabType.Access && !accessTabError) { this.platformUtilsService.showToast( "error", null, this.i18nService.t("fieldOnTabRequiresAttention", this.i18nService.t("collectionInfo")) ); + } else if (this.tabIndex === CollectionDialogTabType.Info && accessTabError) { + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("fieldOnTabRequiresAttention", this.i18nService.t("access")) + ); } return; } @@ -284,32 +334,6 @@ function parseName(collection: CollectionView) { return { name, parent }; } -function mapGroupToAccessItemView(group: GroupView): AccessItemView { - return { - id: group.id, - type: AccessItemType.Group, - listName: group.name, - labelName: group.name, - accessAllItems: group.accessAll, - readonly: group.accessAll, - }; -} - -// TODO: Use view when user apis are migrated to a service -function mapUserToAccessItemView(user: OrganizationUserUserDetailsResponse): AccessItemView { - return { - id: user.id, - type: AccessItemType.Member, - email: user.email, - role: user.type, - listName: user.name?.length > 0 ? `${user.name} (${user.email})` : user.email, - labelName: user.name ?? user.email, - status: user.status, - accessAllItems: user.accessAll, - readonly: user.accessAll, - }; -} - function mapToAccessSelections(collectionDetails: CollectionAdminView): AccessItemValue[] { if (collectionDetails == undefined) { return []; @@ -328,6 +352,16 @@ function mapToAccessSelections(collectionDetails: CollectionAdminView): AccessIt ); } +/** + * Validator to ensure that at least one access item has Manage permission + */ +function validateCanManagePermission(control: AbstractControl) { + const access = control.value as AccessItemValue[]; + const hasManagePermission = access.some((a) => a.permission === CollectionPermission.Manage); + + return hasManagePermission ? null : { managePermissionRequired: true }; +} + /** * Strongly typed helper to open a CollectionDialog * @param dialogService Instance of the dialog service that will be used to open the dialog diff --git a/apps/web/src/app/vault/components/vault-items/vault-item-event.ts b/apps/web/src/app/vault/components/vault-items/vault-item-event.ts index a72734d93d..b12076e359 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-item-event.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-item-event.ts @@ -6,6 +6,7 @@ import { VaultItem } from "./vault-item"; export type VaultItemEvent = | { type: "viewAttachments"; item: CipherView } | { type: "viewCollections"; item: CipherView } + | { type: "bulkEditCollectionAccess"; items: CollectionView[] } | { type: "viewCollectionAccess"; item: CollectionView } | { type: "viewEvents"; item: CipherView } | { type: "editCollection"; item: CollectionView } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html index 4010ccced3..7430bbdbc8 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html @@ -34,6 +34,15 @@ {{ "moveSelected" | i18n }} + + + + + diff --git a/apps/web/src/app/vault/org-vault/bulk-collections-dialog/bulk-collections-dialog.component.ts b/apps/web/src/app/vault/org-vault/bulk-collections-dialog/bulk-collections-dialog.component.ts new file mode 100644 index 0000000000..c34bf1bb20 --- /dev/null +++ b/apps/web/src/app/vault/org-vault/bulk-collections-dialog/bulk-collections-dialog.component.ts @@ -0,0 +1,137 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Component, Inject, OnDestroy } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { combineLatest, of, Subject, switchMap, takeUntil } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { DialogService } from "@bitwarden/components"; + +import { GroupService, GroupView } from "../../../admin-console/organizations/core"; +import { + AccessItemType, + AccessItemValue, + AccessItemView, + AccessSelectorModule, + convertToSelectionView, + mapGroupToAccessItemView, + mapUserToAccessItemView, + PermissionMode, +} from "../../../admin-console/organizations/shared/components/access-selector"; +import { SharedModule } from "../../../shared"; +import { CollectionAdminService } from "../../core/collection-admin.service"; + +export interface BulkCollectionsDialogParams { + organizationId: string; + collections: CollectionView[]; +} + +export enum BulkCollectionsDialogResult { + Saved = "saved", + Canceled = "canceled", +} + +@Component({ + imports: [SharedModule, AccessSelectorModule], + selector: "app-bulk-collections-dialog", + templateUrl: "bulk-collections-dialog.component.html", + standalone: true, +}) +export class BulkCollectionsDialogComponent implements OnDestroy { + protected flexibleCollectionsEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.FlexibleCollections, + false + ); + + protected readonly PermissionMode = PermissionMode; + + protected formGroup = this.formBuilder.group({ + access: [[] as AccessItemValue[]], + }); + protected loading = true; + protected organization: Organization; + protected accessItems: AccessItemView[] = []; + protected numCollections: number; + + private destroy$ = new Subject(); + + constructor( + @Inject(DIALOG_DATA) private params: BulkCollectionsDialogParams, + private dialogRef: DialogRef, + private formBuilder: FormBuilder, + private organizationService: OrganizationService, + private groupService: GroupService, + private organizationUserService: OrganizationUserService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + private collectionAdminService: CollectionAdminService, + private configService: ConfigServiceAbstraction + ) { + this.numCollections = this.params.collections.length; + const organization$ = this.organizationService.get$(this.params.organizationId); + const groups$ = organization$.pipe( + switchMap((organization) => { + if (!organization.useGroups) { + return of([] as GroupView[]); + } + return this.groupService.getAll(organization.id); + }) + ); + + combineLatest([ + organization$, + groups$, + this.organizationUserService.getAllUsers(this.params.organizationId), + ]) + .pipe(takeUntil(this.destroy$)) + .subscribe(([organization, groups, users]) => { + this.organization = organization; + + this.accessItems = [].concat( + groups.map(mapGroupToAccessItemView), + users.data.map(mapUserToAccessItemView) + ); + + this.loading = false; + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + submit = async () => { + const users = this.formGroup.controls.access.value + .filter((v) => v.type === AccessItemType.Member) + .map(convertToSelectionView); + + const groups = this.formGroup.controls.access.value + .filter((v) => v.type === AccessItemType.Group) + .map(convertToSelectionView); + + await this.collectionAdminService.bulkAssignAccess( + this.organization.id, + this.params.collections.map((c) => c.id), + users, + groups + ); + + this.platformUtilsService.showToast("success", null, this.i18nService.t("editedCollections")); + + this.dialogRef.close(BulkCollectionsDialogResult.Saved); + }; + + static open(dialogService: DialogService, config: DialogConfig) { + return dialogService.open( + BulkCollectionsDialogComponent, + config + ); + } +} diff --git a/apps/web/src/app/vault/org-vault/bulk-collections-dialog/index.ts b/apps/web/src/app/vault/org-vault/bulk-collections-dialog/index.ts new file mode 100644 index 0000000000..7f6add5329 --- /dev/null +++ b/apps/web/src/app/vault/org-vault/bulk-collections-dialog/index.ts @@ -0,0 +1 @@ +export * from "./bulk-collections-dialog.component"; diff --git a/apps/web/src/app/vault/org-vault/vault-header/vault-header.component.ts b/apps/web/src/app/vault/org-vault/vault-header/vault-header.component.ts index 2d3f2c156b..3272b3588e 100644 --- a/apps/web/src/app/vault/org-vault/vault-header/vault-header.component.ts +++ b/apps/web/src/app/vault/org-vault/vault-header/vault-header.component.ts @@ -5,7 +5,9 @@ import { firstValueFrom } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { ProductType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { TreeNode } from "@bitwarden/common/models/domain/tree-node"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogService, SimpleDialogOptions } from "@bitwarden/components"; @@ -56,14 +58,23 @@ export class VaultHeaderComponent { protected CollectionDialogTabType = CollectionDialogTabType; protected organizations$ = this.organizationService.organizations$; + private flexibleCollectionsEnabled: boolean; + constructor( private organizationService: OrganizationService, private i18nService: I18nService, private dialogService: DialogService, private collectionAdminService: CollectionAdminService, - private router: Router + private router: Router, + private configService: ConfigServiceAbstraction ) {} + async ngOnInit() { + this.flexibleCollectionsEnabled = await this.configService.getFeatureFlag( + FeatureFlag.FlexibleCollections + ); + } + get title() { if (this.collection !== undefined) { return this.collection.node.name; @@ -171,7 +182,7 @@ export class VaultHeaderComponent { } // Otherwise, check if we can delete the specified collection - return this.collection.node.canDelete(this.organization); + return this.collection.node.canDelete(this.organization, this.flexibleCollectionsEnabled); } deleteCollection() { diff --git a/apps/web/src/app/vault/org-vault/vault.component.html b/apps/web/src/app/vault/org-vault/vault.component.html index 360e4c57b5..16b37b1273 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.html +++ b/apps/web/src/app/vault/org-vault/vault.component.html @@ -52,7 +52,9 @@ [showBulkTrashOptions]="filter.type === 'trash'" [useEvents]="organization?.useEvents" [cloneableOrganizationCiphers]="true" + [showAdminActions]="true" (onEvent)="onVaultItemsEvent($event)" + [showBulkEditCollectionAccess]="showBulkEditCollectionAccess$ | async" >
; + protected showBulkEditCollectionAccess$ = this.configService.getFeatureFlag$( + FeatureFlag.BulkCollectionAccess, + false + ); private searchText$ = new Subject(); private refresh$ = new BehaviorSubject(null); @@ -152,7 +162,8 @@ export class VaultComponent implements OnInit, OnDestroy { private logService: LogService, private eventCollectionService: EventCollectionService, private totpService: TotpService, - private apiService: ApiService + private apiService: ApiService, + protected configService: ConfigServiceAbstraction ) {} async ngOnInit() { @@ -499,6 +510,8 @@ export class VaultComponent implements OnInit, OnDestroy { await this.editCollection(event.item, CollectionDialogTabType.Info); } else if (event.type === "viewCollectionAccess") { await this.editCollection(event.item, CollectionDialogTabType.Access); + } else if (event.type === "bulkEditCollectionAccess") { + await this.bulkEditCollectionAccess(event.items); } else if (event.type === "viewEvents") { await this.viewEvents(event.item); } @@ -890,6 +903,29 @@ export class VaultComponent implements OnInit, OnDestroy { } } + async bulkEditCollectionAccess(collections: CollectionView[]): Promise { + if (collections.length === 0) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("nothingSelected") + ); + return; + } + + const dialog = BulkCollectionsDialogComponent.open(this.dialogService, { + data: { + collections, + organizationId: this.organization?.id, + }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === BulkCollectionsDialogResult.Saved) { + this.refresh(); + } + } + async viewEvents(cipher: CipherView) { await openEntityEventsDialog(this.dialogService, { data: { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index a342802c1c..9caf99e796 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1545,6 +1545,9 @@ "manage": { "message": "Manage" }, + "canManage": { + "message": "Can manage" + }, "disable": { "message": "Turn off" }, @@ -7218,6 +7221,18 @@ } } }, + "collectionManagement": { + "message": "Collection management" + }, + "collectionManagementDesc": { + "message": "Manage the collection behavior for the organization" + }, + "limitCollectionCreationDeletionDesc": { + "message": "Limit collection creation and deletion to owners and admins" + }, + "collectionManagementUpdated": { + "message": "Collection management behavior saved" + }, "passwordManagerPlanPrice": { "message": "Password Manager plan price" }, @@ -7264,6 +7279,12 @@ "beta": { "message": "Beta" }, + "assignCollectionAccess": { + "message": "Assign collection access" + }, + "editedCollections": { + "message": "Edited collections" + }, "baseUrl": { "message": "Server URL" }, @@ -7273,6 +7294,15 @@ "alreadyHaveAccount": { "message": "Already have an account?" }, + "customBillingStart": { + "message": "Custom billing is not reflected. Visit the " + }, + "customBillingEnd": { + "message": " page for latest invoicing." + }, + "managePermissionRequired": { + "message": "At least one member or group must have can manage permission." + }, "typePasskey": { "message": "Passkey" }, diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 5847ed8618..24151425ee 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -99,7 +99,6 @@ import { PlanResponse } from "../billing/models/response/plan.response"; import { SubscriptionResponse } from "../billing/models/response/subscription.response"; import { TaxInfoResponse } from "../billing/models/response/tax-info.response"; import { TaxRateResponse } from "../billing/models/response/tax-rate.response"; -import { CollectionBulkDeleteRequest } from "../models/request/collection-bulk-delete.request"; import { DeleteRecoverRequest } from "../models/request/delete-recover.request"; import { EventRequest } from "../models/request/event.request"; import { IapCheckRequest } from "../models/request/iap-check.request"; @@ -301,7 +300,7 @@ export abstract class ApiService { request: CollectionRequest ) => Promise; deleteCollection: (organizationId: string, id: string) => Promise; - deleteManyCollections: (request: CollectionBulkDeleteRequest) => Promise; + deleteManyCollections: (organizationId: string, collectionIds: string[]) => Promise; deleteCollectionUser: ( organizationId: string, id: string, diff --git a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts index a1792b1fe7..ae62cad5fd 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts @@ -18,6 +18,7 @@ import { StorageRequest } from "../../../models/request/storage.request"; import { VerifyBankRequest } from "../../../models/request/verify-bank.request"; import { ListResponse } from "../../../models/response/list.response"; import { OrganizationApiKeyType } from "../../enums"; +import { OrganizationCollectionManagementUpdateRequest } from "../../models/request/organization-collection-management-update.request"; import { OrganizationCreateRequest } from "../../models/request/organization-create.request"; import { OrganizationKeysRequest } from "../../models/request/organization-keys.request"; import { OrganizationUpdateRequest } from "../../models/request/organization-update.request"; @@ -73,4 +74,8 @@ export class OrganizationApiServiceAbstraction { id: string, request: SecretsManagerSubscribeRequest ) => Promise; + updateCollectionManagement: ( + id: string, + request: OrganizationCollectionManagementUpdateRequest + ) => Promise; } diff --git a/libs/common/src/admin-console/models/data/organization.data.ts b/libs/common/src/admin-console/models/data/organization.data.ts index 10f5e9f262..2eba957660 100644 --- a/libs/common/src/admin-console/models/data/organization.data.ts +++ b/libs/common/src/admin-console/models/data/organization.data.ts @@ -49,6 +49,7 @@ export class OrganizationData { familySponsorshipValidUntil?: Date; familySponsorshipToDelete?: boolean; accessSecretsManager: boolean; + limitCollectionCreationDeletion: boolean; constructor( response: ProfileOrganizationResponse, @@ -100,6 +101,7 @@ export class OrganizationData { this.familySponsorshipValidUntil = response.familySponsorshipValidUntil; this.familySponsorshipToDelete = response.familySponsorshipToDelete; this.accessSecretsManager = response.accessSecretsManager; + this.limitCollectionCreationDeletion = response.limitCollectionCreationDeletion; this.isMember = options.isMember; this.isProviderUser = options.isProviderUser; diff --git a/libs/common/src/admin-console/models/domain/organization.ts b/libs/common/src/admin-console/models/domain/organization.ts index bd3c903636..068ce6d45a 100644 --- a/libs/common/src/admin-console/models/domain/organization.ts +++ b/libs/common/src/admin-console/models/domain/organization.ts @@ -64,6 +64,10 @@ export class Organization { familySponsorshipValidUntil?: Date; familySponsorshipToDelete?: boolean; accessSecretsManager: boolean; + /** + * Refers to the ability for an organization to limit collection creation and deletion to owners and admins only + */ + limitCollectionCreationDeletion: boolean; constructor(obj?: OrganizationData) { if (obj == null) { @@ -115,6 +119,7 @@ export class Organization { this.familySponsorshipValidUntil = obj.familySponsorshipValidUntil; this.familySponsorshipToDelete = obj.familySponsorshipToDelete; this.accessSecretsManager = obj.accessSecretsManager; + this.limitCollectionCreationDeletion = obj.limitCollectionCreationDeletion; } get canAccess() { @@ -158,7 +163,9 @@ export class Organization { } get canCreateNewCollections() { - return this.isManager || this.permissions.createNewCollections; + return ( + !this.limitCollectionCreationDeletion || this.isAdmin || this.permissions.createNewCollections + ); } get canEditAnyCollection() { diff --git a/libs/common/src/admin-console/models/request/organization-collection-management-update.request.ts b/libs/common/src/admin-console/models/request/organization-collection-management-update.request.ts new file mode 100644 index 0000000000..1c6ed27f19 --- /dev/null +++ b/libs/common/src/admin-console/models/request/organization-collection-management-update.request.ts @@ -0,0 +1,3 @@ +export class OrganizationCollectionManagementUpdateRequest { + limitCreateDeleteOwnerAdmin: boolean; +} diff --git a/libs/common/src/admin-console/models/request/selection-read-only.request.ts b/libs/common/src/admin-console/models/request/selection-read-only.request.ts index 7b007324c4..be1bfb875a 100644 --- a/libs/common/src/admin-console/models/request/selection-read-only.request.ts +++ b/libs/common/src/admin-console/models/request/selection-read-only.request.ts @@ -2,10 +2,12 @@ export class SelectionReadOnlyRequest { id: string; readOnly: boolean; hidePasswords: boolean; + manage: boolean; - constructor(id: string, readOnly: boolean, hidePasswords: boolean) { + constructor(id: string, readOnly: boolean, hidePasswords: boolean, manage: boolean) { this.id = id; this.readOnly = readOnly; this.hidePasswords = hidePasswords; + this.manage = manage; } } diff --git a/libs/common/src/admin-console/models/response/organization.response.ts b/libs/common/src/admin-console/models/response/organization.response.ts index 3d49796352..74b29a7948 100644 --- a/libs/common/src/admin-console/models/response/organization.response.ts +++ b/libs/common/src/admin-console/models/response/organization.response.ts @@ -32,6 +32,7 @@ export class OrganizationResponse extends BaseResponse { smServiceAccounts?: number; maxAutoscaleSmSeats?: number; maxAutoscaleSmServiceAccounts?: number; + limitCollectionCreationDeletion: boolean; constructor(response: any) { super(response); @@ -67,5 +68,8 @@ export class OrganizationResponse extends BaseResponse { this.smServiceAccounts = this.getResponseProperty("SmServiceAccounts"); this.maxAutoscaleSmSeats = this.getResponseProperty("MaxAutoscaleSmSeats"); this.maxAutoscaleSmServiceAccounts = this.getResponseProperty("MaxAutoscaleSmServiceAccounts"); + this.limitCollectionCreationDeletion = this.getResponseProperty( + "LimitCollectionCreationDeletion" + ); } } diff --git a/libs/common/src/admin-console/models/response/profile-organization.response.ts b/libs/common/src/admin-console/models/response/profile-organization.response.ts index e042bf145f..a401662cd5 100644 --- a/libs/common/src/admin-console/models/response/profile-organization.response.ts +++ b/libs/common/src/admin-console/models/response/profile-organization.response.ts @@ -48,6 +48,7 @@ export class ProfileOrganizationResponse extends BaseResponse { familySponsorshipValidUntil?: Date; familySponsorshipToDelete?: boolean; accessSecretsManager: boolean; + limitCollectionCreationDeletion: boolean; constructor(response: any) { super(response); @@ -105,5 +106,8 @@ export class ProfileOrganizationResponse extends BaseResponse { } this.familySponsorshipToDelete = this.getResponseProperty("FamilySponsorshipToDelete"); this.accessSecretsManager = this.getResponseProperty("AccessSecretsManager"); + this.limitCollectionCreationDeletion = this.getResponseProperty( + "LimitCollectionCreationDeletion" + ); } } diff --git a/libs/common/src/admin-console/models/response/selection-read-only.response.ts b/libs/common/src/admin-console/models/response/selection-read-only.response.ts index 29c54422c9..b96c1a2bbe 100644 --- a/libs/common/src/admin-console/models/response/selection-read-only.response.ts +++ b/libs/common/src/admin-console/models/response/selection-read-only.response.ts @@ -4,11 +4,13 @@ export class SelectionReadOnlyResponse extends BaseResponse { id: string; readOnly: boolean; hidePasswords: boolean; + manage: boolean; constructor(response: any) { super(response); this.id = this.getResponseProperty("Id"); this.readOnly = this.getResponseProperty("ReadOnly"); this.hidePasswords = this.getResponseProperty("HidePasswords"); + this.manage = this.getResponseProperty("Manage"); } } diff --git a/libs/common/src/admin-console/services/organization/organization-api.service.ts b/libs/common/src/admin-console/services/organization/organization-api.service.ts index 76b5fb0eca..b22cf70efe 100644 --- a/libs/common/src/admin-console/services/organization/organization-api.service.ts +++ b/libs/common/src/admin-console/services/organization/organization-api.service.ts @@ -21,6 +21,7 @@ import { ListResponse } from "../../../models/response/list.response"; import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction"; import { OrganizationApiServiceAbstraction } from "../../abstractions/organization/organization-api.service.abstraction"; import { OrganizationApiKeyType } from "../../enums"; +import { OrganizationCollectionManagementUpdateRequest } from "../../models/request/organization-collection-management-update.request"; import { OrganizationCreateRequest } from "../../models/request/organization-create.request"; import { OrganizationKeysRequest } from "../../models/request/organization-keys.request"; import { OrganizationUpdateRequest } from "../../models/request/organization-update.request"; @@ -322,4 +323,20 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction ); return new ProfileOrganizationResponse(r); } + + async updateCollectionManagement( + id: string, + request: OrganizationCollectionManagementUpdateRequest + ): Promise { + const r = await this.apiService.send( + "PUT", + "/organizations/" + id + "/collection-management", + request, + true, + true + ); + const data = new OrganizationResponse(r); + await this.syncService.fullSync(true); + return data; + } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 1c2a7e93d1..1482317b02 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -7,6 +7,8 @@ export enum FeatureFlag { AutofillV2 = "autofill-v2", BrowserFilelessImport = "browser-fileless-import", ItemShare = "item-share", + FlexibleCollections = "flexible-collections", + BulkCollectionAccess = "bulk-collection-access", } // Replace this with a type safe lookup of the feature flag values in PM-2282 diff --git a/libs/common/src/models/request/collection-bulk-delete.request.ts b/libs/common/src/models/request/collection-bulk-delete.request.ts index 142af44a43..3c4b5015f8 100644 --- a/libs/common/src/models/request/collection-bulk-delete.request.ts +++ b/libs/common/src/models/request/collection-bulk-delete.request.ts @@ -1,9 +1,7 @@ export class CollectionBulkDeleteRequest { ids: string[]; - organizationId: string; - constructor(ids: string[], organizationId?: string) { + constructor(ids: string[]) { this.ids = ids == null ? [] : ids; - this.organizationId = organizationId; } } diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 5bf216e776..58be8afce9 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -834,11 +834,11 @@ export class ApiService implements ApiServiceAbstraction { ); } - deleteManyCollections(request: CollectionBulkDeleteRequest): Promise { + deleteManyCollections(organizationId: string, collectionIds: string[]): Promise { return this.send( "DELETE", - "/organizations/" + request.organizationId + "/collections", - request, + "/organizations/" + organizationId + "/collections", + new CollectionBulkDeleteRequest(collectionIds), true, false ); diff --git a/libs/common/src/vault/models/data/collection.data.ts b/libs/common/src/vault/models/data/collection.data.ts index ee27846eed..512bb4f826 100644 --- a/libs/common/src/vault/models/data/collection.data.ts +++ b/libs/common/src/vault/models/data/collection.data.ts @@ -6,6 +6,7 @@ export class CollectionData { name: string; externalId: string; readOnly: boolean; + manage: boolean; hidePasswords: boolean; constructor(response: CollectionDetailsResponse) { @@ -14,6 +15,7 @@ export class CollectionData { this.name = response.name; this.externalId = response.externalId; this.readOnly = response.readOnly; + this.manage = response.manage; this.hidePasswords = response.hidePasswords; } } diff --git a/libs/common/src/vault/models/domain/collection.spec.ts b/libs/common/src/vault/models/domain/collection.spec.ts index c685f0272e..99fa39384b 100644 --- a/libs/common/src/vault/models/domain/collection.spec.ts +++ b/libs/common/src/vault/models/domain/collection.spec.ts @@ -13,6 +13,7 @@ describe("Collection", () => { name: "encName", externalId: "extId", readOnly: true, + manage: true, hidePasswords: true, }; }); @@ -28,6 +29,7 @@ describe("Collection", () => { name: null, organizationId: null, readOnly: null, + manage: null, }); }); @@ -40,6 +42,7 @@ describe("Collection", () => { name: { encryptedString: "encName", encryptionType: 0 }, externalId: "extId", readOnly: true, + manage: true, hidePasswords: true, }); }); @@ -52,6 +55,7 @@ describe("Collection", () => { collection.externalId = "extId"; collection.readOnly = false; collection.hidePasswords = false; + collection.manage = true; const view = await collection.decrypt(); @@ -62,6 +66,7 @@ describe("Collection", () => { name: "encName", organizationId: "orgId", readOnly: false, + manage: true, }); }); }); diff --git a/libs/common/src/vault/models/domain/collection.ts b/libs/common/src/vault/models/domain/collection.ts index 8bcec318df..27d4d19ada 100644 --- a/libs/common/src/vault/models/domain/collection.ts +++ b/libs/common/src/vault/models/domain/collection.ts @@ -10,6 +10,7 @@ export class Collection extends Domain { externalId: string; readOnly: boolean; hidePasswords: boolean; + manage: boolean; constructor(obj?: CollectionData) { super(); @@ -27,8 +28,9 @@ export class Collection extends Domain { externalId: null, readOnly: null, hidePasswords: null, + manage: null, }, - ["id", "organizationId", "externalId", "readOnly", "hidePasswords"] + ["id", "organizationId", "externalId", "readOnly", "hidePasswords", "manage"] ); } diff --git a/libs/common/src/vault/models/response/collection.response.ts b/libs/common/src/vault/models/response/collection.response.ts index 4cce6b072e..6577f3790a 100644 --- a/libs/common/src/vault/models/response/collection.response.ts +++ b/libs/common/src/vault/models/response/collection.response.ts @@ -18,11 +18,13 @@ export class CollectionResponse extends BaseResponse { export class CollectionDetailsResponse extends CollectionResponse { readOnly: boolean; + manage: boolean; hidePasswords: boolean; constructor(response: any) { super(response); this.readOnly = this.getResponseProperty("ReadOnly") || false; + this.manage = this.getResponseProperty("Manage") || false; this.hidePasswords = this.getResponseProperty("HidePasswords") || false; } } diff --git a/libs/common/src/vault/models/view/collection.view.ts b/libs/common/src/vault/models/view/collection.view.ts index 98159c99cc..48cacb7b55 100644 --- a/libs/common/src/vault/models/view/collection.view.ts +++ b/libs/common/src/vault/models/view/collection.view.ts @@ -14,6 +14,7 @@ export class CollectionView implements View, ITreeNodeObject { // readOnly applies to the items within a collection readOnly: boolean = null; hidePasswords: boolean = null; + manage: boolean = null; constructor(c?: Collection | CollectionAccessDetailsResponse) { if (!c) { @@ -26,6 +27,7 @@ export class CollectionView implements View, ITreeNodeObject { if (c instanceof Collection) { this.readOnly = c.readOnly; this.hidePasswords = c.hidePasswords; + this.manage = c.manage; } } @@ -40,12 +42,17 @@ export class CollectionView implements View, ITreeNodeObject { } // For deleting a collection, not the items within it. - canDelete(org: Organization): boolean { + canDelete(org: Organization, flexibleCollectionsEnabled: boolean): boolean { if (org.id !== this.organizationId) { throw new Error( "Id of the organization provided does not match the org id of the collection." ); } - return org?.canDeleteAnyCollection || org?.canDeleteAssignedCollections; + + if (flexibleCollectionsEnabled) { + return org?.canDeleteAnyCollection || (!org?.limitCollectionCreationDeletion && this.manage); + } else { + return org?.canDeleteAnyCollection || org?.canDeleteAssignedCollections; + } } }