1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00
Files
browser/apps/web/src/app/vault/components/vault-items/vault-items.component.ts
SmithThe4th faf7e3d315 [PM-11201][CL-507] Add the ability to sort by Name, Group, and Permission within the collection and item tables (#11453)
* Added sorting to vault, name, permission and group

Added default sorting

* Fixed import

* reverted test on template

* Only add sorting functionality to admin console

* changed code order

* Fixed leftover test for sortingn

* Fixed reference

* sort permissions by ascending order

* Fixed bug where a collection had multiple groups and sorting alphbatically didn't happen correctly all the time

* Fixed bug whne creating a new cipher item

* Introduced fnFactory to create a sort function with direction provided

* Used new sort function to make collections always remain at the top and ciphers below

* extracted logic to always sort collections at the top

Added similar sorting to sortBygroup

* removed org vault check

* remove unused service

* Sort only collections

* Got rid of sortFn factory in favour of passing the direction as an optional parameter

* Removed tenary

* get cipher permissions

* Use all collections to filter collection ids

* Fixed ascending and descending issues

* Added functionality to default sort in descending order

* default sort permissions in descending order

* Refactored setActive to not pass direction as a paramater
2024-11-07 10:10:15 -05:00

523 lines
16 KiB
TypeScript

import { SelectionModel } from "@angular/cdk/collections";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { SortDirection, TableDataSource } from "@bitwarden/components";
import { GroupView } from "../../../admin-console/organizations/core";
import {
CollectionPermission,
convertToPermission,
} from "./../../../admin-console/organizations/shared/components/access-selector/access-selector.models";
import { VaultItem } from "./vault-item";
import { VaultItemEvent } from "./vault-item-event";
// Fixed manual row height required due to how cdk-virtual-scroll works
export const RowHeight = 65;
export const RowHeightClass = `tw-h-[65px]`;
const MaxSelectionCount = 500;
type ItemPermission = CollectionPermission | "NoAccess";
@Component({
selector: "app-vault-items",
templateUrl: "vault-items.component.html",
// TODO: Improve change detection, see: https://bitwarden.atlassian.net/browse/TDL-220
// changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VaultItemsComponent {
protected RowHeight = RowHeight;
@Input() disabled: boolean;
@Input() showOwner: boolean;
@Input() showCollections: boolean;
@Input() showGroups: boolean;
@Input() useEvents: boolean;
@Input() showPremiumFeatures: boolean;
@Input() showBulkMove: boolean;
@Input() showBulkTrashOptions: boolean;
// Encompasses functionality only available from the organization vault context
@Input() showAdminActions = false;
@Input() allOrganizations: Organization[] = [];
@Input() allCollections: CollectionView[] = [];
@Input() allGroups: GroupView[] = [];
@Input() showBulkEditCollectionAccess = false;
@Input() showBulkAddToCollections = false;
@Input() showPermissionsColumn = false;
@Input() viewingOrgVault: boolean;
@Input() addAccessStatus: number;
@Input() addAccessToggle: boolean;
@Input() activeCollection: CollectionView | undefined;
private _ciphers?: CipherView[] = [];
@Input() get ciphers(): CipherView[] {
return this._ciphers;
}
set ciphers(value: CipherView[] | undefined) {
this._ciphers = value ?? [];
this.refreshItems();
}
private _collections?: CollectionView[] = [];
@Input() get collections(): CollectionView[] {
return this._collections;
}
set collections(value: CollectionView[] | undefined) {
this._collections = value ?? [];
this.refreshItems();
}
@Output() onEvent = new EventEmitter<VaultItemEvent>();
protected editableItems: VaultItem[] = [];
protected dataSource = new TableDataSource<VaultItem>();
protected selection = new SelectionModel<VaultItem>(true, [], true);
get showExtraColumn() {
return this.showCollections || this.showGroups || this.showOwner;
}
get isAllSelected() {
return this.editableItems
.slice(0, MaxSelectionCount)
.every((item) => this.selection.isSelected(item));
}
get isEmpty() {
return this.dataSource.data.length === 0;
}
get bulkMoveAllowed() {
return (
this.showBulkMove && this.selection.selected.filter((item) => item.collection).length === 0
);
}
get showDelete(): boolean {
if (this.selection.selected.length === 0) {
return true;
}
const hasPersonalItems = this.hasPersonalItems();
const uniqueCipherOrgIds = this.getUniqueOrganizationIds();
const canManageCollectionCiphers = this.selection.selected
.filter((item) => item.cipher)
.every(({ cipher }) => this.canManageCollection(cipher));
const canDeleteCollections = this.selection.selected
.filter((item) => item.collection)
.every((item) => item.collection && this.canDeleteCollection(item.collection));
const userCanDeleteAccess = canManageCollectionCiphers && canDeleteCollections;
if (
userCanDeleteAccess ||
(hasPersonalItems && (!uniqueCipherOrgIds.size || userCanDeleteAccess))
) {
return true;
}
return false;
}
get disableMenu() {
return (
!this.bulkMoveAllowed &&
!this.showAssignToCollections() &&
!this.showDelete &&
!this.showBulkEditCollectionAccess
);
}
get bulkAssignToCollectionsAllowed() {
return this.showBulkAddToCollections && this.ciphers.length > 0;
}
protected canEditCollection(collection: CollectionView): boolean {
// Only allow allow deletion if collection editing is enabled and not deleting "Unassigned"
if (collection.id === Unassigned) {
return false;
}
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId);
return collection.canEdit(organization);
}
protected canDeleteCollection(collection: CollectionView): boolean {
// Only allow allow deletion if collection editing is enabled and not deleting "Unassigned"
if (collection.id === Unassigned) {
return false;
}
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId);
return collection.canDelete(organization);
}
protected canViewCollectionInfo(collection: CollectionView) {
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId);
return collection.canViewCollectionInfo(organization);
}
protected toggleAll() {
this.isAllSelected
? this.selection.clear()
: this.selection.select(...this.editableItems.slice(0, MaxSelectionCount));
}
protected event(event: VaultItemEvent) {
this.onEvent.emit(event);
}
protected bulkMoveToFolder() {
this.event({
type: "moveToFolder",
items: this.selection.selected
.filter((item) => item.cipher !== undefined)
.map((item) => item.cipher),
});
}
protected bulkRestore() {
this.event({
type: "restore",
items: this.selection.selected
.filter((item) => item.cipher !== undefined)
.map((item) => item.cipher),
});
}
protected bulkDelete() {
this.event({
type: "delete",
items: this.selection.selected,
});
}
// TODO: PM-13944 Refactor to use cipherAuthorizationService.canClone$ instead
protected canClone(vaultItem: VaultItem) {
if (vaultItem.cipher.organizationId == null) {
return true;
}
const org = this.allOrganizations.find((o) => o.id === vaultItem.cipher.organizationId);
// Admins and custom users can always clone in the Org Vault
if (this.viewingOrgVault && (org.isAdmin || org.permissions.editAnyCollection)) {
return true;
}
// Check if the cipher belongs to a collection with canManage permission
const orgCollections = this.allCollections.filter((c) => c.organizationId === org.id);
for (const collection of orgCollections) {
if (vaultItem.cipher.collectionIds.includes(collection.id) && collection.manage) {
return true;
}
}
return false;
}
protected canEditCipher(cipher: CipherView) {
if (cipher.organizationId == null) {
return true;
}
const organization = this.allOrganizations.find((o) => o.id === cipher.organizationId);
return (organization.canEditAllCiphers && this.viewingOrgVault) || cipher.edit;
}
protected canManageCollection(cipher: CipherView) {
// If the cipher is not part of an organization (personal item), user can manage it
if (cipher.organizationId == null) {
return true;
}
// Check for admin access in AC vault
if (this.showAdminActions) {
const organization = this.allOrganizations.find((o) => o.id === cipher.organizationId);
// If the user is an admin, they can delete an unassigned cipher
if (cipher.collectionIds.length === 0) {
return organization?.canEditUnmanagedCollections === true;
}
if (
organization?.permissions.editAnyCollection ||
(organization?.allowAdminAccessToAllCollectionItems && organization.isAdmin)
) {
return true;
}
}
if (this.activeCollection) {
return this.activeCollection.manage === true;
}
return this.allCollections
.filter((c) => cipher.collectionIds.includes(c.id))
.some((collection) => collection.manage);
}
private refreshItems() {
const collections: VaultItem[] = this.collections.map((collection) => ({ collection }));
const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ cipher }));
const items: VaultItem[] = [].concat(collections).concat(ciphers);
this.selection.clear();
// All ciphers are selectable, collections only if they can be edited or deleted
this.editableItems = items.filter(
(item) =>
item.cipher !== undefined ||
(item.collection !== undefined &&
(this.canEditCollection(item.collection) || this.canDeleteCollection(item.collection))),
);
this.dataSource.data = items;
}
protected bulkEditCollectionAccess() {
this.event({
type: "bulkEditCollectionAccess",
items: this.selection.selected
.filter((item) => item.collection !== undefined)
.map((item) => item.collection),
});
}
protected assignToCollections() {
this.event({
type: "assignToCollections",
items: this.selection.selected
.filter((item) => item.cipher !== undefined)
.map((item) => item.cipher),
});
}
protected showAssignToCollections(): boolean {
if (!this.showBulkMove) {
return false;
}
// When the user doesn't belong to an organization, hide assign to collections
if (this.allOrganizations.length === 0) {
return false;
}
if (this.selection.selected.length === 0) {
return true;
}
const hasPersonalItems = this.hasPersonalItems();
const uniqueCipherOrgIds = this.getUniqueOrganizationIds();
// Return false if items are from different organizations
if (uniqueCipherOrgIds.size > 1) {
return false;
}
// If all items are personal, return based on personal items
if (uniqueCipherOrgIds.size === 0) {
return hasPersonalItems;
}
const [orgId] = uniqueCipherOrgIds;
const organization = this.allOrganizations.find((o) => o.id === orgId);
const canEditOrManageAllCiphers = organization?.canEditAllCiphers && this.viewingOrgVault;
const collectionNotSelected =
this.selection.selected.filter((item) => item.collection).length === 0;
return (canEditOrManageAllCiphers || this.allCiphersHaveEditAccess()) && collectionNotSelected;
}
/**
* Sorts VaultItems, grouping collections before ciphers, and sorting each group alphabetically by name.
*/
protected sortByName = (a: VaultItem, b: VaultItem, direction: SortDirection) => {
// Collections before ciphers
const collectionCompare = this.prioritizeCollections(a, b, direction);
if (collectionCompare !== 0) {
return collectionCompare;
}
return this.compareNames(a, b);
};
/**
* Sorts VaultItems based on group names
*/
protected sortByGroups = (a: VaultItem, b: VaultItem, direction: SortDirection) => {
if (
!(a.collection instanceof CollectionAdminView) &&
!(b.collection instanceof CollectionAdminView)
) {
return 0;
}
const getFirstGroupName = (collection: CollectionAdminView): string => {
if (collection.groups.length > 0) {
return collection.groups.map((group) => this.getGroupName(group.id) || "").sort()[0];
}
return null;
};
// Collections before ciphers
const collectionCompare = this.prioritizeCollections(a, b, direction);
if (collectionCompare !== 0) {
return collectionCompare;
}
const aGroupName = getFirstGroupName(a.collection as CollectionAdminView);
const bGroupName = getFirstGroupName(b.collection as CollectionAdminView);
// Collections with groups come before collections without groups.
// If a collection has no groups, getFirstGroupName returns null.
if (aGroupName === null) {
return 1;
}
if (bGroupName === null) {
return -1;
}
return aGroupName.localeCompare(bGroupName);
};
/**
* Sorts VaultItems based on their permissions, with higher permissions taking precedence.
* If permissions are equal, it falls back to sorting by name.
*/
protected sortByPermissions = (a: VaultItem, b: VaultItem, direction: SortDirection) => {
const getPermissionPriority = (item: VaultItem): number => {
const permission = item.collection
? this.getCollectionPermission(item.collection)
: this.getCipherPermission(item.cipher);
const priorityMap = {
[CollectionPermission.Manage]: 5,
[CollectionPermission.Edit]: 4,
[CollectionPermission.EditExceptPass]: 3,
[CollectionPermission.View]: 2,
[CollectionPermission.ViewExceptPass]: 1,
NoAccess: 0,
};
return priorityMap[permission] ?? -1;
};
// Collections before ciphers
const collectionCompare = this.prioritizeCollections(a, b, direction);
if (collectionCompare !== 0) {
return collectionCompare;
}
const priorityA = getPermissionPriority(a);
const priorityB = getPermissionPriority(b);
// Higher priority first
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
return this.compareNames(a, b);
};
private compareNames(a: VaultItem, b: VaultItem): number {
const getName = (item: VaultItem) => item.collection?.name || item.cipher?.name;
return getName(a).localeCompare(getName(b));
}
/**
* Sorts VaultItems by prioritizing collections over ciphers.
* Collections are always placed before ciphers, regardless of the sorting direction.
*/
private prioritizeCollections(a: VaultItem, b: VaultItem, direction: SortDirection): number {
if (a.collection && !b.collection) {
return direction === "asc" ? -1 : 1;
}
if (!a.collection && b.collection) {
return direction === "asc" ? 1 : -1;
}
return 0;
}
private hasPersonalItems(): boolean {
return this.selection.selected.some(({ cipher }) => cipher?.organizationId === null);
}
private allCiphersHaveEditAccess(): boolean {
return this.selection.selected
.filter(({ cipher }) => cipher)
.every(({ cipher }) => cipher?.edit);
}
private getUniqueOrganizationIds(): Set<string> {
return new Set(this.selection.selected.flatMap((i) => i.cipher?.organizationId ?? []));
}
private getGroupName(groupId: string): string | undefined {
return this.allGroups.find((g) => g.id === groupId)?.name;
}
private getCollectionPermission(collection: CollectionView): ItemPermission {
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId);
if (collection.id == Unassigned && organization?.canEditUnassignedCiphers) {
return CollectionPermission.Edit;
}
if (collection.assigned) {
return convertToPermission(collection);
}
return "NoAccess";
}
private getCipherPermission(cipher: CipherView): ItemPermission {
if (!cipher.organizationId || cipher.collectionIds.length === 0) {
return CollectionPermission.Manage;
}
const filteredCollections = this.allCollections?.filter((collection) => {
if (collection.assigned) {
return cipher.collectionIds.find((id) => {
if (collection.id === id) {
return collection;
}
});
}
});
if (filteredCollections?.length === 1) {
return convertToPermission(filteredCollections[0]);
}
if (filteredCollections?.length > 0) {
const permissions = filteredCollections.map((collection) => convertToPermission(collection));
const orderedPermissions = [
CollectionPermission.Manage,
CollectionPermission.Edit,
CollectionPermission.EditExceptPass,
CollectionPermission.View,
CollectionPermission.ViewExceptPass,
];
return orderedPermissions.find((perm) => permissions.includes(perm));
}
return "NoAccess";
}
}