mirror of
https://github.com/bitwarden/browser
synced 2025-12-23 19:53:43 +00: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 commit65cd39589c. * Add manage property to synced Collection data * Revert "Add manage property to synced Collection data" This reverts commitf7fa30b79a. * [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:
@@ -64,6 +64,12 @@
|
||||
</bit-form-field>
|
||||
</bit-tab>
|
||||
<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
|
||||
*ngIf="organization.useGroups"
|
||||
[permissionMode]="PermissionMode.Edit"
|
||||
@@ -73,6 +79,7 @@
|
||||
[selectorLabelText]="'selectGroupsAndMembers' | i18n"
|
||||
[selectorHelpText]="'userPermissionOverrideHelper' | i18n"
|
||||
[emptySelectionText]="'noMembersOrGroupsAdded' | i18n"
|
||||
[flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async"
|
||||
></bit-access-selector>
|
||||
<bit-access-selector
|
||||
*ngIf="!organization.useGroups"
|
||||
@@ -82,6 +89,7 @@
|
||||
[columnHeader]="'memberColumnHeader' | i18n"
|
||||
[selectorLabelText]="'selectMembers' | i18n"
|
||||
[emptySelectionText]="'noMembersAdded' | i18n"
|
||||
[flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async"
|
||||
></bit-access-selector>
|
||||
</bit-tab>
|
||||
</bit-tab-group>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
|
||||
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from "@angular/core";
|
||||
import { AbstractControl, FormBuilder, Validators } from "@angular/forms";
|
||||
import {
|
||||
combineLatest,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
@@ -14,23 +16,27 @@ import {
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
|
||||
import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-user/responses";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CollectionResponse } from "@bitwarden/common/vault/models/response/collection.response";
|
||||
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
|
||||
import { DialogService, BitValidators } from "@bitwarden/components";
|
||||
import { BitValidators, DialogService } from "@bitwarden/components";
|
||||
|
||||
import { GroupService, GroupView } from "../../../admin-console/organizations/core";
|
||||
import { PermissionMode } from "../../../admin-console/organizations/shared/components/access-selector/access-selector.component";
|
||||
import {
|
||||
AccessItemView,
|
||||
AccessItemValue,
|
||||
AccessItemType,
|
||||
convertToSelectionView,
|
||||
AccessItemValue,
|
||||
AccessItemView,
|
||||
CollectionPermission,
|
||||
convertToPermission,
|
||||
convertToSelectionView,
|
||||
mapGroupToAccessItemView,
|
||||
mapUserToAccessItemView,
|
||||
} from "../../../admin-console/organizations/shared/components/access-selector/access-selector.models";
|
||||
import { CollectionAdminService } from "../../core/collection-admin.service";
|
||||
import { CollectionAdminView } from "../../core/views/collection-admin.view";
|
||||
@@ -64,6 +70,11 @@ export enum CollectionDialogAction {
|
||||
templateUrl: "collection-dialog.component.html",
|
||||
})
|
||||
export class CollectionDialogComponent implements OnInit, OnDestroy {
|
||||
protected flexibleCollectionsEnabled$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.FlexibleCollections,
|
||||
false
|
||||
);
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
protected organizations$: Observable<Organization[]>;
|
||||
|
||||
@@ -94,7 +105,9 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private organizationUserService: OrganizationUserService,
|
||||
private dialogService: DialogService
|
||||
private dialogService: DialogService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private configService: ConfigServiceAbstraction
|
||||
) {
|
||||
this.tabIndex = params.initialTab ?? CollectionDialogTabType.Info;
|
||||
}
|
||||
@@ -118,7 +131,11 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
|
||||
} else {
|
||||
// Opened from the org vault
|
||||
this.formGroup.patchValue({ selectedOrg: this.params.organizationId });
|
||||
this.loadOrg(this.params.organizationId, this.params.collectionIds);
|
||||
await this.loadOrg(this.params.organizationId, this.params.collectionIds);
|
||||
}
|
||||
|
||||
if (await firstValueFrom(this.flexibleCollectionsEnabled$)) {
|
||||
this.formGroup.controls.access.addValidators(validateCanManagePermission);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,51 +156,76 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
|
||||
organization: organization$,
|
||||
collections: this.collectionService.getAll(orgId),
|
||||
collectionDetails: this.params.collectionId
|
||||
? this.collectionService.get(orgId, this.params.collectionId)
|
||||
? from(this.collectionService.get(orgId, this.params.collectionId))
|
||||
: of(null),
|
||||
groups: groups$,
|
||||
users: this.organizationUserService.getAllUsers(orgId),
|
||||
flexibleCollections: this.flexibleCollectionsEnabled$,
|
||||
})
|
||||
.pipe(takeUntil(this.formGroup.controls.selectedOrg.valueChanges), takeUntil(this.destroy$))
|
||||
.subscribe(({ organization, collections, collectionDetails, groups, users }) => {
|
||||
this.organization = organization;
|
||||
this.accessItems = [].concat(
|
||||
groups.map(mapGroupToAccessItemView),
|
||||
users.data.map(mapUserToAccessItemView)
|
||||
);
|
||||
.subscribe(
|
||||
({ organization, collections, collectionDetails, groups, users, flexibleCollections }) => {
|
||||
this.organization = organization;
|
||||
this.accessItems = [].concat(
|
||||
groups.map(mapGroupToAccessItemView),
|
||||
users.data.map(mapUserToAccessItemView)
|
||||
);
|
||||
|
||||
if (collectionIds) {
|
||||
collections = collections.filter((c) => collectionIds.includes(c.id));
|
||||
}
|
||||
// Force change detection to update the access selector's items
|
||||
this.changeDetectorRef.detectChanges();
|
||||
|
||||
if (this.params.collectionId) {
|
||||
this.collection = collections.find((c) => c.id === this.collectionId);
|
||||
this.nestOptions = collections.filter((c) => c.id !== this.collectionId);
|
||||
|
||||
if (!this.collection) {
|
||||
throw new Error("Could not find collection to edit.");
|
||||
if (collectionIds) {
|
||||
collections = collections.filter((c) => collectionIds.includes(c.id));
|
||||
}
|
||||
|
||||
const { name, parent } = parseName(this.collection);
|
||||
if (parent !== undefined && !this.nestOptions.find((c) => c.name === parent)) {
|
||||
this.deletedParentName = parent;
|
||||
if (this.params.collectionId) {
|
||||
this.collection = collections.find((c) => c.id === this.collectionId);
|
||||
this.nestOptions = collections.filter((c) => c.id !== this.collectionId);
|
||||
|
||||
if (!this.collection) {
|
||||
throw new Error("Could not find collection to edit.");
|
||||
}
|
||||
|
||||
const { name, parent } = parseName(this.collection);
|
||||
if (parent !== undefined && !this.nestOptions.find((c) => c.name === parent)) {
|
||||
this.deletedParentName = parent;
|
||||
}
|
||||
|
||||
const accessSelections = mapToAccessSelections(collectionDetails);
|
||||
this.formGroup.patchValue({
|
||||
name,
|
||||
externalId: this.collection.externalId,
|
||||
parent,
|
||||
access: accessSelections,
|
||||
});
|
||||
} else {
|
||||
this.nestOptions = collections;
|
||||
const parent = collections.find((c) => c.id === this.params.parentCollectionId);
|
||||
const currentOrgUserId = users.data.find(
|
||||
(u) => u.userId === this.organization?.userId
|
||||
)?.id;
|
||||
const initialSelection: AccessItemValue[] =
|
||||
currentOrgUserId !== undefined
|
||||
? [
|
||||
{
|
||||
id: currentOrgUserId,
|
||||
type: AccessItemType.Member,
|
||||
permission: flexibleCollections
|
||||
? CollectionPermission.Manage
|
||||
: CollectionPermission.Edit,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
this.formGroup.patchValue({
|
||||
parent: parent?.name ?? undefined,
|
||||
access: initialSelection,
|
||||
});
|
||||
}
|
||||
|
||||
const accessSelections = mapToAccessSelections(collectionDetails);
|
||||
this.formGroup.patchValue({
|
||||
name,
|
||||
externalId: this.collection.externalId,
|
||||
parent,
|
||||
access: accessSelections,
|
||||
});
|
||||
} else {
|
||||
this.nestOptions = collections;
|
||||
const parent = collections.find((c) => c.id === this.params.parentCollectionId);
|
||||
this.formGroup.patchValue({ parent: parent?.name ?? undefined });
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
protected get collectionId() {
|
||||
@@ -202,12 +244,20 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
|
||||
this.formGroup.markAllAsTouched();
|
||||
|
||||
if (this.formGroup.invalid) {
|
||||
if (this.tabIndex === CollectionDialogTabType.Access) {
|
||||
const accessTabError = this.formGroup.controls.access.hasError("managePermissionRequired");
|
||||
|
||||
if (this.tabIndex === CollectionDialogTabType.Access && !accessTabError) {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
null,
|
||||
this.i18nService.t("fieldOnTabRequiresAttention", this.i18nService.t("collectionInfo"))
|
||||
);
|
||||
} else if (this.tabIndex === CollectionDialogTabType.Info && accessTabError) {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
null,
|
||||
this.i18nService.t("fieldOnTabRequiresAttention", this.i18nService.t("access"))
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -284,32 +334,6 @@ function parseName(collection: CollectionView) {
|
||||
return { name, parent };
|
||||
}
|
||||
|
||||
function mapGroupToAccessItemView(group: GroupView): AccessItemView {
|
||||
return {
|
||||
id: group.id,
|
||||
type: AccessItemType.Group,
|
||||
listName: group.name,
|
||||
labelName: group.name,
|
||||
accessAllItems: group.accessAll,
|
||||
readonly: group.accessAll,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Use view when user apis are migrated to a service
|
||||
function mapUserToAccessItemView(user: OrganizationUserUserDetailsResponse): AccessItemView {
|
||||
return {
|
||||
id: user.id,
|
||||
type: AccessItemType.Member,
|
||||
email: user.email,
|
||||
role: user.type,
|
||||
listName: user.name?.length > 0 ? `${user.name} (${user.email})` : user.email,
|
||||
labelName: user.name ?? user.email,
|
||||
status: user.status,
|
||||
accessAllItems: user.accessAll,
|
||||
readonly: user.accessAll,
|
||||
};
|
||||
}
|
||||
|
||||
function mapToAccessSelections(collectionDetails: CollectionAdminView): AccessItemValue[] {
|
||||
if (collectionDetails == undefined) {
|
||||
return [];
|
||||
@@ -328,6 +352,16 @@ function mapToAccessSelections(collectionDetails: CollectionAdminView): AccessIt
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validator to ensure that at least one access item has Manage permission
|
||||
*/
|
||||
function validateCanManagePermission(control: AbstractControl) {
|
||||
const access = control.value as AccessItemValue[];
|
||||
const hasManagePermission = access.some((a) => a.permission === CollectionPermission.Manage);
|
||||
|
||||
return hasManagePermission ? null : { managePermissionRequired: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strongly typed helper to open a CollectionDialog
|
||||
* @param dialogService Instance of the dialog service that will be used to open the dialog
|
||||
|
||||
@@ -6,6 +6,7 @@ import { VaultItem } from "./vault-item";
|
||||
export type VaultItemEvent =
|
||||
| { type: "viewAttachments"; item: CipherView }
|
||||
| { type: "viewCollections"; item: CipherView }
|
||||
| { type: "bulkEditCollectionAccess"; items: CollectionView[] }
|
||||
| { type: "viewCollectionAccess"; item: CollectionView }
|
||||
| { type: "viewEvents"; item: CipherView }
|
||||
| { type: "editCollection"; item: CollectionView }
|
||||
|
||||
@@ -34,6 +34,15 @@
|
||||
<i class="bwi bwi-fw bwi-folder" aria-hidden="true"></i>
|
||||
{{ "moveSelected" | i18n }}
|
||||
</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
|
||||
*ngIf="bulkMoveAllowed"
|
||||
type="button"
|
||||
|
||||
@@ -2,6 +2,8 @@ import { SelectionModel } from "@angular/cdk/collections";
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
|
||||
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 { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
|
||||
import { TableDataSource } from "@bitwarden/components";
|
||||
@@ -27,6 +29,8 @@ const MaxSelectionCount = 500;
|
||||
export class VaultItemsComponent {
|
||||
protected RowHeight = RowHeight;
|
||||
|
||||
private flexibleCollectionsEnabled: boolean;
|
||||
|
||||
@Input() disabled: boolean;
|
||||
@Input() showOwner: boolean;
|
||||
@Input() showCollections: boolean;
|
||||
@@ -36,9 +40,12 @@ export class VaultItemsComponent {
|
||||
@Input() showPremiumFeatures: boolean;
|
||||
@Input() showBulkMove: boolean;
|
||||
@Input() showBulkTrashOptions: boolean;
|
||||
// Encompasses functionality only available from the organization vault context
|
||||
@Input() showAdminActions: boolean;
|
||||
@Input() allOrganizations: Organization[] = [];
|
||||
@Input() allCollections: CollectionView[] = [];
|
||||
@Input() allGroups: GroupView[] = [];
|
||||
@Input() showBulkEditCollectionAccess = false;
|
||||
|
||||
private _ciphers?: CipherView[] = [];
|
||||
@Input() get ciphers(): CipherView[] {
|
||||
@@ -64,6 +71,14 @@ export class VaultItemsComponent {
|
||||
protected dataSource = new TableDataSource<VaultItem>();
|
||||
protected selection = new SelectionModel<VaultItem>(true, [], true);
|
||||
|
||||
constructor(private configService: ConfigServiceAbstraction) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.flexibleCollectionsEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.FlexibleCollections
|
||||
);
|
||||
}
|
||||
|
||||
get showExtraColumn() {
|
||||
return this.showCollections || this.showGroups || this.showOwner;
|
||||
}
|
||||
@@ -101,7 +116,7 @@ export class VaultItemsComponent {
|
||||
}
|
||||
|
||||
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId);
|
||||
return collection.canDelete(organization);
|
||||
return collection.canDelete(organization, this.flexibleCollectionsEnabled);
|
||||
}
|
||||
|
||||
protected toggleAll() {
|
||||
@@ -168,4 +183,13 @@ export class VaultItemsComponent {
|
||||
);
|
||||
this.dataSource.data = items;
|
||||
}
|
||||
|
||||
protected bulkEditCollectionAccess() {
|
||||
this.event({
|
||||
type: "bulkEditCollectionAccess",
|
||||
items: this.selection.selected
|
||||
.filter((item) => item.collection !== undefined)
|
||||
.map((item) => item.collection),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SettingsService } from "@bitwarden/common/abstractions/settings.service
|
||||
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
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 { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
@@ -92,6 +93,15 @@ export default {
|
||||
},
|
||||
} as Partial<TokenService>,
|
||||
},
|
||||
{
|
||||
provide: ConfigServiceAbstraction,
|
||||
useValue: {
|
||||
getFeatureFlag() {
|
||||
// does not currently affect any display logic, default all to OFF
|
||||
return false;
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
applicationConfig({
|
||||
@@ -289,6 +299,7 @@ function createCollectionView(i: number): CollectionAdminView {
|
||||
id: group.id,
|
||||
hidePasswords: false,
|
||||
readOnly: false,
|
||||
manage: false,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user