1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-22 11:13:46 +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 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
53 changed files with 725 additions and 138 deletions

View File

@@ -76,7 +76,7 @@ export class InternalGroupService extends GroupService {
request.accessAll = group.accessAll;
request.users = group.members;
request.collections = group.collections.map(
(c) => new SelectionReadOnlyRequest(c.id, c.readOnly, c.hidePasswords)
(c) => new SelectionReadOnlyRequest(c.id, c.readOnly, c.hidePasswords, c.manage)
);
if (group.id == undefined) {

View File

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

View File

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

View File

@@ -40,6 +40,7 @@
[columnHeader]="'member' | i18n"
[selectorLabelText]="'selectMembers' | i18n"
[emptySelectionText]="'noMembersAdded' | i18n"
[flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async"
></bit-access-selector>
</bit-tab>
@@ -60,6 +61,7 @@
[columnHeader]="'collection' | i18n"
[selectorLabelText]="'selectCollections' | i18n"
[emptySelectionText]="'noCollectionsAdded' | i18n"
[flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async"
></bit-access-selector>
</ng-container>
</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 { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -78,6 +80,11 @@ export const openGroupAddEditDialog = (
templateUrl: "group-add-edit.component.html",
})
export class GroupAddEditComponent implements OnInit, OnDestroy {
protected flexibleCollectionsEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.FlexibleCollections,
false
);
protected PermissionMode = PermissionMode;
protected ResultType = GroupAddEditDialogResultType;
@@ -181,7 +188,8 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
private logService: LogService,
private formBuilder: FormBuilder,
private changeDetectorRef: ChangeDetectorRef,
private dialogService: DialogService
private dialogService: DialogService,
private configService: ConfigServiceAbstraction
) {
this.tabIndex = params.initialTab ?? GroupAddEditTabType.Info;
}

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</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>
<bit-form-field>
@@ -52,6 +52,27 @@
{{ "rotateApiKey" | i18n }}
</button>
</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>
<div class="tw-rounded tw-border tw-border-solid tw-border-danger-500 tw-bg-background tw-p-5">
<p>{{ "dangerZoneDesc" | i18n }}</p>

View File

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

View File

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

View File

@@ -1,19 +1,21 @@
import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-user/responses";
import {
OrganizationUserStatusType,
OrganizationUserType,
} from "@bitwarden/common/admin-console/enums";
import { SelectItemView } from "@bitwarden/components";
import { CollectionAccessSelectionView } from "../../../core";
import { CollectionAccessSelectionView, GroupView } from "../../../core";
/**
* Permission options that replace/correspond with readOnly and hidePassword server fields.
* Permission options that replace/correspond with manage, readOnly, and hidePassword server fields.
*/
export enum CollectionPermission {
View = "view",
ViewExceptPass = "viewExceptPass",
Edit = "edit",
EditExceptPass = "editExceptPass",
Manage = "manage",
}
export enum AccessItemType {
@@ -82,7 +84,9 @@ export type AccessItemValue = {
* @param value
*/
export const convertToPermission = (value: CollectionAccessSelectionView) => {
if (value.readOnly) {
if (value.manage) {
return CollectionPermission.Manage;
} else if (value.readOnly) {
return value.hidePasswords ? CollectionPermission.ViewExceptPass : CollectionPermission.View;
} else {
return value.hidePasswords ? CollectionPermission.EditExceptPass : CollectionPermission.Edit;
@@ -91,7 +95,7 @@ export const convertToPermission = (value: CollectionAccessSelectionView) => {
/**
* Converts an AccessItemValue back into a CollectionAccessView class using the CollectionPermission
* to determine the values for `readOnly` and `hidePassword`
* to determine the values for `manage`, `readOnly`, and `hidePassword`
* @param value
*/
export const convertToSelectionView = (value: AccessItemValue) => {
@@ -99,6 +103,7 @@ export const convertToSelectionView = (value: AccessItemValue) => {
id: value.id,
readOnly: readOnly(value.permission),
hidePasswords: hidePassword(value.permission),
manage: value.permission === CollectionPermission.Manage,
});
};
@@ -107,3 +112,29 @@ const readOnly = (perm: CollectionPermission) =>
const hidePassword = (perm: CollectionPermission) =>
[CollectionPermission.ViewExceptPass, CollectionPermission.EditExceptPass].includes(perm);
export function mapGroupToAccessItemView(group: GroupView): AccessItemView {
return {
id: group.id,
type: AccessItemType.Group,
listName: group.name,
labelName: group.name,
accessAllItems: group.accessAll,
readonly: group.accessAll,
};
}
// TODO: Use view when user apis are migrated to a service
export function mapUserToAccessItemView(user: OrganizationUserUserDetailsResponse): AccessItemView {
return {
id: user.id,
type: AccessItemType.Member,
email: user.email,
role: user.type,
listName: user.name?.length > 0 ? `${user.name} (${user.email})` : user.email,
labelName: user.name ?? user.email,
status: user.status,
accessAllItems: user.accessAll,
readonly: user.accessAll,
};
}