AC-1115 Modify AC Vault/Collections (#6789)

* Permissions Column added to Org Vault. Other updates to filter section and Can Manage Permission added and put behind feature flag

---------

Co-authored-by: Thomas Rittson <trittson@bitwarden.com>
Co-authored-by: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com>
This commit is contained in:
Jason Ng 2024-01-10 09:56:23 -05:00 committed by GitHub
parent c67fd9b584
commit 48d161009d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 187 additions and 28 deletions

View File

@ -7,9 +7,19 @@
[activeOrganization]="organization" [activeOrganization]="organization"
></app-organization-switcher> ></app-organization-switcher>
<bit-tab-nav-bar class="-tw-mb-px"> <bit-tab-nav-bar class="-tw-mb-px">
<bit-tab-link *ngIf="canShowVaultTab(organization)" route="vault">{{ <bit-tab-link
"vault" | i18n *ngIf="
}}</bit-tab-link> canShowVaultTab(organization) && (flexibleCollectionsEnabled$ | async);
else vaultTab
"
route="vault"
>{{ "collections" | i18n }}</bit-tab-link
>
<ng-template #vaultTab>
<bit-tab-link *ngIf="canShowVaultTab(organization)" route="vault">{{
"vault" | i18n
}}</bit-tab-link>
</ng-template>
<bit-tab-link *ngIf="canShowMembersTab(organization)" route="members">{{ <bit-tab-link *ngIf="canShowMembersTab(organization)" route="members">{{
"members" | i18n "members" | i18n
}}</bit-tab-link> }}</bit-tab-link>

View File

@ -13,6 +13,8 @@ import {
OrganizationService, OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; } 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
@Component({ @Component({
selector: "app-organization-layout", selector: "app-organization-layout",
@ -23,12 +25,18 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy {
private _destroy = new Subject<void>(); private _destroy = new Subject<void>();
protected flexibleCollectionsEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.FlexibleCollections,
false,
);
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private organizationService: OrganizationService, private organizationService: OrganizationService,
private configService: ConfigServiceAbstraction,
) {} ) {}
ngOnInit() { async ngOnInit() {
document.body.classList.remove("layout_frontend"); document.body.classList.remove("layout_frontend");
this.organization$ = this.route.params this.organization$ = this.route.params

View File

@ -18,6 +18,8 @@ import {
AccessItemValue, AccessItemValue,
AccessItemView, AccessItemView,
CollectionPermission, CollectionPermission,
getPermissionList,
Permission,
} from "./access-selector.models"; } from "./access-selector.models";
export enum PermissionMode { export enum PermissionMode {
@ -116,16 +118,7 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On
}); });
protected itemType = AccessItemType; protected itemType = AccessItemType;
protected permissionList = [ protected permissionList: Permission[];
{ perm: CollectionPermission.View, labelId: "canView" },
{ perm: CollectionPermission.ViewExceptPass, labelId: "canViewExceptPass" },
{ perm: CollectionPermission.Edit, labelId: "canEdit" },
{ perm: CollectionPermission.EditExceptPass, labelId: "canEditExceptPass" },
];
private canManagePermissionListItem = {
perm: CollectionPermission.Manage,
labelId: "canManage",
};
protected initialPermission = CollectionPermission.View; protected initialPermission = CollectionPermission.View;
disabled: boolean; disabled: boolean;
@ -264,6 +257,7 @@ export class AccessSelectorComponent implements ControlValueAccessor, OnInit, On
} }
async ngOnInit() { async ngOnInit() {
this.permissionList = getPermissionList(this.flexibleCollectionsEnabled);
// 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) {
@ -277,10 +271,6 @@ 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

@ -77,6 +77,25 @@ export type AccessItemValue = {
type: AccessItemType; type: AccessItemType;
}; };
export type Permission = {
perm: CollectionPermission;
labelId: string;
};
export const getPermissionList = (flexibleCollectionsEnabled: boolean): Permission[] => {
const permissions = [
{ perm: CollectionPermission.View, labelId: "canView" },
{ perm: CollectionPermission.ViewExceptPass, labelId: "canViewExceptPass" },
{ perm: CollectionPermission.Edit, labelId: "canEdit" },
{ perm: CollectionPermission.EditExceptPass, labelId: "canEditExceptPass" },
];
if (flexibleCollectionsEnabled) {
permissions.push({ perm: CollectionPermission.Manage, labelId: "canManage" });
}
return permissions;
};
/** /**
* Converts the CollectionAccessSelectionView interface to one of the new CollectionPermission values * Converts the CollectionAccessSelectionView interface to one of the new CollectionPermission values
* for the dropdown in the AccessSelectorComponent * for the dropdown in the AccessSelectorComponent

View File

@ -65,6 +65,7 @@
></app-collection-badge> ></app-collection-badge>
</td> </td>
<td bitCell [ngClass]="RowHeightClass" *ngIf="showGroups"></td> <td bitCell [ngClass]="RowHeightClass" *ngIf="showGroups"></td>
<td bitCell [ngClass]="RowHeightClass" *ngIf="!showCollections"></td>
<td bitCell [ngClass]="RowHeightClass" class="tw-text-right"> <td bitCell [ngClass]="RowHeightClass" class="tw-text-right">
<button <button
[disabled]="disabled" [disabled]="disabled"

View File

@ -47,6 +47,11 @@
[allGroups]="groups" [allGroups]="groups"
></app-group-badge> ></app-group-badge>
</td> </td>
<td bitCell [ngClass]="RowHeightClass" *ngIf="showPermissionsColumn">
<p class="tw-mb-0 tw-text-muted">
{{ permissionText }}
</p>
</td>
<td bitCell [ngClass]="RowHeightClass" class="tw-text-right"> <td bitCell [ngClass]="RowHeightClass" class="tw-text-right">
<button <button
*ngIf="canEditCollection || canDeleteCollection" *ngIf="canEditCollection || canDeleteCollection"

View File

@ -1,12 +1,26 @@
import { Component, EventEmitter, HostBinding, HostListener, Input, Output } from "@angular/core"; import {
import { ActivatedRoute, Router } from "@angular/router"; Component,
EventEmitter,
HostBinding,
HostListener,
Input,
OnInit,
Output,
} from "@angular/core";
import { Router } from "@angular/router";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
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";
import { GroupView } from "../../../admin-console/organizations/core"; import { GroupView } from "../../../admin-console/organizations/core";
import { CollectionAdminView } from "../../core/views/collection-admin.view"; import { CollectionAdminView } from "../../core/views/collection-admin.view";
import {
convertToPermission,
getPermissionList,
Permission,
} from "./../../../admin-console/organizations/shared/components/access-selector/access-selector.models";
import { VaultItemEvent } from "./vault-item-event"; import { VaultItemEvent } from "./vault-item-event";
import { RowHeightClass } from "./vault-items.component"; import { RowHeightClass } from "./vault-items.component";
@ -14,7 +28,7 @@ import { RowHeightClass } from "./vault-items.component";
selector: "tr[appVaultCollectionRow]", selector: "tr[appVaultCollectionRow]",
templateUrl: "vault-collection-row.component.html", templateUrl: "vault-collection-row.component.html",
}) })
export class VaultCollectionRowComponent { export class VaultCollectionRowComponent implements OnInit {
protected RowHeightClass = RowHeightClass; protected RowHeightClass = RowHeightClass;
@Input() disabled: boolean; @Input() disabled: boolean;
@ -26,17 +40,25 @@ export class VaultCollectionRowComponent {
@Input() canDeleteCollection: boolean; @Input() canDeleteCollection: boolean;
@Input() organizations: Organization[]; @Input() organizations: Organization[];
@Input() groups: GroupView[]; @Input() groups: GroupView[];
@Input() showPermissionsColumn: boolean;
@Input() flexibleCollectionsEnabled: boolean;
@Output() onEvent = new EventEmitter<VaultItemEvent>(); @Output() onEvent = new EventEmitter<VaultItemEvent>();
@Input() checked: boolean; @Input() checked: boolean;
@Output() checkedToggled = new EventEmitter<void>(); @Output() checkedToggled = new EventEmitter<void>();
private permissionList: Permission[];
constructor( constructor(
private router: Router, private router: Router,
private activatedRoute: ActivatedRoute, private i18nService: I18nService,
) {} ) {}
ngOnInit() {
this.permissionList = getPermissionList(this.flexibleCollectionsEnabled);
}
@HostBinding("class") @HostBinding("class")
get classes() { get classes() {
return [].concat(this.disabled ? [] : ["tw-cursor-pointer"]); return [].concat(this.disabled ? [] : ["tw-cursor-pointer"]);
@ -54,6 +76,16 @@ export class VaultCollectionRowComponent {
return this.organizations.find((o) => o.id === this.collection.organizationId); return this.organizations.find((o) => o.id === this.collection.organizationId);
} }
get permissionText() {
if (!(this.collection as CollectionAdminView).assigned) {
return "-";
} else {
return this.i18nService.t(
this.permissionList.find((p) => p.perm === convertToPermission(this.collection))?.labelId,
);
}
}
@HostListener("click") @HostListener("click")
protected click() { protected click() {
this.router.navigate([], { this.router.navigate([], {

View File

@ -20,6 +20,9 @@
<th bitCell class="tw-w-2/5" *ngIf="showOwner">{{ "owner" | i18n }}</th> <th bitCell class="tw-w-2/5" *ngIf="showOwner">{{ "owner" | i18n }}</th>
<th bitCell class="tw-w-2/5" *ngIf="showCollections">{{ "collections" | i18n }}</th> <th bitCell class="tw-w-2/5" *ngIf="showCollections">{{ "collections" | i18n }}</th>
<th bitCell class="tw-w-2/5" *ngIf="showGroups">{{ "groups" | i18n }}</th> <th bitCell class="tw-w-2/5" *ngIf="showGroups">{{ "groups" | i18n }}</th>
<th bitCell class="tw-w-2/5" *ngIf="showPermissionsColumn">
{{ "permission" | i18n }}
</th>
<th bitCell class="tw-w-12 tw-text-right"> <th bitCell class="tw-w-12 tw-text-right">
<button <button
[disabled]="disabled || isEmpty" [disabled]="disabled || isEmpty"
@ -79,10 +82,12 @@
[showCollections]="showCollections" [showCollections]="showCollections"
[showGroups]="showGroups" [showGroups]="showGroups"
[organizations]="allOrganizations" [organizations]="allOrganizations"
[showPermissionsColumn]="showPermissionsColumn"
[groups]="allGroups" [groups]="allGroups"
[canDeleteCollection]="canDeleteCollection(item.collection)" [canDeleteCollection]="canDeleteCollection(item.collection)"
[canEditCollection]="canEditCollection(item.collection)" [canEditCollection]="canEditCollection(item.collection)"
[checked]="selection.isSelected(item)" [checked]="selection.isSelected(item)"
[flexibleCollectionsEnabled]="flexibleCollectionsEnabled"
(checkedToggled)="selection.toggle(item)" (checkedToggled)="selection.toggle(item)"
(onEvent)="event($event)" (onEvent)="event($event)"
></tr> ></tr>

View File

@ -29,7 +29,7 @@ const MaxSelectionCount = 500;
export class VaultItemsComponent { export class VaultItemsComponent {
protected RowHeight = RowHeight; protected RowHeight = RowHeight;
private flexibleCollectionsEnabled: boolean; protected flexibleCollectionsEnabled: boolean;
@Input() disabled: boolean; @Input() disabled: boolean;
@Input() showOwner: boolean; @Input() showOwner: boolean;
@ -46,6 +46,7 @@ export class VaultItemsComponent {
@Input() allCollections: CollectionView[] = []; @Input() allCollections: CollectionView[] = [];
@Input() allGroups: GroupView[] = []; @Input() allGroups: GroupView[] = [];
@Input() showBulkEditCollectionAccess = false; @Input() showBulkEditCollectionAccess = false;
@Input() showPermissionsColumn = false;
private _ciphers?: CipherView[] = []; private _ciphers?: CipherView[] = [];
@Input() get ciphers(): CipherView[] { @Input() get ciphers(): CipherView[] {

View File

@ -1,13 +1,20 @@
import { Component, Input, OnDestroy, OnInit } from "@angular/core"; import { Component, Input, OnDestroy, OnInit } from "@angular/core";
import { firstValueFrom, Subject } from "rxjs"; import { firstValueFrom, Subject } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { VaultFilterComponent as BaseVaultFilterComponent } from "../../individual-vault/vault-filter/components/vault-filter.component"; //../../vault/vault-filter/components/vault-filter.component"; import { VaultFilterComponent as BaseVaultFilterComponent } from "../../individual-vault/vault-filter/components/vault-filter.component"; //../../vault/vault-filter/components/vault-filter.component";
import { VaultFilterService } from "../../individual-vault/vault-filter/services/abstractions/vault-filter.service";
import { import {
VaultFilterList, VaultFilterList,
VaultFilterType, VaultFilterType,
VaultFilterSection,
} from "../../individual-vault/vault-filter/shared/models/vault-filter-section.type"; } from "../../individual-vault/vault-filter/shared/models/vault-filter-section.type";
import { CollectionFilter } from "../../individual-vault/vault-filter/shared/models/vault-filter.type"; import { CollectionFilter } from "../../individual-vault/vault-filter/shared/models/vault-filter.type";
@ -25,7 +32,22 @@ export class VaultFilterComponent extends BaseVaultFilterComponent implements On
_organization: Organization; _organization: Organization;
protected destroy$: Subject<void>; protected destroy$: Subject<void>;
private flexibleCollectionsEnabled: boolean;
constructor(
protected vaultFilterService: VaultFilterService,
protected policyService: PolicyService,
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected configService: ConfigServiceAbstraction,
) {
super(vaultFilterService, policyService, i18nService, platformUtilsService);
}
async ngOnInit() { async ngOnInit() {
this.flexibleCollectionsEnabled = await this.configService.getFeatureFlag(
FeatureFlag.FlexibleCollections,
);
this.filters = await this.buildAllFilters(); this.filters = await this.buildAllFilters();
if (!this.activeFilter.selectedCipherTypeNode) { if (!this.activeFilter.selectedCipherTypeNode) {
this.activeFilter.resetFilter(); this.activeFilter.resetFilter();
@ -40,10 +62,52 @@ export class VaultFilterComponent extends BaseVaultFilterComponent implements On
this.destroy$.complete(); this.destroy$.complete();
} }
async removeCollapsibleCollection() {
const collapsedNodes = await firstValueFrom(this.vaultFilterService.collapsedFilterNodes$);
collapsedNodes.delete("AllCollections");
await this.vaultFilterService.setCollapsedFilterNodes(collapsedNodes);
}
protected async addCollectionFilter(): Promise<VaultFilterSection> {
// Ensure the Collections filter is never collapsed for the org vault
this.removeCollapsibleCollection();
const collectionFilterSection: VaultFilterSection = {
data$: this.vaultFilterService.buildTypeTree(
{
id: "AllCollections",
name: "collections",
type: "all",
icon: "bwi-collection",
},
[
{
id: "AllCollections",
name: "Collections",
type: "all",
icon: "bwi-collection",
},
],
),
header: {
showHeader: false,
isSelectable: true,
},
action: this.applyCollectionFilter,
};
return collectionFilterSection;
}
async buildAllFilters(): Promise<VaultFilterList> { async buildAllFilters(): Promise<VaultFilterList> {
const builderFilter = {} as VaultFilterList; const builderFilter = {} as VaultFilterList;
builderFilter.typeFilter = await this.addTypeFilter(["favorites"]); builderFilter.typeFilter = await this.addTypeFilter(["favorites"]);
builderFilter.collectionFilter = await this.addCollectionFilter(); if (this.flexibleCollectionsEnabled) {
builderFilter.collectionFilter = await this.addCollectionFilter();
} else {
builderFilter.collectionFilter = await super.addCollectionFilter();
}
builderFilter.trashFilter = await this.addTrashFilter(); builderFilter.trashFilter = await this.addTrashFilter();
return builderFilter; return builderFilter;
} }

View File

@ -76,6 +76,10 @@ export class VaultHeaderComponent {
} }
get title() { get title() {
const headerType = this.flexibleCollectionsEnabled
? this.i18nService.t("collections").toLowerCase()
: this.i18nService.t("vault").toLowerCase();
if (this.collection !== undefined) { if (this.collection !== undefined) {
return this.collection.node.name; return this.collection.node.name;
} }
@ -84,7 +88,7 @@ export class VaultHeaderComponent {
return this.i18nService.t("unassigned"); return this.i18nService.t("unassigned");
} }
return `${this.organization.name} ${this.i18nService.t("vault").toLowerCase()}`; return `${this.organization.name} ${headerType}`;
} }
protected get showBreadcrumbs() { protected get showBreadcrumbs() {

View File

@ -41,6 +41,7 @@
[allGroups]="allGroups" [allGroups]="allGroups"
[disabled]="loading" [disabled]="loading"
[showOwner]="false" [showOwner]="false"
[showPermissionsColumn]="true"
[showCollections]="filter.type !== undefined" [showCollections]="filter.type !== undefined"
[showGroups]=" [showGroups]="
organization?.useGroups && organization?.useGroups &&

View File

@ -46,6 +46,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
@ -164,6 +165,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private eventCollectionService: EventCollectionService, private eventCollectionService: EventCollectionService,
private totpService: TotpService, private totpService: TotpService,
private apiService: ApiService, private apiService: ApiService,
private collectionService: CollectionService,
protected configService: ConfigServiceAbstraction, protected configService: ConfigServiceAbstraction,
) {} ) {}
@ -232,8 +234,26 @@ export class VaultComponent implements OnInit, OnDestroy {
this.currentSearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search)); this.currentSearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search));
const allCollectionsWithoutUnassigned$ = organizationId$.pipe( const allCollectionsWithoutUnassigned$ = combineLatest([
switchMap((orgId) => this.collectionAdminService.getAll(orgId)), organizationId$.pipe(switchMap((orgId) => this.collectionAdminService.getAll(orgId))),
this.collectionService.getAllDecrypted(),
]).pipe(
map(([adminCollections, syncCollections]) => {
const syncCollectionDict = Object.fromEntries(syncCollections.map((c) => [c.id, c]));
return adminCollections.map((collection) => {
const currentId: any = collection.id;
const match = syncCollectionDict[currentId];
if (match) {
collection.manage = match.manage;
collection.readOnly = match.readOnly;
collection.hidePasswords = match.hidePasswords;
}
return collection;
});
}),
shareReplay({ refCount: true, bufferSize: 1 }), shareReplay({ refCount: true, bufferSize: 1 }),
); );
@ -454,7 +474,6 @@ export class VaultComponent implements OnInit, OnDestroy {
this.collections = collections; this.collections = collections;
this.selectedCollection = selectedCollection; this.selectedCollection = selectedCollection;
this.showMissingCollectionPermissionMessage = showMissingCollectionPermissionMessage; this.showMissingCollectionPermissionMessage = showMissingCollectionPermissionMessage;
this.isEmpty = collections?.length === 0 && ciphers?.length === 0; this.isEmpty = collections?.length === 0 && ciphers?.length === 0;
// This is a temporary fix to avoid double fetching collections. // This is a temporary fix to avoid double fetching collections.