1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-21 11:35:34 +01:00

[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 <trittson@bitwarden.com>

* 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 65cd39589c.

* Add manage property to synced Collection data

* Revert "Add manage property to synced Collection data"

This reverts commit f7fa30b79a.

* [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 <robyntmaccallum@gmail.com>
Co-authored-by: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com>
Co-authored-by: Vincent Salucci <vincesalucci21@gmail.com>
Co-authored-by: Shane Melton <smelton@bitwarden.com>
This commit is contained in:
Thomas Rittson 2023-11-01 19:30:59 +10:00 committed by GitHub
parent 2ec3f808d2
commit 0c3b569d0e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 725 additions and 138 deletions

View File

@ -1,15 +1,17 @@
export class SelectionReadOnly { export class SelectionReadOnly {
static template(): 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; id: string;
readOnly: boolean; readOnly: boolean;
hidePasswords: 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.id = id;
this.readOnly = readOnly; this.readOnly = readOnly;
this.hidePasswords = hidePasswords || false; this.hidePasswords = hidePasswords || false;
this.manage = manage;
} }
} }

View File

@ -169,7 +169,9 @@ export class EditCommand {
const groups = const groups =
req.groups == null req.groups == null
? 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(); const request = new CollectionRequest();
request.name = (await this.cryptoService.encrypt(req.name, orgKey)).encryptedString; request.name = (await this.cryptoService.encrypt(req.name, orgKey)).encryptedString;
request.externalId = req.externalId; request.externalId = req.externalId;

View File

@ -427,7 +427,9 @@ export class GetCommand extends DownloadCommand {
const groups = const groups =
response.groups == null response.groups == null
? 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); const res = new OrganizationCollectionResponse(decCollection, groups);
return Response.success(res); return Response.success(res);
} catch (e) { } catch (e) {

View File

@ -184,7 +184,9 @@ export class CreateCommand {
const groups = const groups =
req.groups == null req.groups == null
? 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(); const request = new CollectionRequest();
request.name = (await this.cryptoService.encrypt(req.name, orgKey)).encryptedString; request.name = (await this.cryptoService.encrypt(req.name, orgKey)).encryptedString;
request.externalId = req.externalId; request.externalId = req.externalId;

View File

@ -76,7 +76,7 @@ export class InternalGroupService extends GroupService {
request.accessAll = group.accessAll; request.accessAll = group.accessAll;
request.users = group.members; request.users = group.members;
request.collections = group.collections.map( 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) { if (group.id == undefined) {

View File

@ -80,6 +80,7 @@ export class UserAdminService {
id: c.id, id: c.id,
hidePasswords: c.hidePasswords, hidePasswords: c.hidePasswords,
readOnly: c.readOnly, readOnly: c.readOnly,
manage: c.manage,
})); }));
view.groups = u.groups; view.groups = u.groups;
view.accessSecretsManager = u.accessSecretsManager; view.accessSecretsManager = u.accessSecretsManager;

View File

@ -4,12 +4,14 @@ interface SelectionResponseLike {
id: string; id: string;
readOnly: boolean; readOnly: boolean;
hidePasswords: boolean; hidePasswords: boolean;
manage: boolean;
} }
export class CollectionAccessSelectionView extends View { export class CollectionAccessSelectionView extends View {
readonly id: string; readonly id: string;
readonly readOnly: boolean; readonly readOnly: boolean;
readonly hidePasswords: boolean; readonly hidePasswords: boolean;
readonly manage: boolean;
constructor(response?: SelectionResponseLike) { constructor(response?: SelectionResponseLike) {
super(); super();
@ -21,5 +23,6 @@ export class CollectionAccessSelectionView extends View {
this.id = response.id; this.id = response.id;
this.readOnly = response.readOnly; this.readOnly = response.readOnly;
this.hidePasswords = response.hidePasswords; this.hidePasswords = response.hidePasswords;
this.manage = response.manage;
} }
} }

View File

@ -40,6 +40,7 @@
[columnHeader]="'member' | i18n" [columnHeader]="'member' | i18n"
[selectorLabelText]="'selectMembers' | i18n" [selectorLabelText]="'selectMembers' | i18n"
[emptySelectionText]="'noMembersAdded' | i18n" [emptySelectionText]="'noMembersAdded' | i18n"
[flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async"
></bit-access-selector> ></bit-access-selector>
</bit-tab> </bit-tab>
@ -60,6 +61,7 @@
[columnHeader]="'collection' | i18n" [columnHeader]="'collection' | i18n"
[selectorLabelText]="'selectCollections' | i18n" [selectorLabelText]="'selectCollections' | i18n"
[emptySelectionText]="'noCollectionsAdded' | i18n" [emptySelectionText]="'noCollectionsAdded' | i18n"
[flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async"
></bit-access-selector> ></bit-access-selector>
</ng-container> </ng-container>
</bit-tab> </bit-tab>

View File

@ -5,7 +5,9 @@ import { catchError, combineLatest, from, map, of, Subject, switchMap, takeUntil
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.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 { 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -78,6 +80,11 @@ export const openGroupAddEditDialog = (
templateUrl: "group-add-edit.component.html", templateUrl: "group-add-edit.component.html",
}) })
export class GroupAddEditComponent implements OnInit, OnDestroy { export class GroupAddEditComponent implements OnInit, OnDestroy {
protected flexibleCollectionsEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.FlexibleCollections,
false
);
protected PermissionMode = PermissionMode; protected PermissionMode = PermissionMode;
protected ResultType = GroupAddEditDialogResultType; protected ResultType = GroupAddEditDialogResultType;
@ -181,7 +188,8 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
private logService: LogService, private logService: LogService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dialogService: DialogService private dialogService: DialogService,
private configService: ConfigServiceAbstraction
) { ) {
this.tabIndex = params.initialTab ?? GroupAddEditTabType.Info; this.tabIndex = params.initialTab ?? GroupAddEditTabType.Info;
} }

View File

@ -289,6 +289,7 @@
[columnHeader]="'groups' | i18n" [columnHeader]="'groups' | i18n"
[selectorLabelText]="'selectGroups' | i18n" [selectorLabelText]="'selectGroups' | i18n"
[emptySelectionText]="'noGroupsAdded' | i18n" [emptySelectionText]="'noGroupsAdded' | i18n"
[flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async"
></bit-access-selector> ></bit-access-selector>
</bit-tab> </bit-tab>
<bit-tab [label]="'collections' | i18n"> <bit-tab [label]="'collections' | i18n">
@ -321,6 +322,7 @@
[columnHeader]="'collection' | i18n" [columnHeader]="'collection' | i18n"
[selectorLabelText]="'selectCollections' | i18n" [selectorLabelText]="'selectCollections' | i18n"
[emptySelectionText]="'noCollectionsAdded' | i18n" [emptySelectionText]="'noCollectionsAdded' | i18n"
[flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async"
></bit-access-selector ></bit-access-selector
></bit-tab> ></bit-tab>
</bit-tab-group> </bit-tab-group>

View File

@ -11,6 +11,8 @@ import {
} from "@bitwarden/common/admin-console/enums"; } from "@bitwarden/common/admin-console/enums";
import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api"; import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
@ -64,6 +66,11 @@ export enum MemberDialogResult {
templateUrl: "member-dialog.component.html", templateUrl: "member-dialog.component.html",
}) })
export class MemberDialogComponent implements OnInit, OnDestroy { export class MemberDialogComponent implements OnInit, OnDestroy {
protected flexibleCollectionsEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.FlexibleCollections,
false
);
loading = true; loading = true;
editMode = false; editMode = false;
isRevoked = false; isRevoked = false;
@ -134,7 +141,8 @@ export class MemberDialogComponent implements OnInit, OnDestroy {
private groupService: GroupService, private groupService: GroupService,
private userService: UserAdminService, private userService: UserAdminService,
private organizationUserService: OrganizationUserService, private organizationUserService: OrganizationUserService,
private dialogService: DialogService private dialogService: DialogService,
private configService: ConfigServiceAbstraction
) {} ) {}
async ngOnInit() { async ngOnInit() {

View File

@ -7,7 +7,7 @@
></i> ></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span> <span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div> </div>
<form *ngIf="org && !loading" #form [bitSubmit]="submit" [formGroup]="formGroup"> <form *ngIf="org && !loading" [bitSubmit]="submit" [formGroup]="formGroup">
<div class="tw-grid tw-grid-cols-2 tw-gap-5"> <div class="tw-grid tw-grid-cols-2 tw-gap-5">
<div> <div>
<bit-form-field> <bit-form-field>
@ -52,6 +52,27 @@
{{ "rotateApiKey" | i18n }} {{ "rotateApiKey" | i18n }}
</button> </button>
</ng-container> </ng-container>
<form
*ngIf="org && !loading && (showCollectionManagementSettings$ | async)"
[bitSubmit]="submitCollectionManagement"
[formGroup]="collectionManagementFormGroup"
>
<h1 bitTypography="h1" class="tw-mt-16 tw-pb-2.5">{{ "collectionManagement" | i18n }}</h1>
<p>{{ "collectionManagementDesc" | i18n }}</p>
<bit-form-control>
<bit-label>{{ "limitCollectionCreationDeletionDesc" | i18n }}</bit-label>
<input type="checkbox" bitCheckbox formControlName="limitCollectionCreationDeletion" />
</bit-form-control>
<button
type="submit"
bitButton
bitFormButton
buttonType="primary"
id="collectionManagementSubmitButton"
>
{{ "save" | i18n }}
</button>
</form>
<h1 bitTypography="h1" class="tw-mt-16 tw-pb-2.5 !tw-text-danger">{{ "dangerZone" | i18n }}</h1> <h1 bitTypography="h1" class="tw-mt-16 tw-pb-2.5 !tw-text-danger">{{ "dangerZone" | i18n }}</h1>
<div class="tw-rounded tw-border tw-border-solid tw-border-danger-500 tw-bg-background tw-p-5"> <div class="tw-rounded tw-border tw-border-solid tw-border-danger-500 tw-bg-background tw-p-5">
<p>{{ "dangerZoneDesc" | i18n }}</p> <p>{{ "dangerZoneDesc" | i18n }}</p>

View File

@ -1,17 +1,19 @@
import { Component, ViewChild, ViewContainerRef } from "@angular/core"; import { Component, ViewChild, ViewContainerRef } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms"; import { FormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router"; 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 { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; 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 { 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 { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
import { OrganizationUpdateRequest } from "@bitwarden/common/admin-console/models/request/organization-update.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 { 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 { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
@ -38,8 +40,11 @@ export class AccountComponent {
loading = true; loading = true;
canUseApi = false; canUseApi = false;
org: OrganizationResponse; org: OrganizationResponse;
formPromise: Promise<OrganizationResponse>;
taxFormPromise: Promise<unknown>; taxFormPromise: Promise<unknown>;
showCollectionManagementSettings$ = this.configService.getFeatureFlag$(
FeatureFlag.FlexibleCollections,
false
);
// FormGroup validators taken from server Organization domain object // FormGroup validators taken from server Organization domain object
protected formGroup = this.formBuilder.group({ protected formGroup = this.formBuilder.group({
@ -60,6 +65,10 @@ export class AccountComponent {
), ),
}); });
protected collectionManagementFormGroup = this.formBuilder.group({
limitCollectionCreationDeletion: [false],
});
protected organizationId: string; protected organizationId: string;
protected publicKeyBuffer: Uint8Array; protected publicKeyBuffer: Uint8Array;
@ -71,27 +80,27 @@ export class AccountComponent {
private route: ActivatedRoute, private route: ActivatedRoute,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private cryptoService: CryptoService, private cryptoService: CryptoService,
private logService: LogService,
private router: Router, private router: Router,
private organizationService: OrganizationService, private organizationService: OrganizationService,
private organizationApiService: OrganizationApiServiceAbstraction, private organizationApiService: OrganizationApiServiceAbstraction,
private dialogService: DialogService, private dialogService: DialogService,
private formBuilder: FormBuilder private formBuilder: FormBuilder,
private configService: ConfigServiceAbstraction
) {} ) {}
async ngOnInit() { async ngOnInit() {
this.selfHosted = this.platformUtilsService.isSelfHost(); this.selfHosted = this.platformUtilsService.isSelfHost();
this.route.parent.parent.params this.route.params
.pipe( .pipe(
switchMap((params) => { switchMap((params) => this.organizationService.get$(params.organizationId)),
switchMap((organization) => {
return combineLatest([ return combineLatest([
// Organization domain of(organization),
this.organizationService.get$(params.organizationId),
// OrganizationResponse for form population // OrganizationResponse for form population
from(this.organizationApiService.get(params.organizationId)), from(this.organizationApiService.get(organization.id)),
// Organization Public Key // Organization Public Key
from(this.organizationApiService.getKeys(params.organizationId)), from(this.organizationApiService.getKeys(organization.id)),
]); ]);
}), }),
takeUntil(this.destroy$) takeUntil(this.destroy$)
@ -102,6 +111,16 @@ export class AccountComponent {
this.canEditSubscription = organization.canEditSubscription; this.canEditSubscription = organization.canEditSubscription;
this.canUseApi = organization.useApi; 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 // Org Response
this.org = orgResponse; this.org = orgResponse;
@ -114,16 +133,9 @@ export class AccountComponent {
billingEmail: this.org.billingEmail, billingEmail: this.org.billingEmail,
businessName: this.org.businessName, businessName: this.org.businessName,
}); });
this.collectionManagementFormGroup.patchValue({
// Update disabled states - reactive forms prefers not using disabled attribute limitCollectionCreationDeletion: this.org.limitCollectionCreationDeletion,
if (!this.selfHosted) { });
this.formGroup.get("orgName").enable();
}
if (!this.selfHosted || this.canEditSubscription) {
this.formGroup.get("billingEmail").enable();
this.formGroup.get("businessName").enable();
}
this.loading = false; this.loading = false;
}); });
@ -153,11 +165,25 @@ export class AccountComponent {
request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString); request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
} }
this.formPromise = this.organizationApiService.save(this.organizationId, request); await this.organizationApiService.save(this.organizationId, request);
await this.formPromise;
this.platformUtilsService.showToast("success", null, this.i18nService.t("organizationUpdated")); 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() { async deleteOrganization() {
const dialog = openDeleteOrganizationDialog(this.dialogService, { const dialog = openDeleteOrganizationDialog(this.dialogService, {
data: { data: {

View File

@ -122,6 +122,10 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On
{ perm: CollectionPermission.Edit, labelId: "canEdit" }, { perm: CollectionPermission.Edit, labelId: "canEdit" },
{ perm: CollectionPermission.EditExceptPass, labelId: "canEditExceptPass" }, { perm: CollectionPermission.EditExceptPass, labelId: "canEditExceptPass" },
]; ];
private canManagePermissionListItem = {
perm: CollectionPermission.Manage,
labelId: "canManage",
};
protected initialPermission = CollectionPermission.View; protected initialPermission = CollectionPermission.View;
disabled: boolean; disabled: boolean;
@ -192,6 +196,11 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On
*/ */
@Input() showGroupColumn: boolean; @Input() showGroupColumn: boolean;
/**
* Enable Flexible Collections changes (feature flag)
*/
@Input() flexibleCollectionsEnabled: boolean;
constructor( constructor(
private readonly formBuilder: FormBuilder, private readonly formBuilder: FormBuilder,
private readonly i18nService: I18nService private readonly i18nService: I18nService
@ -254,7 +263,7 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On
this.pauseChangeNotification = false; this.pauseChangeNotification = false;
} }
ngOnInit() { async ngOnInit() {
// Watch the internal formArray for changes and propagate them // Watch the internal formArray for changes and propagate them
this.selectionList.formArray.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((v) => { this.selectionList.formArray.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((v) => {
if (!this.notifyOnChange || this.pauseChangeNotification) { if (!this.notifyOnChange || this.pauseChangeNotification) {
@ -268,6 +277,10 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On
} }
this.notifyOnChange(v); this.notifyOnChange(v);
}); });
if (this.flexibleCollectionsEnabled) {
this.permissionList.push(this.canManagePermissionListItem);
}
} }
ngOnDestroy() { ngOnDestroy() {

View File

@ -1,19 +1,21 @@
import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-user/responses";
import { import {
OrganizationUserStatusType, OrganizationUserStatusType,
OrganizationUserType, OrganizationUserType,
} from "@bitwarden/common/admin-console/enums"; } from "@bitwarden/common/admin-console/enums";
import { SelectItemView } from "@bitwarden/components"; 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 { export enum CollectionPermission {
View = "view", View = "view",
ViewExceptPass = "viewExceptPass", ViewExceptPass = "viewExceptPass",
Edit = "edit", Edit = "edit",
EditExceptPass = "editExceptPass", EditExceptPass = "editExceptPass",
Manage = "manage",
} }
export enum AccessItemType { export enum AccessItemType {
@ -82,7 +84,9 @@ export type AccessItemValue = {
* @param value * @param value
*/ */
export const convertToPermission = (value: CollectionAccessSelectionView) => { 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; return value.hidePasswords ? CollectionPermission.ViewExceptPass : CollectionPermission.View;
} else { } else {
return value.hidePasswords ? CollectionPermission.EditExceptPass : CollectionPermission.Edit; 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 * 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 * @param value
*/ */
export const convertToSelectionView = (value: AccessItemValue) => { export const convertToSelectionView = (value: AccessItemValue) => {
@ -99,6 +103,7 @@ export const convertToSelectionView = (value: AccessItemValue) => {
id: value.id, id: value.id,
readOnly: readOnly(value.permission), readOnly: readOnly(value.permission),
hidePasswords: hidePassword(value.permission), hidePasswords: hidePassword(value.permission),
manage: value.permission === CollectionPermission.Manage,
}); });
}; };
@ -107,3 +112,29 @@ const readOnly = (perm: CollectionPermission) =>
const hidePassword = (perm: CollectionPermission) => const hidePassword = (perm: CollectionPermission) =>
[CollectionPermission.ViewExceptPass, CollectionPermission.EditExceptPass].includes(perm); [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,
};
}

View File

@ -64,6 +64,12 @@
</bit-form-field> </bit-form-field>
</bit-tab> </bit-tab>
<bit-tab label="{{ 'access' | i18n }}"> <bit-tab label="{{ 'access' | i18n }}">
<div
class="tw-mb-3 tw-text-danger"
*ngIf="formGroup.controls.access.hasError('managePermissionRequired')"
>
<i class="bwi bwi-error"></i> {{ "managePermissionRequired" | i18n }}
</div>
<bit-access-selector <bit-access-selector
*ngIf="organization.useGroups" *ngIf="organization.useGroups"
[permissionMode]="PermissionMode.Edit" [permissionMode]="PermissionMode.Edit"
@ -73,6 +79,7 @@
[selectorLabelText]="'selectGroupsAndMembers' | i18n" [selectorLabelText]="'selectGroupsAndMembers' | i18n"
[selectorHelpText]="'userPermissionOverrideHelper' | i18n" [selectorHelpText]="'userPermissionOverrideHelper' | i18n"
[emptySelectionText]="'noMembersOrGroupsAdded' | i18n" [emptySelectionText]="'noMembersOrGroupsAdded' | i18n"
[flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async"
></bit-access-selector> ></bit-access-selector>
<bit-access-selector <bit-access-selector
*ngIf="!organization.useGroups" *ngIf="!organization.useGroups"
@ -82,6 +89,7 @@
[columnHeader]="'memberColumnHeader' | i18n" [columnHeader]="'memberColumnHeader' | i18n"
[selectorLabelText]="'selectMembers' | i18n" [selectorLabelText]="'selectMembers' | i18n"
[emptySelectionText]="'noMembersAdded' | i18n" [emptySelectionText]="'noMembersAdded' | i18n"
[flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async"
></bit-access-selector> ></bit-access-selector>
</bit-tab> </bit-tab>
</bit-tab-group> </bit-tab-group>

View File

@ -1,8 +1,10 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms"; import { AbstractControl, FormBuilder, Validators } from "@angular/forms";
import { import {
combineLatest, combineLatest,
firstValueFrom,
from,
map, map,
Observable, Observable,
of, of,
@ -14,23 +16,27 @@ import {
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { 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 { 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CollectionResponse } from "@bitwarden/common/vault/models/response/collection.response"; import { CollectionResponse } from "@bitwarden/common/vault/models/response/collection.response";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; 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 { GroupService, GroupView } from "../../../admin-console/organizations/core";
import { PermissionMode } from "../../../admin-console/organizations/shared/components/access-selector/access-selector.component"; import { PermissionMode } from "../../../admin-console/organizations/shared/components/access-selector/access-selector.component";
import { import {
AccessItemView,
AccessItemValue,
AccessItemType, AccessItemType,
convertToSelectionView, AccessItemValue,
AccessItemView,
CollectionPermission,
convertToPermission, convertToPermission,
convertToSelectionView,
mapGroupToAccessItemView,
mapUserToAccessItemView,
} from "../../../admin-console/organizations/shared/components/access-selector/access-selector.models"; } from "../../../admin-console/organizations/shared/components/access-selector/access-selector.models";
import { CollectionAdminService } from "../../core/collection-admin.service"; import { CollectionAdminService } from "../../core/collection-admin.service";
import { CollectionAdminView } from "../../core/views/collection-admin.view"; import { CollectionAdminView } from "../../core/views/collection-admin.view";
@ -64,6 +70,11 @@ export enum CollectionDialogAction {
templateUrl: "collection-dialog.component.html", templateUrl: "collection-dialog.component.html",
}) })
export class CollectionDialogComponent implements OnInit, OnDestroy { export class CollectionDialogComponent implements OnInit, OnDestroy {
protected flexibleCollectionsEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.FlexibleCollections,
false
);
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
protected organizations$: Observable<Organization[]>; protected organizations$: Observable<Organization[]>;
@ -94,7 +105,9 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
private i18nService: I18nService, private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private organizationUserService: OrganizationUserService, private organizationUserService: OrganizationUserService,
private dialogService: DialogService private dialogService: DialogService,
private changeDetectorRef: ChangeDetectorRef,
private configService: ConfigServiceAbstraction
) { ) {
this.tabIndex = params.initialTab ?? CollectionDialogTabType.Info; this.tabIndex = params.initialTab ?? CollectionDialogTabType.Info;
} }
@ -118,7 +131,11 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
} else { } else {
// Opened from the org vault // Opened from the org vault
this.formGroup.patchValue({ selectedOrg: this.params.organizationId }); 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$, organization: organization$,
collections: this.collectionService.getAll(orgId), collections: this.collectionService.getAll(orgId),
collectionDetails: this.params.collectionId collectionDetails: this.params.collectionId
? this.collectionService.get(orgId, this.params.collectionId) ? from(this.collectionService.get(orgId, this.params.collectionId))
: of(null), : of(null),
groups: groups$, groups: groups$,
users: this.organizationUserService.getAllUsers(orgId), users: this.organizationUserService.getAllUsers(orgId),
flexibleCollections: this.flexibleCollectionsEnabled$,
}) })
.pipe(takeUntil(this.formGroup.controls.selectedOrg.valueChanges), takeUntil(this.destroy$)) .pipe(takeUntil(this.formGroup.controls.selectedOrg.valueChanges), takeUntil(this.destroy$))
.subscribe(({ organization, collections, collectionDetails, groups, users }) => { .subscribe(
this.organization = organization; ({ organization, collections, collectionDetails, groups, users, flexibleCollections }) => {
this.accessItems = [].concat( this.organization = organization;
groups.map(mapGroupToAccessItemView), this.accessItems = [].concat(
users.data.map(mapUserToAccessItemView) groups.map(mapGroupToAccessItemView),
); users.data.map(mapUserToAccessItemView)
);
if (collectionIds) { // Force change detection to update the access selector's items
collections = collections.filter((c) => collectionIds.includes(c.id)); this.changeDetectorRef.detectChanges();
}
if (this.params.collectionId) { if (collectionIds) {
this.collection = collections.find((c) => c.id === this.collectionId); collections = collections.filter((c) => collectionIds.includes(c.id));
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 (this.params.collectionId) {
if (parent !== undefined && !this.nestOptions.find((c) => c.name === parent)) { this.collection = collections.find((c) => c.id === this.collectionId);
this.deletedParentName = parent; 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.loading = false;
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;
});
} }
protected get collectionId() { protected get collectionId() {
@ -202,12 +244,20 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
this.formGroup.markAllAsTouched(); this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) { 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( this.platformUtilsService.showToast(
"error", "error",
null, null,
this.i18nService.t("fieldOnTabRequiresAttention", this.i18nService.t("collectionInfo")) 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; return;
} }
@ -284,32 +334,6 @@ function parseName(collection: CollectionView) {
return { name, parent }; 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[] { function mapToAccessSelections(collectionDetails: CollectionAdminView): AccessItemValue[] {
if (collectionDetails == undefined) { if (collectionDetails == undefined) {
return []; 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 * Strongly typed helper to open a CollectionDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog * @param dialogService Instance of the dialog service that will be used to open the dialog

View File

@ -6,6 +6,7 @@ import { VaultItem } from "./vault-item";
export type VaultItemEvent = export type VaultItemEvent =
| { type: "viewAttachments"; item: CipherView } | { type: "viewAttachments"; item: CipherView }
| { type: "viewCollections"; item: CipherView } | { type: "viewCollections"; item: CipherView }
| { type: "bulkEditCollectionAccess"; items: CollectionView[] }
| { type: "viewCollectionAccess"; item: CollectionView } | { type: "viewCollectionAccess"; item: CollectionView }
| { type: "viewEvents"; item: CipherView } | { type: "viewEvents"; item: CipherView }
| { type: "editCollection"; item: CollectionView } | { type: "editCollection"; item: CollectionView }

View File

@ -34,6 +34,15 @@
<i class="bwi bwi-fw bwi-folder" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-folder" aria-hidden="true"></i>
{{ "moveSelected" | i18n }} {{ "moveSelected" | i18n }}
</button> </button>
<button
*ngIf="showAdminActions && showBulkEditCollectionAccess"
type="button"
bitMenuItem
(click)="bulkEditCollectionAccess()"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }}
</button>
<button <button
*ngIf="bulkMoveAllowed" *ngIf="bulkMoveAllowed"
type="button" type="button"

View File

@ -2,6 +2,8 @@ import { SelectionModel } from "@angular/cdk/collections";
import { Component, EventEmitter, Input, Output } from "@angular/core"; import { Component, EventEmitter, Input, Output } from "@angular/core";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; 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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { TableDataSource } from "@bitwarden/components"; import { TableDataSource } from "@bitwarden/components";
@ -27,6 +29,8 @@ const MaxSelectionCount = 500;
export class VaultItemsComponent { export class VaultItemsComponent {
protected RowHeight = RowHeight; protected RowHeight = RowHeight;
private flexibleCollectionsEnabled: boolean;
@Input() disabled: boolean; @Input() disabled: boolean;
@Input() showOwner: boolean; @Input() showOwner: boolean;
@Input() showCollections: boolean; @Input() showCollections: boolean;
@ -36,9 +40,12 @@ export class VaultItemsComponent {
@Input() showPremiumFeatures: boolean; @Input() showPremiumFeatures: boolean;
@Input() showBulkMove: boolean; @Input() showBulkMove: boolean;
@Input() showBulkTrashOptions: boolean; @Input() showBulkTrashOptions: boolean;
// Encompasses functionality only available from the organization vault context
@Input() showAdminActions: boolean;
@Input() allOrganizations: Organization[] = []; @Input() allOrganizations: Organization[] = [];
@Input() allCollections: CollectionView[] = []; @Input() allCollections: CollectionView[] = [];
@Input() allGroups: GroupView[] = []; @Input() allGroups: GroupView[] = [];
@Input() showBulkEditCollectionAccess = false;
private _ciphers?: CipherView[] = []; private _ciphers?: CipherView[] = [];
@Input() get ciphers(): CipherView[] { @Input() get ciphers(): CipherView[] {
@ -64,6 +71,14 @@ export class VaultItemsComponent {
protected dataSource = new TableDataSource<VaultItem>(); protected dataSource = new TableDataSource<VaultItem>();
protected selection = new SelectionModel<VaultItem>(true, [], true); protected selection = new SelectionModel<VaultItem>(true, [], true);
constructor(private configService: ConfigServiceAbstraction) {}
async ngOnInit() {
this.flexibleCollectionsEnabled = await this.configService.getFeatureFlag(
FeatureFlag.FlexibleCollections
);
}
get showExtraColumn() { get showExtraColumn() {
return this.showCollections || this.showGroups || this.showOwner; return this.showCollections || this.showGroups || this.showOwner;
} }
@ -101,7 +116,7 @@ export class VaultItemsComponent {
} }
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId); const organization = this.allOrganizations.find((o) => o.id === collection.organizationId);
return collection.canDelete(organization); return collection.canDelete(organization, this.flexibleCollectionsEnabled);
} }
protected toggleAll() { protected toggleAll() {
@ -168,4 +183,13 @@ export class VaultItemsComponent {
); );
this.dataSource.data = items; this.dataSource.data = items;
} }
protected bulkEditCollectionAccess() {
this.event({
type: "bulkEditCollectionAccess",
items: this.selection.selected
.filter((item) => item.collection !== undefined)
.map((item) => item.collection),
});
}
} }

View File

@ -8,6 +8,7 @@ import { SettingsService } from "@bitwarden/common/abstractions/settings.service
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums"; import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
@ -92,6 +93,15 @@ export default {
}, },
} as Partial<TokenService>, } as Partial<TokenService>,
}, },
{
provide: ConfigServiceAbstraction,
useValue: {
getFeatureFlag() {
// does not currently affect any display logic, default all to OFF
return false;
},
},
},
], ],
}), }),
applicationConfig({ applicationConfig({
@ -289,6 +299,7 @@ function createCollectionView(i: number): CollectionAdminView {
id: group.id, id: group.id,
hidePasswords: false, hidePasswords: false,
readOnly: false, readOnly: false,
manage: false,
}), }),
]; ];
} }

View File

@ -0,0 +1,7 @@
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
export class BulkCollectionAccessRequest {
collectionIds: string[];
users: SelectionReadOnlyRequest[];
groups: SelectionReadOnlyRequest[];
}

View File

@ -10,6 +10,9 @@ import {
CollectionResponse, CollectionResponse,
} from "@bitwarden/common/vault/models/response/collection.response"; } from "@bitwarden/common/vault/models/response/collection.response";
import { CollectionAccessSelectionView } from "../../admin-console/organizations/core";
import { BulkCollectionAccessRequest } from "./bulk-collection-access.request";
import { CollectionAdminView } from "./views/collection-admin.view"; import { CollectionAdminView } from "./views/collection-admin.view";
@Injectable() @Injectable()
@ -68,6 +71,30 @@ export class CollectionAdminService {
await this.apiService.deleteCollection(organizationId, collectionId); await this.apiService.deleteCollection(organizationId, collectionId);
} }
async bulkAssignAccess(
organizationId: string,
collectionIds: string[],
users: CollectionAccessSelectionView[],
groups: CollectionAccessSelectionView[]
): Promise<void> {
const request = new BulkCollectionAccessRequest();
request.collectionIds = collectionIds;
request.users = users.map(
(u) => new SelectionReadOnlyRequest(u.id, u.readOnly, u.hidePasswords, u.manage)
);
request.groups = groups.map(
(g) => new SelectionReadOnlyRequest(g.id, g.readOnly, g.hidePasswords, g.manage)
);
await this.apiService.send(
"POST",
`organizations/${organizationId}/collections/bulk-access`,
request,
true,
false
);
}
private async decryptMany( private async decryptMany(
organizationId: string, organizationId: string,
collections: CollectionResponse[] | CollectionAccessDetailsResponse[] collections: CollectionResponse[] | CollectionAccessDetailsResponse[]
@ -105,10 +132,12 @@ export class CollectionAdminService {
collection.externalId = model.externalId; collection.externalId = model.externalId;
collection.name = (await this.cryptoService.encrypt(model.name, key)).encryptedString; collection.name = (await this.cryptoService.encrypt(model.name, key)).encryptedString;
collection.groups = model.groups.map( collection.groups = model.groups.map(
(group) => new SelectionReadOnlyRequest(group.id, group.readOnly, group.hidePasswords) (group) =>
new SelectionReadOnlyRequest(group.id, group.readOnly, group.hidePasswords, group.manage)
); );
collection.users = model.users.map( collection.users = model.users.map(
(user) => new SelectionReadOnlyRequest(user.id, user.readOnly, user.hidePasswords) (user) =>
new SelectionReadOnlyRequest(user.id, user.readOnly, user.hidePasswords, user.manage)
); );
return collection; return collection;
} }

View File

@ -35,7 +35,11 @@ export class CollectionAdminView extends CollectionView {
return org?.canEditAnyCollection || (org?.canEditAssignedCollections && this.assigned); return org?.canEditAnyCollection || (org?.canEditAssignedCollections && this.assigned);
} }
override canDelete(org: Organization): boolean { override canDelete(org: Organization, flexibleCollectionsEnabled: boolean): boolean {
return org?.canDeleteAnyCollection || (org?.canDeleteAssignedCollections && this.assigned); if (flexibleCollectionsEnabled) {
return org?.canDeleteAnyCollection;
} else {
return org?.canDeleteAnyCollection || (org?.canDeleteAssignedCollections && this.assigned);
}
} }
} }

View File

@ -3,7 +3,6 @@ import { Component, Inject } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionBulkDeleteRequest } from "@bitwarden/common/models/request/collection-bulk-delete.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@ -139,11 +138,7 @@ export class BulkDeleteDialogComponent {
); );
return; return;
} }
const deleteRequest = new CollectionBulkDeleteRequest( return await this.apiService.deleteManyCollections(this.organization.id, this.collectionIds);
this.collectionIds,
this.organization.id
);
return await this.apiService.deleteManyCollections(deleteRequest);
// From individual vault, so there can be multiple organizations // From individual vault, so there can be multiple organizations
} else if (this.organizations && this.collections) { } else if (this.organizations && this.collections) {
const deletePromises: Promise<any>[] = []; const deletePromises: Promise<any>[] = [];
@ -159,8 +154,9 @@ export class BulkDeleteDialogComponent {
const orgCollections = this.collections const orgCollections = this.collections
.filter((o) => o.organizationId === organization.id) .filter((o) => o.organizationId === organization.id)
.map((c) => c.id); .map((c) => c.id);
const deleteRequest = new CollectionBulkDeleteRequest(orgCollections, organization.id); deletePromises.push(
deletePromises.push(this.apiService.deleteManyCollections(deleteRequest)); this.apiService.deleteManyCollections(this.organization.id, orgCollections)
);
} }
return await Promise.all(deletePromises); return await Promise.all(deletePromises);
} }

View File

@ -1,7 +1,9 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from "@angular/core"; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from "@angular/core";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { TreeNode } from "@bitwarden/common/models/domain/tree-node"; 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
@ -22,6 +24,8 @@ export class VaultHeaderComponent {
protected All = All; protected All = All;
protected CollectionDialogTabType = CollectionDialogTabType; protected CollectionDialogTabType = CollectionDialogTabType;
private flexibleCollectionsEnabled: boolean;
/** /**
* Boolean to determine the loading state of the header. * Boolean to determine the loading state of the header.
* Shows a loading spinner if set to true * Shows a loading spinner if set to true
@ -55,7 +59,13 @@ export class VaultHeaderComponent {
/** Emits an event when the delete collection button is clicked in the header */ /** Emits an event when the delete collection button is clicked in the header */
@Output() onDeleteCollection = new EventEmitter<void>(); @Output() onDeleteCollection = new EventEmitter<void>();
constructor(private i18nService: I18nService) {} constructor(private i18nService: I18nService, private configService: ConfigServiceAbstraction) {}
async ngOnInit() {
this.flexibleCollectionsEnabled = await this.configService.getFeatureFlag(
FeatureFlag.FlexibleCollections
);
}
/** /**
* The id of the organization that is currently being filtered on. * The id of the organization that is currently being filtered on.
@ -146,11 +156,12 @@ export class VaultHeaderComponent {
return false; return false;
} }
// Otherwise, check if we can edit the specified collection // Otherwise, check if we can delete the specified collection
const organization = this.organizations.find( const organization = this.organizations.find(
(o) => o.id === this.collection?.node.organizationId (o) => o.id === this.collection?.node.organizationId
); );
return this.collection.node.canDelete(organization);
return this.collection.node.canDelete(organization, this.flexibleCollectionsEnabled);
} }
deleteCollection() { deleteCollection() {

View File

@ -45,7 +45,9 @@
[showBulkTrashOptions]="filter.type === 'trash'" [showBulkTrashOptions]="filter.type === 'trash'"
[useEvents]="false" [useEvents]="false"
[cloneableOrganizationCiphers]="false" [cloneableOrganizationCiphers]="false"
[showAdminActions]="false"
(onEvent)="onVaultItemsEvent($event)" (onEvent)="onVaultItemsEvent($event)"
[showBulkEditCollectionAccess]="showBulkCollectionAccess$ | async"
> >
</app-vault-items> </app-vault-items>
<div <div

View File

@ -39,6 +39,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { DEFAULT_PBKDF2_ITERATIONS, EventType, KdfType } from "@bitwarden/common/enums"; import { DEFAULT_PBKDF2_ITERATIONS, EventType, KdfType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ServiceUtils } from "@bitwarden/common/misc/serviceUtils"; import { ServiceUtils } from "@bitwarden/common/misc/serviceUtils";
import { TreeNode } from "@bitwarden/common/models/domain/tree-node"; import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
@ -143,6 +144,10 @@ export class VaultComponent implements OnInit, OnDestroy {
protected selectedCollection: TreeNode<CollectionView> | undefined; protected selectedCollection: TreeNode<CollectionView> | undefined;
protected canCreateCollections = false; protected canCreateCollections = false;
protected currentSearchText$: Observable<string>; protected currentSearchText$: Observable<string>;
protected showBulkCollectionAccess$ = this.configService.getFeatureFlag$(
FeatureFlag.BulkCollectionAccess,
false
);
private searchText$ = new Subject<string>(); private searchText$ = new Subject<string>();
private refresh$ = new BehaviorSubject<void>(null); private refresh$ = new BehaviorSubject<void>(null);

View File

@ -0,0 +1,43 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [loading]="loading" dialogSize="large">
<span bitDialogTitle>
{{ "assignCollectionAccess" | i18n }}
<span class="tw-text-sm tw-normal-case tw-text-muted">
{{ numCollections }} {{ (numCollections == 1 ? "collection" : "collections") | i18n }}
</span>
</span>
<div bitDialogContent>
<bit-access-selector
*ngIf="organization?.useGroups"
[permissionMode]="PermissionMode.Edit"
formControlName="access"
[items]="accessItems"
[columnHeader]="'groupAndMemberColumnHeader' | i18n"
[selectorLabelText]="'selectGroupsAndMembers' | i18n"
[selectorHelpText]="'userPermissionOverrideHelper' | i18n"
[emptySelectionText]="'noMembersOrGroupsAdded' | i18n"
[flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async"
></bit-access-selector>
<bit-access-selector
*ngIf="!organization?.useGroups"
[permissionMode]="PermissionMode.Edit"
formControlName="access"
[items]="accessItems"
[columnHeader]="'memberColumnHeader' | i18n"
[selectorLabelText]="'selectMembers' | i18n"
[emptySelectionText]="'noMembersAdded' | i18n"
[flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async"
></bit-access-selector>
</div>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "save" | i18n }}
</button>
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@ -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<void>();
constructor(
@Inject(DIALOG_DATA) private params: BulkCollectionsDialogParams,
private dialogRef: DialogRef<BulkCollectionsDialogResult>,
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<BulkCollectionsDialogParams>) {
return dialogService.open<BulkCollectionsDialogResult, BulkCollectionsDialogParams>(
BulkCollectionsDialogComponent,
config
);
}
}

View File

@ -0,0 +1 @@
export * from "./bulk-collections-dialog.component";

View File

@ -5,7 +5,9 @@ import { firstValueFrom } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductType } from "@bitwarden/common/enums"; 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 { 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, SimpleDialogOptions } from "@bitwarden/components"; import { DialogService, SimpleDialogOptions } from "@bitwarden/components";
@ -56,14 +58,23 @@ export class VaultHeaderComponent {
protected CollectionDialogTabType = CollectionDialogTabType; protected CollectionDialogTabType = CollectionDialogTabType;
protected organizations$ = this.organizationService.organizations$; protected organizations$ = this.organizationService.organizations$;
private flexibleCollectionsEnabled: boolean;
constructor( constructor(
private organizationService: OrganizationService, private organizationService: OrganizationService,
private i18nService: I18nService, private i18nService: I18nService,
private dialogService: DialogService, private dialogService: DialogService,
private collectionAdminService: CollectionAdminService, private collectionAdminService: CollectionAdminService,
private router: Router private router: Router,
private configService: ConfigServiceAbstraction
) {} ) {}
async ngOnInit() {
this.flexibleCollectionsEnabled = await this.configService.getFeatureFlag(
FeatureFlag.FlexibleCollections
);
}
get title() { get title() {
if (this.collection !== undefined) { if (this.collection !== undefined) {
return this.collection.node.name; return this.collection.node.name;
@ -171,7 +182,7 @@ export class VaultHeaderComponent {
} }
// Otherwise, check if we can delete the specified collection // 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() { deleteCollection() {

View File

@ -52,7 +52,9 @@
[showBulkTrashOptions]="filter.type === 'trash'" [showBulkTrashOptions]="filter.type === 'trash'"
[useEvents]="organization?.useEvents" [useEvents]="organization?.useEvents"
[cloneableOrganizationCiphers]="true" [cloneableOrganizationCiphers]="true"
[showAdminActions]="true"
(onEvent)="onVaultItemsEvent($event)" (onEvent)="onVaultItemsEvent($event)"
[showBulkEditCollectionAccess]="showBulkEditCollectionAccess$ | async"
> >
</app-vault-items> </app-vault-items>
<div <div

View File

@ -38,9 +38,11 @@ import { TotpService } from "@bitwarden/common/abstractions/totp.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { EventType } from "@bitwarden/common/enums"; import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ServiceUtils } from "@bitwarden/common/misc/serviceUtils"; import { ServiceUtils } from "@bitwarden/common/misc/serviceUtils";
import { TreeNode } from "@bitwarden/common/models/domain/tree-node"; import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@ -82,6 +84,10 @@ import { getNestedCollectionTree } from "../utils/collection-utils";
import { AddEditComponent } from "./add-edit.component"; import { AddEditComponent } from "./add-edit.component";
import { AttachmentsComponent } from "./attachments.component"; import { AttachmentsComponent } from "./attachments.component";
import {
BulkCollectionsDialogComponent,
BulkCollectionsDialogResult,
} from "./bulk-collections-dialog";
import { CollectionsComponent } from "./collections.component"; import { CollectionsComponent } from "./collections.component";
import { VaultFilterComponent } from "./vault-filter/vault-filter.component"; import { VaultFilterComponent } from "./vault-filter/vault-filter.component";
@ -122,6 +128,10 @@ export class VaultComponent implements OnInit, OnDestroy {
protected isEmpty: boolean; protected isEmpty: boolean;
protected showMissingCollectionPermissionMessage: boolean; protected showMissingCollectionPermissionMessage: boolean;
protected currentSearchText$: Observable<string>; protected currentSearchText$: Observable<string>;
protected showBulkEditCollectionAccess$ = this.configService.getFeatureFlag$(
FeatureFlag.BulkCollectionAccess,
false
);
private searchText$ = new Subject<string>(); private searchText$ = new Subject<string>();
private refresh$ = new BehaviorSubject<void>(null); private refresh$ = new BehaviorSubject<void>(null);
@ -152,7 +162,8 @@ export class VaultComponent implements OnInit, OnDestroy {
private logService: LogService, private logService: LogService,
private eventCollectionService: EventCollectionService, private eventCollectionService: EventCollectionService,
private totpService: TotpService, private totpService: TotpService,
private apiService: ApiService private apiService: ApiService,
protected configService: ConfigServiceAbstraction
) {} ) {}
async ngOnInit() { async ngOnInit() {
@ -499,6 +510,8 @@ export class VaultComponent implements OnInit, OnDestroy {
await this.editCollection(event.item, CollectionDialogTabType.Info); await this.editCollection(event.item, CollectionDialogTabType.Info);
} else if (event.type === "viewCollectionAccess") { } else if (event.type === "viewCollectionAccess") {
await this.editCollection(event.item, CollectionDialogTabType.Access); await this.editCollection(event.item, CollectionDialogTabType.Access);
} else if (event.type === "bulkEditCollectionAccess") {
await this.bulkEditCollectionAccess(event.items);
} else if (event.type === "viewEvents") { } else if (event.type === "viewEvents") {
await this.viewEvents(event.item); await this.viewEvents(event.item);
} }
@ -890,6 +903,29 @@ export class VaultComponent implements OnInit, OnDestroy {
} }
} }
async bulkEditCollectionAccess(collections: CollectionView[]): Promise<void> {
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) { async viewEvents(cipher: CipherView) {
await openEntityEventsDialog(this.dialogService, { await openEntityEventsDialog(this.dialogService, {
data: { data: {

View File

@ -1545,6 +1545,9 @@
"manage": { "manage": {
"message": "Manage" "message": "Manage"
}, },
"canManage": {
"message": "Can manage"
},
"disable": { "disable": {
"message": "Turn off" "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": { "passwordManagerPlanPrice": {
"message": "Password Manager plan price" "message": "Password Manager plan price"
}, },
@ -7264,6 +7279,12 @@
"beta": { "beta": {
"message": "Beta" "message": "Beta"
}, },
"assignCollectionAccess": {
"message": "Assign collection access"
},
"editedCollections": {
"message": "Edited collections"
},
"baseUrl": { "baseUrl": {
"message": "Server URL" "message": "Server URL"
}, },
@ -7273,6 +7294,15 @@
"alreadyHaveAccount": { "alreadyHaveAccount": {
"message": "Already have an account?" "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": { "typePasskey": {
"message": "Passkey" "message": "Passkey"
}, },

View File

@ -99,7 +99,6 @@ import { PlanResponse } from "../billing/models/response/plan.response";
import { SubscriptionResponse } from "../billing/models/response/subscription.response"; import { SubscriptionResponse } from "../billing/models/response/subscription.response";
import { TaxInfoResponse } from "../billing/models/response/tax-info.response"; import { TaxInfoResponse } from "../billing/models/response/tax-info.response";
import { TaxRateResponse } from "../billing/models/response/tax-rate.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 { DeleteRecoverRequest } from "../models/request/delete-recover.request";
import { EventRequest } from "../models/request/event.request"; import { EventRequest } from "../models/request/event.request";
import { IapCheckRequest } from "../models/request/iap-check.request"; import { IapCheckRequest } from "../models/request/iap-check.request";
@ -301,7 +300,7 @@ export abstract class ApiService {
request: CollectionRequest request: CollectionRequest
) => Promise<CollectionResponse>; ) => Promise<CollectionResponse>;
deleteCollection: (organizationId: string, id: string) => Promise<any>; deleteCollection: (organizationId: string, id: string) => Promise<any>;
deleteManyCollections: (request: CollectionBulkDeleteRequest) => Promise<any>; deleteManyCollections: (organizationId: string, collectionIds: string[]) => Promise<any>;
deleteCollectionUser: ( deleteCollectionUser: (
organizationId: string, organizationId: string,
id: string, id: string,

View File

@ -18,6 +18,7 @@ import { StorageRequest } from "../../../models/request/storage.request";
import { VerifyBankRequest } from "../../../models/request/verify-bank.request"; import { VerifyBankRequest } from "../../../models/request/verify-bank.request";
import { ListResponse } from "../../../models/response/list.response"; import { ListResponse } from "../../../models/response/list.response";
import { OrganizationApiKeyType } from "../../enums"; import { OrganizationApiKeyType } from "../../enums";
import { OrganizationCollectionManagementUpdateRequest } from "../../models/request/organization-collection-management-update.request";
import { OrganizationCreateRequest } from "../../models/request/organization-create.request"; import { OrganizationCreateRequest } from "../../models/request/organization-create.request";
import { OrganizationKeysRequest } from "../../models/request/organization-keys.request"; import { OrganizationKeysRequest } from "../../models/request/organization-keys.request";
import { OrganizationUpdateRequest } from "../../models/request/organization-update.request"; import { OrganizationUpdateRequest } from "../../models/request/organization-update.request";
@ -73,4 +74,8 @@ export class OrganizationApiServiceAbstraction {
id: string, id: string,
request: SecretsManagerSubscribeRequest request: SecretsManagerSubscribeRequest
) => Promise<ProfileOrganizationResponse>; ) => Promise<ProfileOrganizationResponse>;
updateCollectionManagement: (
id: string,
request: OrganizationCollectionManagementUpdateRequest
) => Promise<OrganizationResponse>;
} }

View File

@ -49,6 +49,7 @@ export class OrganizationData {
familySponsorshipValidUntil?: Date; familySponsorshipValidUntil?: Date;
familySponsorshipToDelete?: boolean; familySponsorshipToDelete?: boolean;
accessSecretsManager: boolean; accessSecretsManager: boolean;
limitCollectionCreationDeletion: boolean;
constructor( constructor(
response: ProfileOrganizationResponse, response: ProfileOrganizationResponse,
@ -100,6 +101,7 @@ export class OrganizationData {
this.familySponsorshipValidUntil = response.familySponsorshipValidUntil; this.familySponsorshipValidUntil = response.familySponsorshipValidUntil;
this.familySponsorshipToDelete = response.familySponsorshipToDelete; this.familySponsorshipToDelete = response.familySponsorshipToDelete;
this.accessSecretsManager = response.accessSecretsManager; this.accessSecretsManager = response.accessSecretsManager;
this.limitCollectionCreationDeletion = response.limitCollectionCreationDeletion;
this.isMember = options.isMember; this.isMember = options.isMember;
this.isProviderUser = options.isProviderUser; this.isProviderUser = options.isProviderUser;

View File

@ -64,6 +64,10 @@ export class Organization {
familySponsorshipValidUntil?: Date; familySponsorshipValidUntil?: Date;
familySponsorshipToDelete?: boolean; familySponsorshipToDelete?: boolean;
accessSecretsManager: 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) { constructor(obj?: OrganizationData) {
if (obj == null) { if (obj == null) {
@ -115,6 +119,7 @@ export class Organization {
this.familySponsorshipValidUntil = obj.familySponsorshipValidUntil; this.familySponsorshipValidUntil = obj.familySponsorshipValidUntil;
this.familySponsorshipToDelete = obj.familySponsorshipToDelete; this.familySponsorshipToDelete = obj.familySponsorshipToDelete;
this.accessSecretsManager = obj.accessSecretsManager; this.accessSecretsManager = obj.accessSecretsManager;
this.limitCollectionCreationDeletion = obj.limitCollectionCreationDeletion;
} }
get canAccess() { get canAccess() {
@ -158,7 +163,9 @@ export class Organization {
} }
get canCreateNewCollections() { get canCreateNewCollections() {
return this.isManager || this.permissions.createNewCollections; return (
!this.limitCollectionCreationDeletion || this.isAdmin || this.permissions.createNewCollections
);
} }
get canEditAnyCollection() { get canEditAnyCollection() {

View File

@ -0,0 +1,3 @@
export class OrganizationCollectionManagementUpdateRequest {
limitCreateDeleteOwnerAdmin: boolean;
}

View File

@ -2,10 +2,12 @@ export class SelectionReadOnlyRequest {
id: string; id: string;
readOnly: boolean; readOnly: boolean;
hidePasswords: 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.id = id;
this.readOnly = readOnly; this.readOnly = readOnly;
this.hidePasswords = hidePasswords; this.hidePasswords = hidePasswords;
this.manage = manage;
} }
} }

View File

@ -32,6 +32,7 @@ export class OrganizationResponse extends BaseResponse {
smServiceAccounts?: number; smServiceAccounts?: number;
maxAutoscaleSmSeats?: number; maxAutoscaleSmSeats?: number;
maxAutoscaleSmServiceAccounts?: number; maxAutoscaleSmServiceAccounts?: number;
limitCollectionCreationDeletion: boolean;
constructor(response: any) { constructor(response: any) {
super(response); super(response);
@ -67,5 +68,8 @@ export class OrganizationResponse extends BaseResponse {
this.smServiceAccounts = this.getResponseProperty("SmServiceAccounts"); this.smServiceAccounts = this.getResponseProperty("SmServiceAccounts");
this.maxAutoscaleSmSeats = this.getResponseProperty("MaxAutoscaleSmSeats"); this.maxAutoscaleSmSeats = this.getResponseProperty("MaxAutoscaleSmSeats");
this.maxAutoscaleSmServiceAccounts = this.getResponseProperty("MaxAutoscaleSmServiceAccounts"); this.maxAutoscaleSmServiceAccounts = this.getResponseProperty("MaxAutoscaleSmServiceAccounts");
this.limitCollectionCreationDeletion = this.getResponseProperty(
"LimitCollectionCreationDeletion"
);
} }
} }

View File

@ -48,6 +48,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
familySponsorshipValidUntil?: Date; familySponsorshipValidUntil?: Date;
familySponsorshipToDelete?: boolean; familySponsorshipToDelete?: boolean;
accessSecretsManager: boolean; accessSecretsManager: boolean;
limitCollectionCreationDeletion: boolean;
constructor(response: any) { constructor(response: any) {
super(response); super(response);
@ -105,5 +106,8 @@ export class ProfileOrganizationResponse extends BaseResponse {
} }
this.familySponsorshipToDelete = this.getResponseProperty("FamilySponsorshipToDelete"); this.familySponsorshipToDelete = this.getResponseProperty("FamilySponsorshipToDelete");
this.accessSecretsManager = this.getResponseProperty("AccessSecretsManager"); this.accessSecretsManager = this.getResponseProperty("AccessSecretsManager");
this.limitCollectionCreationDeletion = this.getResponseProperty(
"LimitCollectionCreationDeletion"
);
} }
} }

View File

@ -4,11 +4,13 @@ export class SelectionReadOnlyResponse extends BaseResponse {
id: string; id: string;
readOnly: boolean; readOnly: boolean;
hidePasswords: boolean; hidePasswords: boolean;
manage: boolean;
constructor(response: any) { constructor(response: any) {
super(response); super(response);
this.id = this.getResponseProperty("Id"); this.id = this.getResponseProperty("Id");
this.readOnly = this.getResponseProperty("ReadOnly"); this.readOnly = this.getResponseProperty("ReadOnly");
this.hidePasswords = this.getResponseProperty("HidePasswords"); this.hidePasswords = this.getResponseProperty("HidePasswords");
this.manage = this.getResponseProperty("Manage");
} }
} }

View File

@ -21,6 +21,7 @@ import { ListResponse } from "../../../models/response/list.response";
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction"; import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
import { OrganizationApiServiceAbstraction } from "../../abstractions/organization/organization-api.service.abstraction"; import { OrganizationApiServiceAbstraction } from "../../abstractions/organization/organization-api.service.abstraction";
import { OrganizationApiKeyType } from "../../enums"; import { OrganizationApiKeyType } from "../../enums";
import { OrganizationCollectionManagementUpdateRequest } from "../../models/request/organization-collection-management-update.request";
import { OrganizationCreateRequest } from "../../models/request/organization-create.request"; import { OrganizationCreateRequest } from "../../models/request/organization-create.request";
import { OrganizationKeysRequest } from "../../models/request/organization-keys.request"; import { OrganizationKeysRequest } from "../../models/request/organization-keys.request";
import { OrganizationUpdateRequest } from "../../models/request/organization-update.request"; import { OrganizationUpdateRequest } from "../../models/request/organization-update.request";
@ -322,4 +323,20 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
); );
return new ProfileOrganizationResponse(r); return new ProfileOrganizationResponse(r);
} }
async updateCollectionManagement(
id: string,
request: OrganizationCollectionManagementUpdateRequest
): Promise<OrganizationResponse> {
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;
}
} }

View File

@ -7,6 +7,8 @@ export enum FeatureFlag {
AutofillV2 = "autofill-v2", AutofillV2 = "autofill-v2",
BrowserFilelessImport = "browser-fileless-import", BrowserFilelessImport = "browser-fileless-import",
ItemShare = "item-share", 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 // Replace this with a type safe lookup of the feature flag values in PM-2282

View File

@ -1,9 +1,7 @@
export class CollectionBulkDeleteRequest { export class CollectionBulkDeleteRequest {
ids: string[]; ids: string[];
organizationId: string;
constructor(ids: string[], organizationId?: string) { constructor(ids: string[]) {
this.ids = ids == null ? [] : ids; this.ids = ids == null ? [] : ids;
this.organizationId = organizationId;
} }
} }

View File

@ -834,11 +834,11 @@ export class ApiService implements ApiServiceAbstraction {
); );
} }
deleteManyCollections(request: CollectionBulkDeleteRequest): Promise<any> { deleteManyCollections(organizationId: string, collectionIds: string[]): Promise<any> {
return this.send( return this.send(
"DELETE", "DELETE",
"/organizations/" + request.organizationId + "/collections", "/organizations/" + organizationId + "/collections",
request, new CollectionBulkDeleteRequest(collectionIds),
true, true,
false false
); );

View File

@ -6,6 +6,7 @@ export class CollectionData {
name: string; name: string;
externalId: string; externalId: string;
readOnly: boolean; readOnly: boolean;
manage: boolean;
hidePasswords: boolean; hidePasswords: boolean;
constructor(response: CollectionDetailsResponse) { constructor(response: CollectionDetailsResponse) {
@ -14,6 +15,7 @@ export class CollectionData {
this.name = response.name; this.name = response.name;
this.externalId = response.externalId; this.externalId = response.externalId;
this.readOnly = response.readOnly; this.readOnly = response.readOnly;
this.manage = response.manage;
this.hidePasswords = response.hidePasswords; this.hidePasswords = response.hidePasswords;
} }
} }

View File

@ -13,6 +13,7 @@ describe("Collection", () => {
name: "encName", name: "encName",
externalId: "extId", externalId: "extId",
readOnly: true, readOnly: true,
manage: true,
hidePasswords: true, hidePasswords: true,
}; };
}); });
@ -28,6 +29,7 @@ describe("Collection", () => {
name: null, name: null,
organizationId: null, organizationId: null,
readOnly: null, readOnly: null,
manage: null,
}); });
}); });
@ -40,6 +42,7 @@ describe("Collection", () => {
name: { encryptedString: "encName", encryptionType: 0 }, name: { encryptedString: "encName", encryptionType: 0 },
externalId: "extId", externalId: "extId",
readOnly: true, readOnly: true,
manage: true,
hidePasswords: true, hidePasswords: true,
}); });
}); });
@ -52,6 +55,7 @@ describe("Collection", () => {
collection.externalId = "extId"; collection.externalId = "extId";
collection.readOnly = false; collection.readOnly = false;
collection.hidePasswords = false; collection.hidePasswords = false;
collection.manage = true;
const view = await collection.decrypt(); const view = await collection.decrypt();
@ -62,6 +66,7 @@ describe("Collection", () => {
name: "encName", name: "encName",
organizationId: "orgId", organizationId: "orgId",
readOnly: false, readOnly: false,
manage: true,
}); });
}); });
}); });

View File

@ -10,6 +10,7 @@ export class Collection extends Domain {
externalId: string; externalId: string;
readOnly: boolean; readOnly: boolean;
hidePasswords: boolean; hidePasswords: boolean;
manage: boolean;
constructor(obj?: CollectionData) { constructor(obj?: CollectionData) {
super(); super();
@ -27,8 +28,9 @@ export class Collection extends Domain {
externalId: null, externalId: null,
readOnly: null, readOnly: null,
hidePasswords: null, hidePasswords: null,
manage: null,
}, },
["id", "organizationId", "externalId", "readOnly", "hidePasswords"] ["id", "organizationId", "externalId", "readOnly", "hidePasswords", "manage"]
); );
} }

View File

@ -18,11 +18,13 @@ export class CollectionResponse extends BaseResponse {
export class CollectionDetailsResponse extends CollectionResponse { export class CollectionDetailsResponse extends CollectionResponse {
readOnly: boolean; readOnly: boolean;
manage: boolean;
hidePasswords: boolean; hidePasswords: boolean;
constructor(response: any) { constructor(response: any) {
super(response); super(response);
this.readOnly = this.getResponseProperty("ReadOnly") || false; this.readOnly = this.getResponseProperty("ReadOnly") || false;
this.manage = this.getResponseProperty("Manage") || false;
this.hidePasswords = this.getResponseProperty("HidePasswords") || false; this.hidePasswords = this.getResponseProperty("HidePasswords") || false;
} }
} }

View File

@ -14,6 +14,7 @@ export class CollectionView implements View, ITreeNodeObject {
// readOnly applies to the items within a collection // readOnly applies to the items within a collection
readOnly: boolean = null; readOnly: boolean = null;
hidePasswords: boolean = null; hidePasswords: boolean = null;
manage: boolean = null;
constructor(c?: Collection | CollectionAccessDetailsResponse) { constructor(c?: Collection | CollectionAccessDetailsResponse) {
if (!c) { if (!c) {
@ -26,6 +27,7 @@ export class CollectionView implements View, ITreeNodeObject {
if (c instanceof Collection) { if (c instanceof Collection) {
this.readOnly = c.readOnly; this.readOnly = c.readOnly;
this.hidePasswords = c.hidePasswords; 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. // For deleting a collection, not the items within it.
canDelete(org: Organization): boolean { canDelete(org: Organization, flexibleCollectionsEnabled: boolean): boolean {
if (org.id !== this.organizationId) { if (org.id !== this.organizationId) {
throw new Error( throw new Error(
"Id of the organization provided does not match the org id of the collection." "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;
}
} }
} }