diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html new file mode 100644 index 0000000000..97b3875a47 --- /dev/null +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -0,0 +1,147 @@ + + + + + + + +
+ + + + {{ "attachments" | i18n }} + + + {{ "attachmentsNeedFix" | i18n }} + + +
+
+ {{ cipher.subTitle }} + + + + + + + + + + + + + + + + + + + {{ "launch" | i18n }} + + + + + + + + + + + diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts new file mode 100644 index 0000000000..abed3b320b --- /dev/null +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts @@ -0,0 +1,94 @@ +import { Component, EventEmitter, HostBinding, HostListener, Input, Output } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { VaultItemEvent } from "./vault-item-event"; +import { RowHeightClass } from "./vault-items.component"; + +@Component({ + selector: "tr[appVaultCipherRow]", + templateUrl: "vault-cipher-row.component.html", +}) +export class VaultCipherRowComponent { + protected RowHeightClass = RowHeightClass; + + @Input() disabled: boolean; + @Input() cipher: CipherView; + @Input() showOwner: boolean; + @Input() showCollections: boolean; + @Input() showGroups: boolean; + @Input() showPremiumFeatures: boolean; + @Input() useEvents: boolean; + @Input() cloneable: boolean; + @Input() organizations: Organization[]; + @Input() collections: CollectionView[]; + + @Output() onEvent = new EventEmitter(); + + @Input() checked: boolean; + @Output() checkedToggled = new EventEmitter(); + + protected CipherType = CipherType; + + constructor(private router: Router, private activatedRoute: ActivatedRoute) {} + + @HostBinding("class") + get classes() { + return [].concat(this.disabled ? [] : ["tw-cursor-pointer"]); + } + + protected get showTotpCopyButton() { + return ( + (this.cipher.login?.hasTotp ?? false) && + (this.cipher.organizationUseTotp || this.showPremiumFeatures) + ); + } + + protected get showFixOldAttachments() { + return this.cipher.hasOldAttachments && this.cipher.organizationId == null; + } + + @HostListener("click") + protected click() { + this.router.navigate([], { + queryParams: { cipherId: this.cipher.id }, + queryParamsHandling: "merge", + }); + } + + protected copy(field: "username" | "password" | "totp") { + this.onEvent.emit({ type: "copyField", item: this.cipher, field }); + } + + protected clone() { + this.onEvent.emit({ type: "clone", item: this.cipher }); + } + + protected moveToOrganization() { + this.onEvent.emit({ type: "moveToOrganization", items: [this.cipher] }); + } + + protected editCollections() { + this.onEvent.emit({ type: "viewCollections", item: this.cipher }); + } + + protected events() { + this.onEvent.emit({ type: "viewEvents", item: this.cipher }); + } + + protected restore() { + this.onEvent.emit({ type: "restore", items: [this.cipher] }); + } + + protected deleteCipher() { + this.onEvent.emit({ type: "delete", items: [{ cipher: this.cipher }] }); + } + + protected attachments() { + this.onEvent.emit({ type: "viewAttachments", item: this.cipher }); + } +} diff --git a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.html new file mode 100644 index 0000000000..6a4a17db6d --- /dev/null +++ b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.html @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts new file mode 100644 index 0000000000..085d465350 --- /dev/null +++ b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts @@ -0,0 +1,72 @@ +import { Component, EventEmitter, HostBinding, HostListener, Input, Output } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view"; + +import { CollectionAdminView, GroupView } from "../../../admin-console/organizations/core"; + +import { VaultItemEvent } from "./vault-item-event"; +import { RowHeightClass } from "./vault-items.component"; + +@Component({ + selector: "tr[appVaultCollectionRow]", + templateUrl: "vault-collection-row.component.html", +}) +export class VaultCollectionRowComponent { + protected RowHeightClass = RowHeightClass; + + @Input() disabled: boolean; + @Input() collection: CollectionView; + @Input() showOwner: boolean; + @Input() showCollections: boolean; + @Input() showGroups: boolean; + @Input() canEditCollection: boolean; + @Input() canDeleteCollection: boolean; + @Input() organizations: Organization[]; + @Input() groups: GroupView[]; + + @Output() onEvent = new EventEmitter(); + + @Input() checked: boolean; + @Output() checkedToggled = new EventEmitter(); + + constructor(private router: Router, private activatedRoute: ActivatedRoute) {} + + @HostBinding("class") + get classes() { + return [].concat(this.disabled ? [] : ["tw-cursor-pointer"]); + } + + get collectionGroups() { + if (!(this.collection instanceof CollectionAdminView)) { + return []; + } + + return this.collection.groups; + } + + get organization() { + return this.organizations.find((o) => o.id === this.collection.organizationId); + } + + @HostListener("click") + protected click() { + this.router.navigate([], { + queryParams: { collectionId: this.collection.id }, + queryParamsHandling: "merge", + }); + } + + protected edit() { + this.onEvent.next({ type: "edit", item: this.collection }); + } + + protected access() { + this.onEvent.next({ type: "viewAccess", item: this.collection }); + } + + protected deleteCollection() { + this.onEvent.next({ type: "delete", items: [{ collection: this.collection }] }); + } +} diff --git a/apps/web/src/app/vault/components/vault-items/vault-item-event.ts b/apps/web/src/app/vault/components/vault-items/vault-item-event.ts new file mode 100644 index 0000000000..2643fcd7b1 --- /dev/null +++ b/apps/web/src/app/vault/components/vault-items/vault-item-event.ts @@ -0,0 +1,17 @@ +import { CollectionView } from "@bitwarden/common/src/admin-console/models/view/collection.view"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { VaultItem } from "./vault-item"; + +export type VaultItemEvent = + | { type: "viewAttachments"; item: CipherView } + | { type: "viewCollections"; item: CipherView } + | { type: "viewAccess"; item: CollectionView } + | { type: "viewEvents"; item: CipherView } + | { type: "edit"; item: CollectionView } + | { type: "clone"; item: CipherView } + | { type: "restore"; items: CipherView[] } + | { type: "delete"; items: VaultItem[] } + | { type: "copyField"; item: CipherView; field: "username" | "password" | "totp" } + | { type: "moveToFolder"; items: CipherView[] } + | { type: "moveToOrganization"; items: CipherView[] }; diff --git a/apps/web/src/app/vault/components/vault-items/vault-item.ts b/apps/web/src/app/vault/components/vault-items/vault-item.ts new file mode 100644 index 0000000000..c8c91becdb --- /dev/null +++ b/apps/web/src/app/vault/components/vault-items/vault-item.ts @@ -0,0 +1,7 @@ +import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +export interface VaultItem { + collection?: CollectionView; + cipher?: CipherView; +} diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html new file mode 100644 index 0000000000..7179a0dfc8 --- /dev/null +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html @@ -0,0 +1,105 @@ + + + + + + + + + {{ "name" | i18n }} + {{ "owner" | i18n }} + {{ "collections" | i18n }} + {{ "groups" | i18n }} + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts new file mode 100644 index 0000000000..4fcf043992 --- /dev/null +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -0,0 +1,179 @@ +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 { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { TableDataSource } from "@bitwarden/components"; + +import { CollectionAdminView, GroupView } from "../../../admin-console/organizations/core"; +import { Unassigned } from "../../individual-vault/vault-filter/shared/models/routed-vault-filter.model"; + +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; + +@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() editableCollections: boolean; + @Input() cloneableOrganizationCiphers: boolean; + @Input() showPremiumFeatures: boolean; + @Input() showBulkMove: boolean; + @Input() showBulkTrashOptions: boolean; + @Input() allOrganizations: Organization[] = []; + @Input() allCollections: CollectionView[] = []; + @Input() allGroups: GroupView[] = []; + + 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(); + + protected editableItems: VaultItem[] = []; + protected dataSource = new TableDataSource(); + protected selection = new SelectionModel(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; + } + + protected canEditCollection(collection: CollectionView): boolean { + // We currently don't support editing collections from individual vault + if (!(collection instanceof CollectionAdminView)) { + return false; + } + + // Only allow allow deletion if collection editing is enabled and not deleting "Unassigned" + if (!this.editableCollections || collection.id === Unassigned) { + return false; + } + + const organization = this.allOrganizations.find((o) => o.id === collection.organizationId); + + // Otherwise, check if we can edit the specified collection + return ( + organization?.canEditAnyCollection || + (organization?.canEditAssignedCollections && collection.assigned) + ); + } + + protected canDeleteCollection(collection: CollectionView): boolean { + // We currently don't support editing collections from individual vault + if (!(collection instanceof CollectionAdminView)) { + return false; + } + + // Only allow allow deletion if collection editing is enabled and not deleting "Unassigned" + if (!this.editableCollections || collection.id === Unassigned) { + return false; + } + + const organization = this.allOrganizations.find((o) => o.id === collection.organizationId); + + // Otherwise, check if we can delete the specified collection + return ( + organization?.canDeleteAnyCollection || + (organization?.canDeleteAssignedCollections && collection.assigned) + ); + } + + 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 bulkMoveToOrganization() { + this.event({ + type: "moveToOrganization", + 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, + }); + } + + 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(); + this.editableItems = items.filter( + (item) => + item.cipher !== undefined || + (item.collection !== undefined && this.canDeleteCollection(item.collection)) + ); + this.dataSource.data = items; + } +} diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.module.ts b/apps/web/src/app/vault/components/vault-items/vault-items.module.ts new file mode 100644 index 0000000000..ac0d0fb194 --- /dev/null +++ b/apps/web/src/app/vault/components/vault-items/vault-items.module.ts @@ -0,0 +1,33 @@ +import { ScrollingModule } from "@angular/cdk/scrolling"; +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { RouterModule } from "@angular/router"; + +import { TableModule } from "@bitwarden/components"; + +import { SharedModule } from "../../../shared/shared.module"; +import { OrganizationBadgeModule } from "../../individual-vault/organization-badge/organization-badge.module"; +import { PipesModule } from "../../individual-vault/pipes/pipes.module"; +import { CollectionBadgeModule } from "../../org-vault/collection-badge/collection-badge.module"; +import { GroupBadgeModule } from "../../org-vault/group-badge/group-badge.module"; + +import { VaultCipherRowComponent } from "./vault-cipher-row.component"; +import { VaultCollectionRowComponent } from "./vault-collection-row.component"; +import { VaultItemsComponent } from "./vault-items.component"; + +@NgModule({ + imports: [ + CommonModule, + RouterModule, + ScrollingModule, + SharedModule, + TableModule, + OrganizationBadgeModule, + CollectionBadgeModule, + GroupBadgeModule, + PipesModule, + ], + declarations: [VaultItemsComponent, VaultCipherRowComponent, VaultCollectionRowComponent], + exports: [VaultItemsComponent], +}) +export class VaultItemsModule {} diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts new file mode 100644 index 0000000000..5ce71b1eeb --- /dev/null +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -0,0 +1,316 @@ +import { Component } from "@angular/core"; +import { RouterModule } from "@angular/router"; +import { Meta, moduleMetadata, Story } from "@storybook/angular"; +import { BehaviorSubject } from "rxjs"; + +import { AvatarUpdateService } from "@bitwarden/common/abstractions/account/avatar-update.service"; +import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; +import { StateService } from "@bitwarden/common/abstractions/state.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 { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; +import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; +import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; + +import { + CollectionAccessSelectionView, + CollectionAdminView, + GroupView, +} from "../../../admin-console/organizations/core"; +import { PreloadedEnglishI18nModule } from "../../../tests/preloaded-english-i18n.module"; +import { Unassigned } from "../../individual-vault/vault-filter/shared/models/routed-vault-filter.model"; + +import { VaultItemsComponent } from "./vault-items.component"; +import { VaultItemsModule } from "./vault-items.module"; + +@Component({ + template: "", +}) +class EmptyComponent {} + +const organizations = [...new Array(3).keys()].map(createOrganization); +const groups = [...Array(3).keys()].map(createGroupView); +const collections = [...Array(5).keys()].map(createCollectionView); +const ciphers = [...Array(50).keys()].map((i) => createCipherView(i)); +const deletedCiphers = [...Array(15).keys()].map((i) => createCipherView(i, true)); +const organizationOnlyCiphers = ciphers.filter((c) => c.organizationId != undefined); +const deletedOrganizationOnlyCiphers = deletedCiphers.filter((c) => c.organizationId != undefined); + +export default { + title: "Web/Vault/Items", + component: VaultItemsComponent, + decorators: [ + moduleMetadata({ + imports: [ + VaultItemsModule, + PreloadedEnglishI18nModule, + RouterModule.forRoot([{ path: "**", component: EmptyComponent }], { useHash: true }), + ], + providers: [ + { + provide: EnvironmentService, + useValue: { + getIconsUrl() { + return ""; + }, + } as Partial, + }, + { + provide: StateService, + useValue: { + activeAccount$: new BehaviorSubject("1").asObservable(), + accounts$: new BehaviorSubject({ "1": { profile: { name: "Foo" } } }).asObservable(), + async getDisableFavicon() { + return false; + }, + } as Partial, + }, + { + provide: AvatarUpdateService, + useValue: { + async loadColorFromState() { + return "#FF0000"; + }, + } as Partial, + }, + { + provide: TokenService, + useValue: { + async getUserId() { + return "user-id"; + }, + async getName() { + return "name"; + }, + async getEmail() { + return "email"; + }, + } as Partial, + }, + ], + }), + ], + args: { + disabled: false, + allCollections: collections, + allGroups: groups, + allOrganizations: organizations, + }, + argTypes: { onEvent: { action: "onEvent" } }, +} as Meta; + +const Template: Story = (args: VaultItemsComponent) => ({ + props: args, +}); + +export const Individual = Template.bind({}); +Individual.args = { + ciphers, + collections: [], + showOwner: true, + showCollections: false, + showGroups: false, + showPremiumFeatures: true, + showBulkMove: true, + showBulkTrashOptions: false, + useEvents: false, + editableCollections: false, + cloneableOrganizationCiphers: false, +}; + +export const IndividualDisabled = Template.bind({}); +IndividualDisabled.args = { + ciphers, + collections: [], + disabled: true, + showOwner: true, + showCollections: false, + showGroups: false, + showPremiumFeatures: true, + showBulkMove: true, + showBulkTrashOptions: false, + useEvents: false, + editableCollections: false, + cloneableOrganizationCiphers: false, +}; + +export const IndividualTrash = Template.bind({}); +IndividualTrash.args = { + ciphers: deletedCiphers, + collections: [], + showOwner: true, + showCollections: false, + showGroups: false, + showPremiumFeatures: true, + showBulkMove: false, + showBulkTrashOptions: true, + useEvents: false, + editableCollections: false, + cloneableOrganizationCiphers: false, +}; + +export const IndividualTopLevelCollection = Template.bind({}); +IndividualTopLevelCollection.args = { + ciphers: [], + collections, + showOwner: true, + showCollections: false, + showGroups: false, + showPremiumFeatures: true, + showBulkMove: false, + showBulkTrashOptions: false, + useEvents: false, + editableCollections: false, + cloneableOrganizationCiphers: false, +}; + +export const IndividualSecondLevelCollection = Template.bind({}); +IndividualSecondLevelCollection.args = { + ciphers, + collections, + showOwner: true, + showCollections: false, + showGroups: false, + showPremiumFeatures: true, + showBulkMove: true, + showBulkTrashOptions: false, + useEvents: false, + editableCollections: false, + cloneableOrganizationCiphers: false, +}; + +export const OrganizationVault = Template.bind({}); +OrganizationVault.args = { + ciphers: organizationOnlyCiphers, + collections: [], + showOwner: false, + showCollections: true, + showGroups: false, + showPremiumFeatures: true, + showBulkMove: false, + showBulkTrashOptions: false, + useEvents: true, + editableCollections: true, + cloneableOrganizationCiphers: true, +}; + +export const OrganizationTrash = Template.bind({}); +OrganizationTrash.args = { + ciphers: deletedOrganizationOnlyCiphers, + collections: [], + showOwner: false, + showCollections: true, + showGroups: false, + showPremiumFeatures: true, + showBulkMove: false, + showBulkTrashOptions: true, + useEvents: true, + editableCollections: true, + cloneableOrganizationCiphers: true, +}; + +const unassignedCollection = new CollectionAdminView(); +unassignedCollection.id = Unassigned; +unassignedCollection.name = "Unassigned"; +export const OrganizationTopLevelCollection = Template.bind({}); +OrganizationTopLevelCollection.args = { + ciphers: [], + collections: collections.concat(unassignedCollection), + showOwner: false, + showCollections: false, + showGroups: true, + showPremiumFeatures: true, + showBulkMove: false, + showBulkTrashOptions: false, + useEvents: true, + editableCollections: true, + cloneableOrganizationCiphers: true, +}; + +export const OrganizationSecondLevelCollection = Template.bind({}); +OrganizationSecondLevelCollection.args = { + ciphers: organizationOnlyCiphers, + collections, + showOwner: false, + showCollections: false, + showGroups: true, + showPremiumFeatures: true, + showBulkMove: false, + showBulkTrashOptions: false, + useEvents: true, + editableCollections: true, + cloneableOrganizationCiphers: true, +}; + +function createCipherView(i: number, deleted = false): CipherView { + const organization = organizations[i % (organizations.length + 1)]; + const collection = collections[i % (collections.length + 1)]; + const view = new CipherView(); + view.id = `cipher-${i}`; + view.name = `Vault item ${i}`; + view.type = CipherType.Login; + view.organizationId = organization?.id; + view.deletedDate = deleted ? new Date() : undefined; + view.login = new LoginView(); + view.login.username = i % 10 === 0 ? undefined : `username-${i}`; + view.login.totp = i % 2 === 0 ? "I65VU7K5ZQL7WB4E" : undefined; + view.login.uris = [new LoginUriView()]; + view.login.uris[0].uri = "https://bitwarden.com"; + view.collectionIds = collection ? [collection.id] : []; + + if (i === 0) { + // Old attachment + const attachement = new AttachmentView(); + view.organizationId = null; + view.collectionIds = []; + view.attachments = [attachement]; + } else if (i % 5 === 0) { + const attachement = new AttachmentView(); + attachement.key = new SymmetricCryptoKey(new ArrayBuffer(32)); + view.attachments = [attachement]; + } + + return view; +} + +function createCollectionView(i: number): CollectionAdminView { + const organization = organizations[i % (organizations.length + 1)]; + const group = groups[i % (groups.length + 1)]; + const view = new CollectionAdminView(); + view.id = `collection-${i}`; + view.name = `Collection ${i}`; + view.organizationId = organization?.id; + + if (group !== undefined) { + view.groups = [ + new CollectionAccessSelectionView({ + id: group.id, + hidePasswords: false, + readOnly: false, + }), + ]; + } + + return view; +} + +function createGroupView(i: number): GroupView { + const organization = organizations[i % organizations.length]; + const view = new GroupView(); + view.id = `group-${i}`; + view.name = `Group ${i}`; + view.organizationId = organization.id; + return view; +} + +function createOrganization(i: number): Organization { + const organization = new Organization(); + organization.id = `organization-${i}`; + organization.name = `Organization ${i}`; + organization.type = OrganizationUserType.Owner; + return organization; +} diff --git a/apps/web/src/app/vault/individual-vault/organization-badge/organization-badge.module.ts b/apps/web/src/app/vault/individual-vault/organization-badge/organization-badge.module.ts index 8806974060..e585d165d4 100644 --- a/apps/web/src/app/vault/individual-vault/organization-badge/organization-badge.module.ts +++ b/apps/web/src/app/vault/individual-vault/organization-badge/organization-badge.module.ts @@ -1,6 +1,6 @@ import { NgModule } from "@angular/core"; -import { SharedModule } from "../../../shared"; +import { SharedModule } from "../../../shared/shared.module"; import { OrganizationNameBadgeComponent } from "./organization-name-badge.component"; diff --git a/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.html b/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.html index d19df04853..a4745c0720 100644 --- a/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.html +++ b/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.html @@ -1,11 +1,13 @@ - - diff --git a/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.ts b/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.ts index faaf01b309..702aff8246 100644 --- a/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.ts +++ b/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.ts @@ -1,20 +1,23 @@ -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Component, Input, OnChanges } from "@angular/core"; import { AvatarUpdateService } from "@bitwarden/common/abstractions/account/avatar-update.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { Utils } from "@bitwarden/common/misc/utils"; +import { Unassigned } from "../vault-filter/shared/models/routed-vault-filter.model"; + @Component({ selector: "app-org-badge", templateUrl: "organization-name-badge.component.html", }) -export class OrganizationNameBadgeComponent implements OnInit { +export class OrganizationNameBadgeComponent implements OnChanges { + @Input() organizationId?: string; @Input() organizationName: string; - @Input() profileName: string; - - @Output() onOrganizationClicked = new EventEmitter(); + @Input() disabled: boolean; + // Need a separate variable or we get weird behavior when used as part of cdk virtual scrolling + name: string; color: string; textColor: string; isMe: boolean; @@ -25,12 +28,13 @@ export class OrganizationNameBadgeComponent implements OnInit { private tokenService: TokenService ) {} - async ngOnInit(): Promise { - if (this.organizationName == null || this.organizationName === "") { - this.organizationName = this.i18nService.t("me"); - this.isMe = true; - } + // ngOnChanges is required since this component might be reused as part of + // cdk virtual scrolling + async ngOnChanges() { + this.isMe = this.organizationName == null || this.organizationName === ""; + if (this.isMe) { + this.name = this.i18nService.t("me"); this.color = await this.avatarService.loadColorFromState(); if (this.color == null) { const userId = await this.tokenService.getUserId(); @@ -43,12 +47,13 @@ export class OrganizationNameBadgeComponent implements OnInit { } } } else { + this.name = this.organizationName; this.color = Utils.stringToColor(this.organizationName.toUpperCase()); } this.textColor = Utils.pickTextColorBasedOnBgColor(this.color, 135, true) + "!important"; } - emitOnOrganizationClicked() { - this.onOrganizationClicked.emit(); + get organizationIdLink() { + return this.organizationId ?? Unassigned; } } diff --git a/apps/web/src/app/vault/individual-vault/pipes/get-organization-name.pipe.ts b/apps/web/src/app/vault/individual-vault/pipes/get-organization-name.pipe.ts index 0ec8ae5afb..4d6c0b7d8d 100644 --- a/apps/web/src/app/vault/individual-vault/pipes/get-organization-name.pipe.ts +++ b/apps/web/src/app/vault/individual-vault/pipes/get-organization-name.pipe.ts @@ -8,7 +8,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga }) export class GetOrgNameFromIdPipe implements PipeTransform { transform(value: string, organizations: Organization[]) { - const orgName = organizations.find((o) => o.id === value)?.name; + const orgName = organizations?.find((o) => o.id === value)?.name; return orgName; } } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/abstractions/vault-filter.service.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/abstractions/vault-filter.service.ts index 23b7541c67..3a03a338e3 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/abstractions/vault-filter.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/abstractions/vault-filter.service.ts @@ -20,7 +20,7 @@ export abstract class VaultFilterService { folderTree$: Observable>; collectionTree$: Observable>; cipherTypeTree$: Observable>; - reloadCollections: () => Promise; + reloadCollections: (collections: CollectionView[]) => void; getCollectionNodeFromTree: (id: string) => Promise>; setCollapsedFilterNodes: (collapsedFilterNodes: Set) => Promise; expandOrgFilter: () => Promise; diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service.ts index a532ecff78..cce5a5e24a 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service.ts @@ -151,7 +151,12 @@ function createLegacyFilterForEndUser( legacyFilter.selectedFolderNode = ServiceUtils.getTreeNodeObject(folderTree, filter.folderId); } - if (filter.organizationId !== undefined) { + if (filter.organizationId !== undefined && filter.organizationId === Unassigned) { + legacyFilter.selectedOrganizationNode = ServiceUtils.getTreeNodeObject( + organizationTree, + "MyVault" + ); + } else if (filter.organizationId !== undefined && filter.organizationId !== Unassigned) { legacyFilter.selectedOrganizationNode = ServiceUtils.getTreeNodeObject( organizationTree, filter.organizationId diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts index 45fa73ed26..c56d780f06 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts @@ -3,7 +3,6 @@ import { firstValueFrom, ReplaySubject, take } from "rxjs"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; @@ -23,7 +22,6 @@ describe("vault filter service", () => { let organizationService: MockProxy; let folderService: MockProxy; let cipherService: MockProxy; - let collectionService: MockProxy; let policyService: MockProxy; let i18nService: MockProxy; let organizations: ReplaySubject; @@ -34,7 +32,6 @@ describe("vault filter service", () => { organizationService = mock(); folderService = mock(); cipherService = mock(); - collectionService = mock(); policyService = mock(); i18nService = mock(); i18nService.collator = new Intl.Collator("en-US"); @@ -50,7 +47,6 @@ describe("vault filter service", () => { organizationService, folderService, cipherService, - collectionService, policyService, i18nService ); @@ -177,8 +173,7 @@ describe("vault filter service", () => { createCollectionView("1", "collection 1", "org test id"), createCollectionView("2", "collection 2", "non matching org id"), ]; - collectionService.getAllDecrypted.mockResolvedValue(storedCollections); - vaultFilterService.reloadCollections(); + vaultFilterService.reloadCollections(storedCollections); await expect(firstValueFrom(vaultFilterService.filteredCollections$)).resolves.toEqual([ createCollectionView("1", "collection 1", "org test id"), @@ -193,8 +188,7 @@ describe("vault filter service", () => { createCollectionView("id-2", "Collection 1/Collection 2", "org test id"), createCollectionView("id-3", "Collection 1/Collection 3", "org test id"), ]; - collectionService.getAllDecrypted.mockResolvedValue(storedCollections); - vaultFilterService.reloadCollections(); + vaultFilterService.reloadCollections(storedCollections); const result = await firstValueFrom(vaultFilterService.collectionTree$); @@ -207,8 +201,7 @@ describe("vault filter service", () => { createCollectionView("id-1", "Collection 1", "org test id"), createCollectionView("id-3", "Collection 1/Collection 2/Collection 3", "org test id"), ]; - collectionService.getAllDecrypted.mockResolvedValue(storedCollections); - vaultFilterService.reloadCollections(); + vaultFilterService.reloadCollections(storedCollections); const result = await firstValueFrom(vaultFilterService.collectionTree$); @@ -224,8 +217,7 @@ describe("vault filter service", () => { createCollectionView("id-3", "Collection 1/Collection 2/Collection 3", "org test id"), createCollectionView("id-4", "Collection 1/Collection 4", "org test id"), ]; - collectionService.getAllDecrypted.mockResolvedValue(storedCollections); - vaultFilterService.reloadCollections(); + vaultFilterService.reloadCollections(storedCollections); const result = await firstValueFrom(vaultFilterService.collectionTree$); @@ -243,8 +235,7 @@ describe("vault filter service", () => { createCollectionView("id-1", "Collection 1", "org test id"), createCollectionView("id-3", "Collection 1/Collection 2/Collection 3", "org test id"), ]; - collectionService.getAllDecrypted.mockResolvedValue(storedCollections); - vaultFilterService.reloadCollections(); + vaultFilterService.reloadCollections(storedCollections); const result = await firstValueFrom(vaultFilterService.collectionTree$); diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts index ab0b63b669..e4f605caf8 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts @@ -1,6 +1,7 @@ import { Injectable } from "@angular/core"; import { BehaviorSubject, + combineLatest, combineLatestWith, firstValueFrom, map, @@ -12,7 +13,6 @@ import { import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service"; import { isNotProviderUser, OrganizationService, @@ -67,8 +67,10 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { // TODO: Remove once collections is refactored with observables // replace with collection service observable private collectionViews$ = new ReplaySubject(1); - filteredCollections$: Observable = this.collectionViews$.pipe( - combineLatestWith(this._organizationFilter), + filteredCollections$: Observable = combineLatest([ + this.collectionViews$, + this._organizationFilter, + ]).pipe( switchMap(([collections, org]) => { return this.filterCollections(collections, org); }) @@ -84,14 +86,12 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { protected organizationService: OrganizationService, protected folderService: FolderService, protected cipherService: CipherService, - protected collectionService: CollectionService, protected policyService: PolicyService, protected i18nService: I18nService ) {} - // TODO: Remove once collections is refactored with observables - async reloadCollections() { - this.collectionViews$.next(await this.collectionService.getAllDecrypted()); + async reloadCollections(collections: CollectionView[]) { + this.collectionViews$.next(collections); } async getCollectionNodeFromTree(id: string) { diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts new file mode 100644 index 0000000000..adb0d5d4f8 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts @@ -0,0 +1,227 @@ +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { createFilterFunction } from "./filter-function"; +import { Unassigned, All } from "./routed-vault-filter.model"; + +describe("createFilter", () => { + describe("given a generic cipher", () => { + it("should return true when no filter is applied", () => { + const cipher = createCipher(); + const filterFunction = createFilterFunction({}); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + }); + + describe("given a favorite cipher", () => { + const cipher = createCipher({ favorite: true }); + + it("should return true when filtering for favorites", () => { + const filterFunction = createFilterFunction({ type: "favorites" }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + + it("should return false when filtering for trash", () => { + const filterFunction = createFilterFunction({ type: "trash" }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + }); + + describe("given a deleted cipher", () => { + const cipher = createCipher({ deletedDate: new Date() }); + + it("should return true when filtering for trash", () => { + const filterFunction = createFilterFunction({ type: "trash" }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + + it("should return false when filtering for favorites", () => { + const filterFunction = createFilterFunction({ type: "favorites" }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + + it("should return false when type is not specified in filter", () => { + const filterFunction = createFilterFunction({}); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + }); + + describe("given a cipher with type", () => { + it("should return true when filter matches cipher type", () => { + const cipher = createCipher({ type: CipherType.Identity }); + const filterFunction = createFilterFunction({ type: "identity" }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + + it("should return false when filter does not match cipher type", () => { + const cipher = createCipher({ type: CipherType.Card }); + const filterFunction = createFilterFunction({ type: "favorites" }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + }); + + describe("given a cipher with folder id", () => { + it("should return true when filter matches folder id", () => { + const cipher = createCipher({ folderId: "folderId" }); + const filterFunction = createFilterFunction({ folderId: "folderId" }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + + it("should return false when filter does not match folder id", () => { + const cipher = createCipher({ folderId: "folderId" }); + const filterFunction = createFilterFunction({ folderId: "differentFolderId" }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + }); + + describe("given a cipher without folder", () => { + const cipher = createCipher({ folderId: null }); + + it("should return true when filtering on unassigned folder", () => { + const filterFunction = createFilterFunction({ folderId: Unassigned }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + }); + + describe("given an organizational cipher (with organization and collections)", () => { + const cipher = createCipher({ + organizationId: "organizationId", + collectionIds: ["collectionId", "anotherId"], + }); + + it("should return true when filter matches collection id", () => { + const filterFunction = createFilterFunction({ + collectionId: "collectionId", + organizationId: "organizationId", + }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + + it("should return false when filter does not match collection id", () => { + const filterFunction = createFilterFunction({ + collectionId: "nonMatchingCollectionId", + organizationId: "organizationId", + }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + + it("should return false when filter does not match organization id", () => { + const filterFunction = createFilterFunction({ + organizationId: "nonMatchingOrganizationId", + }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + + it("should return false when filtering for my vault only", () => { + const filterFunction = createFilterFunction({ organizationId: Unassigned }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + + it("should return false when filtering by All Collections", () => { + const filterFunction = createFilterFunction({ collectionId: All }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + }); + + describe("given an unassigned organizational cipher (with organization, without collection)", () => { + const cipher = createCipher({ organizationId: "organizationId", collectionIds: [] }); + + it("should return true when filtering for unassigned collection", () => { + const filterFunction = createFilterFunction({ collectionId: Unassigned }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + + it("should return true when filter matches organization id", () => { + const filterFunction = createFilterFunction({ organizationId: "organizationId" }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + }); + + describe("given an individual cipher (without organization or collection)", () => { + const cipher = createCipher({ organizationId: null, collectionIds: [] }); + + it("should return false when filtering for unassigned collection", () => { + const filterFunction = createFilterFunction({ collectionId: Unassigned }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + + it("should return true when filtering for my vault only", () => { + const cipher = createCipher({ organizationId: null }); + const filterFunction = createFilterFunction({ organizationId: Unassigned }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + }); +}); + +function createCipher(options: Partial = {}) { + const cipher = new CipherView(); + + cipher.favorite = options.favorite ?? false; + cipher.deletedDate = options.deletedDate; + cipher.type = options.type; + cipher.folderId = options.folderId; + cipher.collectionIds = options.collectionIds; + cipher.organizationId = options.organizationId; + + return cipher; +} diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts new file mode 100644 index 0000000000..c8d21e314d --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts @@ -0,0 +1,81 @@ +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { All, RoutedVaultFilterModel, Unassigned } from "./routed-vault-filter.model"; + +export type FilterFunction = (cipher: CipherView) => boolean; + +export function createFilterFunction(filter: RoutedVaultFilterModel): FilterFunction { + return (cipher) => { + if (filter.type === "favorites" && !cipher.favorite) { + return false; + } + if (filter.type === "card" && cipher.type !== CipherType.Card) { + return false; + } + if (filter.type === "identity" && cipher.type !== CipherType.Identity) { + return false; + } + if (filter.type === "login" && cipher.type !== CipherType.Login) { + return false; + } + if (filter.type === "note" && cipher.type !== CipherType.SecureNote) { + return false; + } + if (filter.type === "trash" && !cipher.isDeleted) { + return false; + } + // Hide trash unless explicitly selected + if (filter.type !== "trash" && cipher.isDeleted) { + return false; + } + // No folder + if (filter.folderId === Unassigned && cipher.folderId !== null) { + return false; + } + // Folder + if ( + filter.folderId !== undefined && + filter.folderId !== All && + filter.folderId !== Unassigned && + cipher.folderId !== filter.folderId + ) { + return false; + } + // All collections (top level) + if (filter.collectionId === All) { + return false; + } + // Unassigned + if ( + filter.collectionId === Unassigned && + (cipher.organizationId == null || + (cipher.collectionIds != null && cipher.collectionIds.length > 0)) + ) { + return false; + } + // Collection + if ( + filter.collectionId !== undefined && + filter.collectionId !== All && + filter.collectionId !== Unassigned && + (cipher.collectionIds == null || !cipher.collectionIds.includes(filter.collectionId)) + ) { + return false; + } + // My Vault + if (filter.organizationId === Unassigned && cipher.organizationId != null) { + return false; + } + // Organization + else if ( + filter.organizationId !== undefined && + filter.organizationId !== Unassigned && + cipher.organizationId !== filter.organizationId + ) { + return false; + } + + return true; + }; +} diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter-bridge.model.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter-bridge.model.ts index c2a25b6751..07a901c8eb 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter-bridge.model.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter-bridge.model.ts @@ -53,7 +53,7 @@ export class RoutedVaultFilterBridge implements VaultFilter { set selectedOrganizationNode(value: TreeNode) { this.bridgeService.navigate({ ...this.routedFilter, - organizationId: value.node.id, + organizationId: value?.node.id === "MyVault" ? Unassigned : value?.node.id, folderId: undefined, collectionId: undefined, }); diff --git a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html index d128013946..1697dd7216 100644 --- a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html @@ -1,40 +1,45 @@
- + - - - {{ activeOrganizationId | orgNameFromId : (organizations$ | async) }} - {{ "vault" | i18n | lowercase }} - - {{ collection.node.name }} + {{ activeOrganizationId | orgNameFromId : organizations }} {{ "vault" | i18n | lowercase }} + + + {{ collection.name }} + +

{{ title }} - - - - {{ "loading" | i18n }} - + + + {{ "loading" | i18n }}

-
+
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{ "attachments" | i18n }} - - - {{ "attachmentsNeedFix" | i18n }} - - -
- {{ c.subTitle }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
- - - {{ "loading" | i18n }} - - - - -

{{ "noPermissionToViewAllCollectionItems" | i18n }}

-
- -

{{ "noItemsInList" | i18n }}

- -
-
-
- diff --git a/apps/web/src/app/vault/individual-vault/vault-items.component.ts b/apps/web/src/app/vault/individual-vault/vault-items.component.ts deleted file mode 100644 index 98f52bf5ca..0000000000 --- a/apps/web/src/app/vault/individual-vault/vault-items.component.ts +++ /dev/null @@ -1,588 +0,0 @@ -import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core"; -import { lastValueFrom } from "rxjs"; - -import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; -import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angular/vault/components/vault-items.component"; -import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { SearchService } from "@bitwarden/common/abstractions/search.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { TotpService } from "@bitwarden/common/abstractions/totp.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view"; -import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; -import { EventType } from "@bitwarden/common/enums"; -import { TreeNode } from "@bitwarden/common/models/domain/tree-node"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service"; -import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; -import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { DialogService, Icons } from "@bitwarden/components"; - -import { CollectionAdminView, GroupView } from "../../admin-console/organizations/core"; - -import { - BulkDeleteDialogResult, - openBulkDeleteDialog, -} from "./bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component"; -import { - BulkMoveDialogResult, - openBulkMoveDialog, -} from "./bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component"; -import { - BulkRestoreDialogResult, - openBulkRestoreDialog, -} from "./bulk-action-dialogs/bulk-restore-dialog/bulk-restore-dialog.component"; -import { - BulkShareDialogResult, - openBulkShareDialog, -} from "./bulk-action-dialogs/bulk-share-dialog/bulk-share-dialog.component"; -import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service"; -import { VaultFilter } from "./vault-filter/shared/models/vault-filter.model"; -import { CollectionFilter } from "./vault-filter/shared/models/vault-filter.type"; - -const MaxCheckedCount = 500; - -export type VaultItemRow = (CipherView | TreeNode) & { checked?: boolean }; - -@Component({ - selector: "app-vault-items", - templateUrl: "vault-items.component.html", -}) -export class VaultItemsComponent extends BaseVaultItemsComponent implements OnDestroy { - @Output() activeFilterChanged = new EventEmitter(); - @Output() onAttachmentsClicked = new EventEmitter(); - @Output() onShareClicked = new EventEmitter(); - @Output() onEditCipherCollectionsClicked = new EventEmitter(); - @Output() onCloneClicked = new EventEmitter(); - @Output() onOrganzationBadgeClicked = new EventEmitter(); - - private _activeFilter: VaultFilter; - @Input() get activeFilter(): VaultFilter { - return this._activeFilter; - } - set activeFilter(value: VaultFilter) { - this._activeFilter = value; - this.reload(this.activeFilter.buildFilter(), this.activeFilter.isDeleted); - } - - cipherType = CipherType; - actionPromise: Promise; - userHasPremiumAccess = false; - organizations: Organization[] = []; - profileName: string; - noItemIcon = Icons.Search; - groups: GroupView[] = []; - - protected pageSizeLimit = 200; - protected isAllChecked = false; - protected didScroll = false; - protected currentPagedCiphersCount = 0; - protected currentPagedCollectionsCount = 0; - protected refreshing = false; - - protected pagedCiphers: CipherView[] = []; - protected pagedCollections: TreeNode[] = []; - protected searchedCollections: TreeNode[] = []; - - get showAddNew() { - return !this.activeFilter.isDeleted; - } - - get collections(): TreeNode[] { - return this.activeFilter?.selectedCollectionNode?.children ?? []; - } - - get filteredCollections(): TreeNode[] { - if (this.isPaging()) { - return this.pagedCollections; - } - - if (this.searchService.isSearchable(this.searchText)) { - return this.searchedCollections; - } - - return this.collections; - } - - get filteredCiphers(): CipherView[] { - return this.isPaging() ? this.pagedCiphers : this.ciphers; - } - - constructor( - searchService: SearchService, - protected i18nService: I18nService, - protected platformUtilsService: PlatformUtilsService, - protected vaultFilterService: VaultFilterService, - cipherService: CipherService, - protected eventCollectionService: EventCollectionService, - protected totpService: TotpService, - protected stateService: StateService, - protected passwordRepromptService: PasswordRepromptService, - protected dialogService: DialogService, - protected logService: LogService, - private searchPipe: SearchPipe, - private organizationService: OrganizationService, - private tokenService: TokenService - ) { - super(searchService, cipherService); - } - - ngOnDestroy() { - this.checkAll(false); - } - - async applyFilter(filter: (cipher: CipherView) => boolean = null) { - this.checkAll(false); - this.isAllChecked = false; - this.pagedCollections = []; - if (!this.refreshing && this.isPaging()) { - this.currentPagedCollectionsCount = 0; - this.currentPagedCiphersCount = 0; - } - await super.applyFilter(filter); - } - - // load() is called after the page loads and the first sync has completed. - // Do not use ngOnInit() for anything that requires sync data. - async load(filter: (cipher: CipherView) => boolean = null, deleted = false) { - await super.load(filter, deleted); - this.updateSearchedCollections(this.collections); - this.profileName = await this.tokenService.getName(); - this.organizations = await this.organizationService.getAll(); - this.userHasPremiumAccess = await this.stateService.getCanAccessPremium(); - } - - async refresh() { - try { - this.refreshing = true; - await this.reload(this.filter, this.deleted); - } finally { - this.refreshing = false; - } - } - - loadMore() { - // If we have less rows than the page size, we don't need to page anything - if (this.ciphers.length + (this.collections?.length || 0) <= this.pageSizeLimit) { - return; - } - - let pageSpaceLeft = this.pageSizeLimit; - if ( - this.refreshing && - this.pagedCiphers.length + this.pagedCollections.length === 0 && - this.currentPagedCiphersCount + this.currentPagedCollectionsCount > this.pageSizeLimit - ) { - // When we refresh, we want to load the previous amount of items, not restart the paging - pageSpaceLeft = this.currentPagedCiphersCount + this.currentPagedCollectionsCount; - } - // if there are still collections to show - if (this.collections?.length > this.pagedCollections.length) { - const collectionsToAdd = this.collections.slice( - this.pagedCollections.length, - this.currentPagedCollectionsCount + pageSpaceLeft - ); - this.pagedCollections = this.pagedCollections.concat(collectionsToAdd); - // set the current count to the new count of paged collections - this.currentPagedCollectionsCount = this.pagedCollections.length; - // subtract the available page size by the amount of collections we just added, default to 0 if negative - pageSpaceLeft = - collectionsToAdd.length > pageSpaceLeft ? 0 : pageSpaceLeft - collectionsToAdd.length; - } - // if we have room left to show ciphers and we have ciphers to show - if (pageSpaceLeft > 0 && this.ciphers.length > this.pagedCiphers.length) { - this.pagedCiphers = this.pagedCiphers.concat( - this.ciphers.slice(this.pagedCiphers.length, this.currentPagedCiphersCount + pageSpaceLeft) - ); - // set the current count to the new count of paged ciphers - this.currentPagedCiphersCount = this.pagedCiphers.length; - } - // set a flag if we actually loaded the second page while paging - this.didScroll = this.pagedCiphers.length + this.pagedCollections.length > this.pageSizeLimit; - } - - isPaging() { - const searching = this.isSearching(); - if (searching && this.didScroll) { - this.resetPaging(); - } - const totalRows = - this.ciphers.length + (this.activeFilter?.selectedCollectionNode?.children.length || 0); - return !searching && totalRows > this.pageSizeLimit; - } - - async resetPaging() { - this.pagedCollections = []; - this.pagedCiphers = []; - this.loadMore(); - } - - async doSearch(indexedCiphers?: CipherView[]) { - indexedCiphers = indexedCiphers ?? (await this.cipherService.getAllDecrypted()); - this.ciphers = await this.searchService.searchCiphers( - this.searchText, - [this.filter, this.deletedFilter], - indexedCiphers - ); - this.updateSearchedCollections(this.collections); - this.resetPaging(); - } - - launch(uri: string) { - this.platformUtilsService.launchUri(uri); - } - - async attachments(c: CipherView) { - if (!(await this.repromptCipher(c))) { - return; - } - this.onAttachmentsClicked.emit(c); - } - - async share(c: CipherView) { - if (!(await this.repromptCipher(c))) { - return; - } - this.onShareClicked.emit(c); - } - - editCipherCollections(c: CipherView) { - this.onEditCipherCollectionsClicked.emit(c); - } - - async clone(c: CipherView) { - if (!(await this.repromptCipher(c))) { - return; - } - this.onCloneClicked.emit(c); - } - - async deleteCipher(c: CipherView): Promise { - if (!(await this.repromptCipher(c))) { - return; - } - if (this.actionPromise != null) { - return; - } - const permanent = c.isDeleted; - const confirmed = await this.platformUtilsService.showDialog( - this.i18nService.t( - permanent ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation" - ), - this.i18nService.t(permanent ? "permanentlyDeleteItem" : "deleteItem"), - this.i18nService.t("yes"), - this.i18nService.t("no"), - "warning" - ); - if (!confirmed) { - return false; - } - - try { - this.actionPromise = this.deleteCipherWithServer(c.id, permanent); - await this.actionPromise; - this.platformUtilsService.showToast( - "success", - null, - this.i18nService.t(permanent ? "permanentlyDeletedItem" : "deletedItem") - ); - this.refresh(); - } catch (e) { - this.logService.error(e); - } - this.actionPromise = null; - } - - async bulkDelete() { - if (!(await this.repromptCipher())) { - return; - } - - const selectedIds = this.selectedCipherIds; - if (selectedIds.length === 0) { - this.platformUtilsService.showToast( - "error", - this.i18nService.t("errorOccurred"), - this.i18nService.t("nothingSelected") - ); - return; - } - - const dialog = openBulkDeleteDialog(this.dialogService, { - data: { permanent: this.deleted, cipherIds: selectedIds }, - }); - - const result = await lastValueFrom(dialog.closed); - if (result === BulkDeleteDialogResult.Deleted) { - this.actionPromise = this.refresh(); - await this.actionPromise; - this.actionPromise = null; - } - } - - async restore(c: CipherView): Promise { - if (this.actionPromise != null || !c.isDeleted) { - return; - } - const confirmed = await this.platformUtilsService.showDialog( - this.i18nService.t("restoreItemConfirmation"), - this.i18nService.t("restoreItem"), - this.i18nService.t("yes"), - this.i18nService.t("no"), - "warning" - ); - if (!confirmed) { - return false; - } - - try { - this.actionPromise = this.cipherService.restoreWithServer(c.id); - await this.actionPromise; - this.platformUtilsService.showToast("success", null, this.i18nService.t("restoredItem")); - this.refresh(); - } catch (e) { - this.logService.error(e); - } - this.actionPromise = null; - } - - async bulkRestore() { - if (!(await this.repromptCipher())) { - return; - } - - const selectedCipherIds = this.selectedCipherIds; - if (selectedCipherIds.length === 0) { - this.platformUtilsService.showToast( - "error", - this.i18nService.t("errorOccurred"), - this.i18nService.t("nothingSelected") - ); - return; - } - - const dialog = openBulkRestoreDialog(this.dialogService, { - data: { cipherIds: selectedCipherIds }, - }); - - const result = await lastValueFrom(dialog.closed); - if (result === BulkRestoreDialogResult.Restored) { - this.actionPromise = this.refresh(); - await this.actionPromise; - this.actionPromise = null; - } - } - - async bulkShare() { - if (!(await this.repromptCipher())) { - return; - } - - const selectedCiphers = this.selectedCiphers; - if (selectedCiphers.length === 0) { - this.platformUtilsService.showToast( - "error", - this.i18nService.t("errorOccurred"), - this.i18nService.t("nothingSelected") - ); - return; - } - - const dialog = openBulkShareDialog(this.dialogService, { data: { ciphers: selectedCiphers } }); - - const result = await lastValueFrom(dialog.closed); - if (result === BulkShareDialogResult.Shared) { - this.actionPromise = this.refresh(); - await this.actionPromise; - this.actionPromise = null; - } - } - - async bulkMove() { - if (!(await this.repromptCipher())) { - return; - } - - const selectedCipherIds = this.selectedCipherIds; - if (selectedCipherIds.length === 0) { - this.platformUtilsService.showToast( - "error", - this.i18nService.t("errorOccurred"), - this.i18nService.t("nothingSelected") - ); - return; - } - - const dialog = openBulkMoveDialog(this.dialogService, { - data: { cipherIds: selectedCipherIds }, - }); - - const result = await lastValueFrom(dialog.closed); - if (result === BulkMoveDialogResult.Moved) { - this.actionPromise = this.refresh(); - await this.actionPromise; - this.actionPromise = null; - } - } - - async copy(cipher: CipherView, value: string, typeI18nKey: string, aType: string) { - if ( - this.passwordRepromptService.protectedFields().includes(aType) && - !(await this.repromptCipher(cipher)) - ) { - return; - } - - if (value == null || (aType === "TOTP" && !this.displayTotpCopyButton(cipher))) { - return; - } else if (value === cipher.login.totp) { - value = await this.totpService.getCode(value); - } - - if (!cipher.viewPassword) { - return; - } - - this.platformUtilsService.copyToClipboard(value, { window: window }); - this.platformUtilsService.showToast( - "info", - null, - this.i18nService.t("valueCopied", this.i18nService.t(typeI18nKey)) - ); - - if (typeI18nKey === "password" || typeI18nKey === "verificationCodeTotp") { - this.eventCollectionService.collect( - EventType.Cipher_ClientToggledHiddenFieldVisible, - cipher.id - ); - } else if (typeI18nKey === "securityCode") { - this.eventCollectionService.collect(EventType.Cipher_ClientCopiedCardCode, cipher.id); - } - } - - navigateCollection(node: TreeNode) { - const filter = this.activeFilter; - filter.selectedCollectionNode = node; - this.activeFilterChanged.emit(filter); - } - - checkAll(select: boolean) { - if (select) { - this.checkAll(false); - } - const items: VaultItemRow[] = this.ciphers; - if (!items) { - return; - } - - const selectCount = select && items.length > MaxCheckedCount ? MaxCheckedCount : items.length; - for (let i = 0; i < selectCount; i++) { - this.checkRow(items[i], select); - } - } - - checkRow(item: VaultItemRow, select?: boolean) { - // Collections can't be managed in end user vault - if (!(item instanceof CipherView)) { - return; - } - item.checked = select ?? !item.checked; - } - - get selectedCiphers(): CipherView[] { - if (!this.ciphers) { - return []; - } - return this.ciphers.filter((c) => !!(c as VaultItemRow).checked); - } - - get selectedCipherIds(): string[] { - return this.selectedCiphers.map((c) => c.id); - } - - displayTotpCopyButton(cipher: CipherView) { - return ( - (cipher?.login?.hasTotp ?? false) && (cipher.organizationUseTotp || this.userHasPremiumAccess) - ); - } - - onOrganizationClicked(organizationId: string) { - this.onOrganzationBadgeClicked.emit(organizationId); - } - - events(c: CipherView) { - // TODO: This should be removed but is needed since we reuse the same template - } - - canDeleteCollection(c: CollectionAdminView): boolean { - // TODO: This should be removed but is needed since we reuse the same template - return false; // Always return false for non org vault - } - - async deleteCollection(collection: CollectionView): Promise { - // TODO: This should be removed but is needed since we reuse the same template - } - - canEditCollection(c: CollectionAdminView): boolean { - // TODO: This should be removed but is needed since we reuse the same template - return false; // Always return false for non org vault - } - - async editCollection(c: CollectionView, tab: "info" | "access"): Promise { - // TODO: This should be removed but is needed since we reuse the same template - } - - get showMissingCollectionPermissionMessage(): boolean { - // TODO: This should be removed but is needed since we reuse the same template - return false; // Always return false for non org vault - } - - /** - * @deprecated Block interaction using long running modal dialog instead - */ - protected get isProcessingAction() { - return this.actionPromise != null; - } - - protected updateSearchedCollections(collections: TreeNode[]) { - if (this.searchService.isSearchable(this.searchText)) { - this.searchedCollections = this.searchPipe.transform( - collections, - this.searchText, - (collection) => collection.node.name, - (collection) => collection.node.id - ); - } - } - - protected deleteCipherWithServer(id: string, permanent: boolean) { - return permanent - ? this.cipherService.deleteWithServer(id) - : this.cipherService.softDeleteWithServer(id); - } - - protected showFixOldAttachments(c: CipherView) { - return c.hasOldAttachments && c.organizationId == null; - } - - protected async repromptCipher(c?: CipherView) { - if (c) { - return ( - c.reprompt === CipherRepromptType.None || - (await this.passwordRepromptService.showPasswordPrompt()) - ); - } else { - const selectedCiphers = this.selectedCiphers; - const notProtected = !selectedCiphers.find( - (cipher) => cipher.reprompt !== CipherRepromptType.None - ); - - return notProtected || (await this.passwordRepromptService.showPasswordPrompt()); - } - } -} diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index 25f4411875..0e7333940d 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -17,24 +17,61 @@
{{ trashCleanupWarning }} +
+ + {{ "loading" | i18n }} +
+
+ +

{{ "noItemsInList" | i18n }}

+ +
@@ -96,5 +133,5 @@ - + diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 2ce4988e38..28bfc95431 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -8,30 +8,69 @@ import { ViewContainerRef, } from "@angular/core"; import { ActivatedRoute, Params, Router } from "@angular/router"; -import { firstValueFrom, Subject } from "rxjs"; -import { first, switchMap, takeUntil } from "rxjs/operators"; +import { BehaviorSubject, combineLatest, firstValueFrom, lastValueFrom, Subject } from "rxjs"; +import { + concatMap, + debounceTime, + filter, + first, + map, + shareReplay, + switchMap, + takeUntil, + tap, +} from "rxjs/operators"; +import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { TotpService } from "@bitwarden/common/abstractions/totp.service"; +import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; -import { KdfType, DEFAULT_PBKDF2_ITERATIONS } from "@bitwarden/common/enums"; +import { KdfType, DEFAULT_PBKDF2_ITERATIONS, EventType } from "@bitwarden/common/enums"; import { ServiceUtils } from "@bitwarden/common/misc/serviceUtils"; +import { Utils } from "@bitwarden/common/misc/utils"; import { TreeNode } from "@bitwarden/common/models/domain/tree-node"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { DialogService, Icons } from "@bitwarden/components"; import { UpdateKeyComponent } from "../../settings/update-key.component"; +import { VaultItemEvent } from "../components/vault-items/vault-item-event"; +import { getNestedCollectionTree } from "../utils/collection-utils"; import { AddEditComponent } from "./add-edit.component"; import { AttachmentsComponent } from "./attachments.component"; +import { + BulkDeleteDialogResult, + openBulkDeleteDialog, +} from "./bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component"; +import { + BulkMoveDialogResult, + openBulkMoveDialog, +} from "./bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component"; +import { + BulkRestoreDialogResult, + openBulkRestoreDialog, +} from "./bulk-action-dialogs/bulk-restore-dialog/bulk-restore-dialog.component"; +import { + BulkShareDialogResult, + openBulkShareDialog, +} from "./bulk-action-dialogs/bulk-share-dialog/bulk-share-dialog.component"; import { CollectionsComponent } from "./collections.component"; import { FolderAddEditComponent } from "./folder-add-edit.component"; import { ShareComponent } from "./share.component"; @@ -39,11 +78,17 @@ import { VaultFilterComponent } from "./vault-filter/components/vault-filter.com import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service"; import { RoutedVaultFilterBridgeService } from "./vault-filter/services/routed-vault-filter-bridge.service"; import { RoutedVaultFilterService } from "./vault-filter/services/routed-vault-filter.service"; +import { createFilterFunction } from "./vault-filter/shared/models/filter-function"; +import { + All, + RoutedVaultFilterModel, + Unassigned, +} from "./vault-filter/shared/models/routed-vault-filter.model"; import { VaultFilter } from "./vault-filter/shared/models/vault-filter.model"; import { FolderFilter, OrganizationFilter } from "./vault-filter/shared/models/vault-filter.type"; -import { VaultItemsComponent } from "./vault-items.component"; const BroadcasterSubscriptionId = "VaultComponent"; +const SearchTextDebounceInterval = 200; @Component({ selector: "app-vault", @@ -52,7 +97,6 @@ const BroadcasterSubscriptionId = "VaultComponent"; }) export class VaultComponent implements OnInit, OnDestroy { @ViewChild("vaultFilter", { static: true }) filterComponent: VaultFilterComponent; - @ViewChild(VaultItemsComponent, { static: true }) vaultItemsComponent: VaultItemsComponent; @ViewChild("attachments", { read: ViewContainerRef, static: true }) attachmentsModalRef: ViewContainerRef; @ViewChild("folderAddEdit", { read: ViewContainerRef, static: true }) @@ -60,7 +104,7 @@ export class VaultComponent implements OnInit, OnDestroy { @ViewChild("cipherAddEdit", { read: ViewContainerRef, static: true }) cipherAddEditModalRef: ViewContainerRef; @ViewChild("share", { read: ViewContainerRef, static: true }) shareModalRef: ViewContainerRef; - @ViewChild("collections", { read: ViewContainerRef, static: true }) + @ViewChild("collectionsModal", { read: ViewContainerRef, static: true }) collectionsModalRef: ViewContainerRef; @ViewChild("updateKeyTemplate", { read: ViewContainerRef, static: true }) updateKeyModalRef: ViewContainerRef; @@ -73,6 +117,23 @@ export class VaultComponent implements OnInit, OnDestroy { trashCleanupWarning: string = null; kdfIterations: number; activeFilter: VaultFilter = new VaultFilter(); + + protected noItemIcon = Icons.Search; + protected performingInitialLoad = true; + protected refreshing = false; + protected processingEvent = false; + protected filter: RoutedVaultFilterModel = {}; + protected showBulkMove: boolean; + protected canAccessPremium: boolean; + protected allCollections: CollectionView[]; + protected allOrganizations: Organization[]; + protected ciphers: CipherView[]; + protected collections: CollectionView[]; + protected isEmpty: boolean; + protected selectedCollection: TreeNode | undefined; + + private refresh$ = new BehaviorSubject(null); + private searchText$ = new Subject(); private destroy$ = new Subject(); constructor( @@ -82,6 +143,7 @@ export class VaultComponent implements OnInit, OnDestroy { private changeDetectorRef: ChangeDetectorRef, private i18nService: I18nService, private modalService: ModalService, + private dialogService: DialogService, private tokenService: TokenService, private cryptoService: CryptoService, private messagingService: MessagingService, @@ -91,47 +153,169 @@ export class VaultComponent implements OnInit, OnDestroy { private stateService: StateService, private organizationService: OrganizationService, private vaultFilterService: VaultFilterService, + private routedVaultFilterService: RoutedVaultFilterService, private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService, private cipherService: CipherService, - private passwordRepromptService: PasswordRepromptService + private passwordRepromptService: PasswordRepromptService, + private collectionService: CollectionService, + private logService: LogService, + private totpService: TotpService, + private eventCollectionService: EventCollectionService, + private searchService: SearchService, + private searchPipe: SearchPipe ) {} async ngOnInit() { - this.showVerifyEmail = !(await this.tokenService.getEmailVerified()); this.showBrowserOutdated = window.navigator.userAgent.indexOf("MSIE") !== -1; - // disable warning for March release -> add await this.isLowKdfIteration(); when ready - this.showLowKdf = false; this.trashCleanupWarning = this.i18nService.t( this.platformUtilsService.isSelfHost() ? "trashCleanupWarningSelfHosted" : "trashCleanupWarning" ); - this.route.queryParams + const firstSetup$ = this.route.queryParams.pipe( + first(), + switchMap(async (params: Params) => { + this.showVerifyEmail = !(await this.tokenService.getEmailVerified()); + // disable warning for March release -> add await this.isLowKdfIteration(); when ready + this.showLowKdf = false; + await this.syncService.fullSync(false); + + const canAccessPremium = await this.stateService.getCanAccessPremium(); + this.showPremiumCallout = + !this.showVerifyEmail && !canAccessPremium && !this.platformUtilsService.isSelfHost(); + this.showUpdateKey = !(await this.cryptoService.hasEncKey()); + + const cipherId = getCipherIdFromParams(params); + if (!cipherId) { + return; + } + const cipherView = new CipherView(); + cipherView.id = cipherId; + if (params.action === "clone") { + await this.cloneCipher(cipherView); + } else if (params.action === "edit") { + await this.editCipher(cipherView); + } + }), + shareReplay({ refCount: true, bufferSize: 1 }) + ); + + this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { + this.ngZone.run(async () => { + switch (message.command) { + case "syncCompleted": + if (message.successfully) { + this.refresh(); + this.changeDetectorRef.detectChanges(); + } + break; + } + }); + }); + + this.routedVaultFilterBridgeService.activeFilter$ + .pipe(takeUntil(this.destroy$)) + .subscribe((activeFilter) => { + this.activeFilter = activeFilter; + }); + + const filter$ = this.routedVaultFilterService.filter$; + const canAccessPremium$ = Utils.asyncToObservable(() => + this.stateService.getCanAccessPremium() + ).pipe(shareReplay({ refCount: true, bufferSize: 1 })); + const allCollections$ = Utils.asyncToObservable(() => + this.collectionService.getAllDecrypted() + ).pipe(shareReplay({ refCount: true, bufferSize: 1 })); + const nestedCollections$ = allCollections$.pipe( + map((collections) => getNestedCollectionTree(collections)), + shareReplay({ refCount: true, bufferSize: 1 }) + ); + + this.searchText$ + .pipe(debounceTime(SearchTextDebounceInterval), takeUntil(this.destroy$)) + .subscribe((searchText) => + this.router.navigate([], { + queryParams: { search: Utils.isNullOrEmpty(searchText) ? null : searchText }, + queryParamsHandling: "merge", + replaceUrl: true, + }) + ); + + const querySearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search)); + + const ciphers$ = combineLatest([ + Utils.asyncToObservable(() => this.cipherService.getAllDecrypted()), + filter$, + querySearchText$, + ]).pipe( + filter(([ciphers, filter]) => ciphers != undefined && filter != undefined), + concatMap(async ([ciphers, filter, searchText]) => { + const filterFunction = createFilterFunction(filter); + + if (this.searchService.isSearchable(searchText)) { + return await this.searchService.searchCiphers(searchText, [filterFunction], ciphers); + } + + return ciphers.filter(filterFunction); + }), + shareReplay({ refCount: true, bufferSize: 1 }) + ); + + const collections$ = combineLatest([nestedCollections$, filter$, querySearchText$]).pipe( + filter(([collections, filter]) => collections != undefined && filter != undefined), + map(([collections, filter, searchText]) => { + if (filter.collectionId === undefined || filter.collectionId === Unassigned) { + return []; + } + + let collectionsToReturn = []; + if (filter.organizationId !== undefined && filter.collectionId === All) { + collectionsToReturn = collections + .filter((c) => c.node.organizationId === filter.organizationId) + .map((c) => c.node); + } else if (filter.collectionId === All) { + collectionsToReturn = collections.map((c) => c.node); + } else { + const selectedCollection = ServiceUtils.getTreeNodeObjectFromList( + collections, + filter.collectionId + ); + collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? []; + } + + if (this.searchService.isSearchable(searchText)) { + collectionsToReturn = this.searchPipe.transform( + collectionsToReturn, + searchText, + (collection) => collection.name, + (collection) => collection.id + ); + } + + return collectionsToReturn; + }), + shareReplay({ refCount: true, bufferSize: 1 }) + ); + + const selectedCollection$ = combineLatest([nestedCollections$, filter$]).pipe( + filter(([collections, filter]) => collections != undefined && filter != undefined), + map(([collections, filter]) => { + if ( + filter.collectionId === undefined || + filter.collectionId === All || + filter.collectionId === Unassigned + ) { + return undefined; + } + + return ServiceUtils.getTreeNodeObjectFromList(collections, filter.collectionId); + }), + shareReplay({ refCount: true, bufferSize: 1 }) + ); + + firstSetup$ .pipe( - first(), - switchMap(async (params: Params) => { - await this.syncService.fullSync(false); - await this.vaultFilterService.reloadCollections(); - await this.vaultItemsComponent.reload(); - - const canAccessPremium = await this.stateService.getCanAccessPremium(); - this.showPremiumCallout = - !this.showVerifyEmail && !canAccessPremium && !this.platformUtilsService.isSelfHost(); - this.showUpdateKey = !(await this.cryptoService.hasEncKey()); - - const cipherId = getCipherIdFromParams(params); - if (!cipherId) { - return; - } - const cipherView = new CipherView(); - cipherView.id = cipherId; - if (params.action === "clone") { - await this.cloneCipher(cipherView); - } else if (params.action === "edit") { - await this.editCipher(cipherView); - } - }), switchMap(() => this.route.queryParams), switchMap(async (params) => { const cipherId = getCipherIdFromParams(params); @@ -155,27 +339,54 @@ export class VaultComponent implements OnInit, OnDestroy { ) .subscribe(); - this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { - this.ngZone.run(async () => { - switch (message.command) { - case "syncCompleted": - if (message.successfully) { - await Promise.all([ - this.vaultFilterService.reloadCollections(), - this.vaultItemsComponent.load(this.vaultItemsComponent.filter), - ]); - this.changeDetectorRef.detectChanges(); - } - break; - } - }); - }); + firstSetup$ + .pipe( + switchMap(() => this.refresh$), + tap(() => (this.refreshing = true)), + switchMap(() => + combineLatest([ + filter$, + canAccessPremium$, + allCollections$, + this.organizationService.organizations$, + ciphers$, + collections$, + selectedCollection$, + ]) + ), + takeUntil(this.destroy$) + ) + .subscribe( + ([ + filter, + canAccessPremium, + allCollections, + allOrganizations, + ciphers, + collections, + selectedCollection, + ]) => { + this.filter = filter; + this.canAccessPremium = canAccessPremium; + this.allCollections = allCollections; + this.allOrganizations = allOrganizations; + this.ciphers = ciphers; + this.collections = collections; + this.selectedCollection = selectedCollection; - this.routedVaultFilterBridgeService.activeFilter$ - .pipe(takeUntil(this.destroy$)) - .subscribe((activeFilter) => { - this.activeFilter = activeFilter; - }); + this.showBulkMove = + filter.type !== "trash" && + (filter.organizationId === undefined || filter.organizationId === Unassigned); + this.isEmpty = collections?.length === 0 && ciphers?.length === 0; + + // This is a temporary fix to avoid double fetching collections. + // TODO: Remove when implementing new VVR menu + this.vaultFilterService.reloadCollections(allCollections); + + this.performingInitialLoad = false; + this.refreshing = false; + } + ); } get isShowingCards() { @@ -198,6 +409,44 @@ export class VaultComponent implements OnInit, OnDestroy { this.destroy$.complete(); } + async onVaultItemsEvent(event: VaultItemEvent) { + this.processingEvent = true; + try { + if (event.type === "viewAttachments") { + await this.editCipherAttachments(event.item); + } else if (event.type === "viewCollections") { + await this.editCipherCollections(event.item); + } else if (event.type === "clone") { + await this.cloneCipher(event.item); + } else if (event.type === "restore") { + if (event.items.length === 1) { + await this.restore(event.items[0]); + } else { + await this.bulkRestore(event.items); + } + } else if (event.type === "delete") { + const ciphers = event.items.filter((i) => i.collection === undefined).map((i) => i.cipher); + if (ciphers.length === 1) { + await this.deleteCipher(ciphers[0]); + } else { + await this.bulkDelete(ciphers); + } + } else if (event.type === "moveToFolder") { + await this.bulkMove(event.items); + } else if (event.type === "moveToOrganization") { + if (event.items.length === 1) { + await this.shareCipher(event.items[0]); + } else { + await this.bulkShare(event.items); + } + } else if (event.type === "copyField") { + await this.copy(event.item, event.field); + } + } finally { + this.processingEvent = false; + } + } + async applyOrganizationFilter(orgId: string) { if (orgId == null) { orgId = "MyVault"; @@ -213,8 +462,7 @@ export class VaultComponent implements OnInit, OnDestroy { this.folderAddEditModalRef, (comp) => { comp.folderId = null; - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onSavedFolder.subscribe(async () => { + comp.onSavedFolder.pipe(takeUntil(this.destroy$)).subscribe(() => { modal.close(); }); } @@ -227,12 +475,18 @@ export class VaultComponent implements OnInit, OnDestroy { this.folderAddEditModalRef, (comp) => { comp.folderId = folder.id; - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onSavedFolder.subscribe(async () => { + comp.onSavedFolder.pipe(takeUntil(this.destroy$)).subscribe(() => { modal.close(); }); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onDeletedFolder.subscribe(async () => { + comp.onDeletedFolder.pipe(takeUntil(this.destroy$)).subscribe(() => { + // Navigate away if we deleted the colletion we were viewing + if (this.filter.folderId === folder.id) { + this.router.navigate([], { + queryParams: { folderId: null }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + } modal.close(); }); } @@ -240,8 +494,7 @@ export class VaultComponent implements OnInit, OnDestroy { }; filterSearchText(searchText: string) { - this.vaultItemsComponent.searchText = searchText; - this.vaultItemsComponent.search(200); + this.searchText$.next(searchText); } async editCipherAttachments(cipher: CipherView) { @@ -265,19 +518,21 @@ export class VaultComponent implements OnInit, OnDestroy { this.attachmentsModalRef, (comp) => { comp.cipherId = cipher.id; - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - comp.onUploadedAttachment.subscribe(() => (madeAttachmentChanges = true)); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - comp.onDeletedAttachment.subscribe(() => (madeAttachmentChanges = true)); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - comp.onReuploadedAttachment.subscribe(() => (madeAttachmentChanges = true)); + comp.onUploadedAttachment + .pipe(takeUntil(this.destroy$)) + .subscribe(() => (madeAttachmentChanges = true)); + comp.onDeletedAttachment + .pipe(takeUntil(this.destroy$)) + .subscribe(() => (madeAttachmentChanges = true)); + comp.onReuploadedAttachment + .pipe(takeUntil(this.destroy$)) + .subscribe(() => (madeAttachmentChanges = true)); } ); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - modal.onClosed.subscribe(async () => { + modal.onClosed.pipe(takeUntil(this.destroy$)).subscribe(() => { if (madeAttachmentChanges) { - await this.vaultItemsComponent.refresh(); + this.refresh(); } madeAttachmentChanges = false; }); @@ -289,10 +544,9 @@ export class VaultComponent implements OnInit, OnDestroy { this.shareModalRef, (comp) => { comp.cipherId = cipher.id; - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onSharedCipher.subscribe(async () => { + comp.onSharedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => { modal.close(); - await this.vaultItemsComponent.refresh(); + this.refresh(); }); } ); @@ -304,10 +558,9 @@ export class VaultComponent implements OnInit, OnDestroy { this.collectionsModalRef, (comp) => { comp.cipherId = cipher.id; - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onSavedCollections.subscribe(async () => { + comp.onSavedCollections.pipe(takeUntil(this.destroy$)).subscribe(() => { modal.close(); - await this.vaultItemsComponent.refresh(); + this.refresh(); }); } ); @@ -351,20 +604,17 @@ export class VaultComponent implements OnInit, OnDestroy { this.cipherAddEditModalRef, (comp) => { comp.cipherId = id; - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onSavedCipher.subscribe(async () => { + comp.onSavedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => { modal.close(); - await this.vaultItemsComponent.refresh(); + this.refresh(); }); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onDeletedCipher.subscribe(async () => { + comp.onDeletedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => { modal.close(); - await this.vaultItemsComponent.refresh(); + this.refresh(); }); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onRestoredCipher.subscribe(async () => { + comp.onRestoredCipher.pipe(takeUntil(this.destroy$)).subscribe(() => { modal.close(); - await this.vaultItemsComponent.refresh(); + this.refresh(); }); } ); @@ -381,6 +631,216 @@ export class VaultComponent implements OnInit, OnDestroy { component.cloneMode = true; } + async restore(c: CipherView): Promise { + if (!(await this.repromptCipher([c]))) { + return; + } + + if (!c.isDeleted) { + return; + } + const confirmed = await this.platformUtilsService.showDialog( + this.i18nService.t("restoreItemConfirmation"), + this.i18nService.t("restoreItem"), + this.i18nService.t("yes"), + this.i18nService.t("no"), + "warning" + ); + if (!confirmed) { + return false; + } + + try { + await this.cipherService.restoreWithServer(c.id); + this.platformUtilsService.showToast("success", null, this.i18nService.t("restoredItem")); + this.refresh(); + } catch (e) { + this.logService.error(e); + } + } + + async bulkRestore(ciphers: CipherView[]) { + if (!(await this.repromptCipher(ciphers))) { + return; + } + + const selectedCipherIds = ciphers.map((cipher) => cipher.id); + if (selectedCipherIds.length === 0) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("nothingSelected") + ); + return; + } + + const dialog = openBulkRestoreDialog(this.dialogService, { + data: { cipherIds: selectedCipherIds }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === BulkRestoreDialogResult.Restored) { + this.refresh(); + } + } + + async deleteCipher(c: CipherView): Promise { + if (!(await this.repromptCipher([c]))) { + return; + } + + const permanent = c.isDeleted; + const confirmed = await this.platformUtilsService.showDialog( + this.i18nService.t( + permanent ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation" + ), + this.i18nService.t(permanent ? "permanentlyDeleteItem" : "deleteItem"), + this.i18nService.t("yes"), + this.i18nService.t("no"), + "warning" + ); + if (!confirmed) { + return false; + } + + try { + await this.deleteCipherWithServer(c.id, permanent); + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t(permanent ? "permanentlyDeletedItem" : "deletedItem") + ); + this.refresh(); + } catch (e) { + this.logService.error(e); + } + } + + async bulkDelete(ciphers: CipherView[]) { + if (!(await this.repromptCipher(ciphers))) { + return; + } + + const selectedIds = ciphers.map((cipher) => cipher.id); + if (selectedIds.length === 0) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("nothingSelected") + ); + return; + } + const dialog = openBulkDeleteDialog(this.dialogService, { + data: { permanent: this.filter.type === "trash", cipherIds: selectedIds }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === BulkDeleteDialogResult.Deleted) { + this.refresh(); + } + } + + async bulkMove(ciphers: CipherView[]) { + if (!(await this.repromptCipher(ciphers))) { + return; + } + + const selectedCipherIds = ciphers.map((cipher) => cipher.id); + if (selectedCipherIds.length === 0) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("nothingSelected") + ); + return; + } + + const dialog = openBulkMoveDialog(this.dialogService, { + data: { cipherIds: selectedCipherIds }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === BulkMoveDialogResult.Moved) { + this.refresh(); + } + } + + async copy(cipher: CipherView, field: "username" | "password" | "totp") { + let aType; + let value; + let typeI18nKey; + + if (field === "username") { + aType = "Username"; + value = cipher.login.username; + typeI18nKey = "username"; + } else if (field === "password") { + aType = "Password"; + value = cipher.login.password; + typeI18nKey = "password"; + } else if (field === "totp") { + aType = "TOTP"; + value = await this.totpService.getCode(cipher.login.totp); + typeI18nKey = "verificationCodeTotp"; + } else { + this.platformUtilsService.showToast("info", null, this.i18nService.t("unexpectedError")); + return; + } + + if ( + this.passwordRepromptService.protectedFields().includes(aType) && + !(await this.repromptCipher([cipher])) + ) { + return; + } + + if (!cipher.viewPassword) { + return; + } + + this.platformUtilsService.copyToClipboard(value, { window: window }); + this.platformUtilsService.showToast( + "info", + null, + this.i18nService.t("valueCopied", this.i18nService.t(typeI18nKey)) + ); + + if (field === "password" || field === "totp") { + this.eventCollectionService.collect( + EventType.Cipher_ClientToggledHiddenFieldVisible, + cipher.id + ); + } + } + + async bulkShare(ciphers: CipherView[]) { + if (!(await this.repromptCipher(ciphers))) { + return; + } + + if (ciphers.length === 0) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("nothingSelected") + ); + return; + } + + const dialog = openBulkShareDialog(this.dialogService, { data: { ciphers } }); + + const result = await lastValueFrom(dialog.closed); + if (result === BulkShareDialogResult.Shared) { + this.refresh(); + } + } + + protected deleteCipherWithServer(id: string, permanent: boolean) { + return permanent + ? this.cipherService.deleteWithServer(id) + : this.cipherService.softDeleteWithServer(id); + } + async updateKey() { await this.modalService.openViewRef(UpdateKeyComponent, this.updateKeyModalRef); } @@ -391,6 +851,16 @@ export class VaultComponent implements OnInit, OnDestroy { return kdfType === KdfType.PBKDF2_SHA256 && kdfOptions.iterations < DEFAULT_PBKDF2_ITERATIONS; } + protected async repromptCipher(ciphers: CipherView[]) { + const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None); + + return notProtected || (await this.passwordRepromptService.showPasswordPrompt()); + } + + private refresh() { + this.refresh$.next(); + } + private go(queryParams: any = null) { if (queryParams == null) { queryParams = { diff --git a/apps/web/src/app/vault/individual-vault/vault.module.ts b/apps/web/src/app/vault/individual-vault/vault.module.ts index 97ae44a461..5e3cc67bc0 100644 --- a/apps/web/src/app/vault/individual-vault/vault.module.ts +++ b/apps/web/src/app/vault/individual-vault/vault.module.ts @@ -3,6 +3,7 @@ import { NgModule } from "@angular/core"; import { BreadcrumbsModule } from "@bitwarden/components"; import { LooseComponentsModule, SharedModule } from "../../shared"; +import { VaultItemsModule } from "../components/vault-items/vault-items.module"; import { CollectionBadgeModule } from "../org-vault/collection-badge/collection-badge.module"; import { GroupBadgeModule } from "../org-vault/group-badge/group-badge.module"; @@ -11,7 +12,6 @@ import { OrganizationBadgeModule } from "./organization-badge/organization-badge import { PipesModule } from "./pipes/pipes.module"; import { VaultFilterModule } from "./vault-filter/vault-filter.module"; import { VaultHeaderComponent } from "./vault-header/vault-header.component"; -import { VaultItemsComponent } from "./vault-items.component"; import { VaultRoutingModule } from "./vault-routing.module"; import { VaultComponent } from "./vault.component"; @@ -27,8 +27,9 @@ import { VaultComponent } from "./vault.component"; LooseComponentsModule, BulkDialogsModule, BreadcrumbsModule, + VaultItemsModule, ], - declarations: [VaultComponent, VaultItemsComponent, VaultHeaderComponent], + declarations: [VaultComponent, VaultHeaderComponent], exports: [VaultComponent], }) export class VaultModule {} diff --git a/apps/web/src/app/vault/org-vault/collection-badge/collection-badge.module.ts b/apps/web/src/app/vault/org-vault/collection-badge/collection-badge.module.ts index 6e29b016a5..44c27e57c8 100644 --- a/apps/web/src/app/vault/org-vault/collection-badge/collection-badge.module.ts +++ b/apps/web/src/app/vault/org-vault/collection-badge/collection-badge.module.ts @@ -1,6 +1,6 @@ import { NgModule } from "@angular/core"; -import { SharedModule } from "../../../shared"; +import { SharedModule } from "../../../shared/shared.module"; import { PipesModule } from "../../individual-vault/pipes/pipes.module"; import { CollectionNameBadgeComponent } from "./collection-name.badge.component"; diff --git a/apps/web/src/app/vault/org-vault/group-badge/group-badge.module.ts b/apps/web/src/app/vault/org-vault/group-badge/group-badge.module.ts index 5839e7e17b..26ce689ed8 100644 --- a/apps/web/src/app/vault/org-vault/group-badge/group-badge.module.ts +++ b/apps/web/src/app/vault/org-vault/group-badge/group-badge.module.ts @@ -1,6 +1,6 @@ import { NgModule } from "@angular/core"; -import { SharedModule } from "../../../shared"; +import { SharedModule } from "../../../shared/shared.module"; import { PipesModule } from "../../individual-vault/pipes/pipes.module"; import { GroupNameBadgeComponent } from "./group-name-badge.component"; diff --git a/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.service.ts b/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.service.ts index 7456fbdd5e..4918ef82bf 100644 --- a/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.service.ts +++ b/apps/web/src/app/vault/org-vault/vault-filter/vault-filter.service.ts @@ -1,21 +1,18 @@ import { Injectable, OnDestroy } from "@angular/core"; -import { filter, map, Observable, ReplaySubject, Subject, switchMap, takeUntil } from "rxjs"; +import { map, Observable, ReplaySubject, Subject } from "rxjs"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service"; -import { - canAccessVaultTab, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { TreeNode } from "@bitwarden/common/models/domain/tree-node"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { CollectionAdminView } from "../../../admin-console/organizations/core"; -import { CollectionAdminService } from "../../../admin-console/organizations/core/services/collection-admin.service"; +import { + CollectionAdminService, + CollectionAdminView, +} from "../../../admin-console/organizations/core"; +import { StateService } from "../../../core"; import { VaultFilterService as BaseVaultFilterService } from "../../individual-vault/vault-filter/services/vault-filter.service"; import { CollectionFilter } from "../../individual-vault/vault-filter/shared/models/vault-filter.type"; @@ -35,7 +32,6 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest organizationService: OrganizationService, folderService: FolderService, cipherService: CipherService, - collectionService: CollectionService, policyService: PolicyService, i18nService: I18nService, protected collectionAdminService: CollectionAdminService @@ -45,42 +41,13 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest organizationService, folderService, cipherService, - collectionService, policyService, i18nService ); - this.loadSubscriptions(); } - protected loadSubscriptions() { - this._organizationFilter - .pipe( - filter((org) => org != null), - switchMap((org) => { - return this.loadCollections(org); - }), - takeUntil(this.destroy$) - ) - .subscribe((collections) => { - this._collections.next(collections); - }); - } - - async reloadCollections() { - this._collections.next(await this.loadCollections(this._organizationFilter.getValue())); - } - - protected async loadCollections(org: Organization): Promise { - let collections: CollectionAdminView[] = []; - if (canAccessVaultTab(org)) { - collections = await this.collectionAdminService.getAll(org.id); - - const noneCollection = new CollectionAdminView(); - noneCollection.name = this.i18nService.t("unassigned"); - noneCollection.organizationId = org.id; - collections.push(noneCollection); - } - return collections; + async reloadCollections(collections: CollectionAdminView[]) { + this._collections.next(collections); } ngOnDestroy() { diff --git a/apps/web/src/app/vault/org-vault/vault-header/vault-header.component.html b/apps/web/src/app/vault/org-vault/vault-header/vault-header.component.html index 8648639a85..7855acec38 100644 --- a/apps/web/src/app/vault/org-vault/vault-header/vault-header.component.html +++ b/apps/web/src/app/vault/org-vault/vault-header/vault-header.component.html @@ -1,29 +1,33 @@
- + - - - {{ activeOrganizationId | orgNameFromId : (organizations$ | async) }} - {{ "vault" | i18n | lowercase }} - - {{ collection.node.name }} + {{ organization.name }} {{ "vault" | i18n | lowercase }} + + + {{ collection.name }} + +

{{ title }} - + - - - - {{ "loading" | i18n }} - + + + {{ "loading" | i18n }}

-
+
{{ trashCleanupWarning }} - - + +
+ +

{{ "noPermissionToViewAllCollectionItems" | i18n }}

+
+
+ +

{{ "noItemsInList" | i18n }}

+ +
+
+ + {{ "loading" | i18n }} +
+ + + +
- - - - diff --git a/apps/web/src/app/vault/org-vault/vault.component.ts b/apps/web/src/app/vault/org-vault/vault.component.ts index b207d1f38e..7a8bdb7c70 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -8,35 +8,85 @@ import { ViewContainerRef, } from "@angular/core"; import { ActivatedRoute, Params, Router } from "@angular/router"; -import { combineLatest, firstValueFrom, Subject } from "rxjs"; -import { first, switchMap, takeUntil } from "rxjs/operators"; +import { BehaviorSubject, combineLatest, firstValueFrom, lastValueFrom, Subject } from "rxjs"; +import { + concatMap, + debounceTime, + distinctUntilChanged, + filter, + first, + map, + shareReplay, + switchMap, + takeUntil, + tap, +} from "rxjs/operators"; +import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { ModalService } from "@bitwarden/angular/services/modal.service"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service"; +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { SearchService } from "@bitwarden/common/abstractions/search.service"; +import { TotpService } from "@bitwarden/common/abstractions/totp.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view"; +import { EventType } from "@bitwarden/common/enums"; +import { ServiceUtils } from "@bitwarden/common/misc/serviceUtils"; +import { Utils } from "@bitwarden/common/misc/utils"; +import { TreeNode } from "@bitwarden/common/models/domain/tree-node"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { DialogService } from "@bitwarden/components"; +import { DialogService, Icons } from "@bitwarden/components"; +import { + CollectionAdminService, + CollectionAdminView, + GroupService, + GroupView, +} from "../../admin-console/organizations/core"; import { EntityEventsComponent } from "../../admin-console/organizations/manage/entity-events.component"; +import { + CollectionDialogResult, + CollectionDialogTabType, + openCollectionDialog, +} from "../../admin-console/organizations/shared"; import { VaultFilterService } from "../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service"; import { VaultFilter } from "../../vault/individual-vault/vault-filter/shared/models/vault-filter.model"; +import { VaultItemEvent } from "../components/vault-items/vault-item-event"; +import { + BulkDeleteDialogResult, + openBulkDeleteDialog, +} from "../individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component"; +import { + BulkRestoreDialogResult, + openBulkRestoreDialog, +} from "../individual-vault/bulk-action-dialogs/bulk-restore-dialog/bulk-restore-dialog.component"; import { RoutedVaultFilterBridgeService } from "../individual-vault/vault-filter/services/routed-vault-filter-bridge.service"; import { RoutedVaultFilterService } from "../individual-vault/vault-filter/services/routed-vault-filter.service"; +import { createFilterFunction } from "../individual-vault/vault-filter/shared/models/filter-function"; +import { + All, + RoutedVaultFilterModel, + Unassigned, +} from "../individual-vault/vault-filter/shared/models/routed-vault-filter.model"; +import { getNestedCollectionTree } from "../utils/collection-utils"; import { AddEditComponent } from "./add-edit.component"; import { AttachmentsComponent } from "./attachments.component"; import { CollectionsComponent } from "./collections.component"; import { VaultFilterComponent } from "./vault-filter/vault-filter.component"; -import { VaultItemsComponent } from "./vault-items.component"; const BroadcasterSubscriptionId = "OrgVaultComponent"; +const SearchTextDebounceInterval = 200; @Component({ selector: "app-org-vault", @@ -44,21 +94,38 @@ const BroadcasterSubscriptionId = "OrgVaultComponent"; providers: [RoutedVaultFilterService, RoutedVaultFilterBridgeService], }) export class VaultComponent implements OnInit, OnDestroy { + protected Unassigned = Unassigned; + @ViewChild("vaultFilter", { static: true }) vaultFilterComponent: VaultFilterComponent; - @ViewChild(VaultItemsComponent, { static: true }) vaultItemsComponent: VaultItemsComponent; @ViewChild("attachments", { read: ViewContainerRef, static: true }) attachmentsModalRef: ViewContainerRef; @ViewChild("cipherAddEdit", { read: ViewContainerRef, static: true }) cipherAddEditModalRef: ViewContainerRef; - @ViewChild("collections", { read: ViewContainerRef, static: true }) + @ViewChild("collectionsModal", { read: ViewContainerRef, static: true }) collectionsModalRef: ViewContainerRef; @ViewChild("eventsTemplate", { read: ViewContainerRef, static: true }) eventsModalRef: ViewContainerRef; - organization: Organization; trashCleanupWarning: string = null; activeFilter: VaultFilter = new VaultFilter(); + + protected noItemIcon = Icons.Search; + protected performingInitialLoad = true; + protected refreshing = false; + protected processingEvent = false; + protected filter: RoutedVaultFilterModel = {}; + protected organization: Organization; + protected allCollections: CollectionAdminView[]; + protected allGroups: GroupView[]; + protected ciphers: CipherView[]; + protected collections: CollectionAdminView[]; + protected selectedCollection: TreeNode | undefined; + protected isEmpty: boolean; + protected showMissingCollectionPermissionMessage: boolean; + + private refresh$ = new BehaviorSubject(null); + private searchText$ = new Subject(); private destroy$ = new Subject(); constructor( @@ -66,6 +133,7 @@ export class VaultComponent implements OnInit, OnDestroy { private organizationService: OrganizationService, protected vaultFilterService: VaultFilterService, private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService, + private routedVaultFilterService: RoutedVaultFilterService, private router: Router, private changeDetectorRef: ChangeDetectorRef, private syncService: SyncService, @@ -77,7 +145,15 @@ export class VaultComponent implements OnInit, OnDestroy { private ngZone: NgZone, private platformUtilsService: PlatformUtilsService, private cipherService: CipherService, - private passwordRepromptService: PasswordRepromptService + private passwordRepromptService: PasswordRepromptService, + private collectionAdminService: CollectionAdminService, + private searchService: SearchService, + private searchPipe: SearchPipe, + private groupService: GroupService, + private logService: LogService, + private eventCollectionService: EventCollectionService, + private totpService: TotpService, + private apiService: ApiService ) {} async ngOnInit() { @@ -87,25 +163,203 @@ export class VaultComponent implements OnInit, OnDestroy { : "trashCleanupWarning" ); - this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params) => { - this.organization = this.organizationService.get(params.organizationId); + const filter$ = this.routedVaultFilterService.filter$; + const organizationId$ = filter$.pipe( + map((filter) => filter.organizationId), + filter((filter) => filter !== undefined), + distinctUntilChanged() + ); + + const organization$ = organizationId$.pipe( + switchMap((organizationId) => this.organizationService.get$(organizationId)), + takeUntil(this.destroy$), + shareReplay({ refCount: false, bufferSize: 1 }) + ); + + const firstSetup$ = combineLatest([organization$, this.route.queryParams]).pipe( + first(), + switchMap(async ([organization]) => { + this.organization = organization; + + if (!organization.canUseAdminCollections) { + await this.syncService.fullSync(false); + } + + return undefined; + }), + shareReplay({ refCount: true, bufferSize: 1 }) + ); + + this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { + this.ngZone.run(async () => { + switch (message.command) { + case "syncCompleted": + if (message.successfully) { + this.refresh(); + this.changeDetectorRef.detectChanges(); + } + break; + } + }); }); - this.route.queryParams.pipe(first(), takeUntil(this.destroy$)).subscribe((qParams) => { - this.vaultItemsComponent.searchText = this.vaultFilterComponent.searchText = qParams.search; - }); + this.routedVaultFilterBridgeService.activeFilter$ + .pipe(takeUntil(this.destroy$)) + .subscribe((activeFilter) => { + this.activeFilter = activeFilter; + }); - // verifies that the organization has been set - combineLatest([this.route.queryParams, this.route.parent.params]) + this.searchText$ + .pipe(debounceTime(SearchTextDebounceInterval), takeUntil(this.destroy$)) + .subscribe((searchText) => + this.router.navigate([], { + queryParams: { search: Utils.isNullOrEmpty(searchText) ? null : searchText }, + queryParamsHandling: "merge", + replaceUrl: true, + }) + ); + + const querySearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search)); + + const allCollectionsWithoutUnassigned$ = organizationId$.pipe( + switchMap((orgId) => this.collectionAdminService.getAll(orgId)), + shareReplay({ refCount: true, bufferSize: 1 }) + ); + + const allCollections$ = combineLatest([organizationId$, allCollectionsWithoutUnassigned$]).pipe( + map(([organizationId, allCollections]) => { + const noneCollection = new CollectionAdminView(); + noneCollection.name = this.i18nService.t("unassigned"); + noneCollection.id = Unassigned; + noneCollection.organizationId = organizationId; + return allCollections.concat(noneCollection); + }) + ); + + const allGroups$ = organizationId$.pipe( + switchMap((organizationId) => this.groupService.getAll(organizationId)), + shareReplay({ refCount: true, bufferSize: 1 }) + ); + + const allCiphers$ = organization$.pipe( + concatMap(async (organization) => { + let ciphers; + if (organization.canEditAnyCollection) { + ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id); + } else { + ciphers = (await this.cipherService.getAllDecrypted()).filter( + (c) => c.organizationId === organization.id + ); + } + await this.searchService.indexCiphers(ciphers, organization.id); + return ciphers; + }) + ); + + const ciphers$ = combineLatest([allCiphers$, filter$, querySearchText$]).pipe( + filter(([ciphers, filter]) => ciphers != undefined && filter != undefined), + concatMap(async ([ciphers, filter, searchText]) => { + if (filter.collectionId === undefined && filter.type === undefined) { + return []; + } + + const filterFunction = createFilterFunction(filter); + + if (this.searchService.isSearchable(searchText)) { + return await this.searchService.searchCiphers(searchText, [filterFunction], ciphers); + } + + return ciphers.filter(filterFunction); + }), + shareReplay({ refCount: true, bufferSize: 1 }) + ); + + const nestedCollections$ = allCollections$.pipe( + map((collections) => getNestedCollectionTree(collections)), + shareReplay({ refCount: true, bufferSize: 1 }) + ); + + const collections$ = combineLatest([nestedCollections$, filter$, querySearchText$]).pipe( + filter(([collections, filter]) => collections != undefined && filter != undefined), + map(([collections, filter, searchText]) => { + if ( + filter.collectionId === Unassigned || + (filter.collectionId === undefined && filter.type !== undefined) + ) { + return []; + } + + let collectionsToReturn = []; + if (filter.collectionId === undefined || filter.collectionId === All) { + collectionsToReturn = collections.map((c) => c.node); + } else { + const selectedCollection = ServiceUtils.getTreeNodeObjectFromList( + collections, + filter.collectionId + ); + collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? []; + } + + if (this.searchService.isSearchable(searchText)) { + collectionsToReturn = this.searchPipe.transform( + collectionsToReturn, + searchText, + (collection) => collection.name, + (collection) => collection.id + ); + } + + return collectionsToReturn; + }), + takeUntil(this.destroy$), + shareReplay({ refCount: true, bufferSize: 1 }) + ); + + const selectedCollection$ = combineLatest([nestedCollections$, filter$]).pipe( + filter(([collections, filter]) => collections != undefined && filter != undefined), + map(([collections, filter]) => { + if ( + filter.collectionId === undefined || + filter.collectionId === All || + filter.collectionId === Unassigned + ) { + return undefined; + } + + return ServiceUtils.getTreeNodeObjectFromList(collections, filter.collectionId); + }), + shareReplay({ refCount: true, bufferSize: 1 }) + ); + + const showMissingCollectionPermissionMessage$ = combineLatest([ + filter$, + selectedCollection$, + organization$, + ]).pipe( + map(([filter, collection, organization]) => { + return ( + // Filtering by unassigned, show message if not admin + (filter.collectionId === Unassigned && !organization.canUseAdminCollections) || + // Filtering by a collection, so show message if user is not assigned + (collection != undefined && + !collection.node.assigned && + !organization.canUseAdminCollections) + ); + }), + shareReplay({ refCount: true, bufferSize: 1 }) + ); + + firstSetup$ .pipe( - switchMap(async ([qParams]) => { + switchMap(() => combineLatest([this.route.queryParams, organization$])), + switchMap(async ([qParams, organization]) => { const cipherId = getCipherIdFromParams(qParams); if (!cipherId) { return; } if ( // Handle users with implicit collection access since they use the admin endpoint - this.organization.canUseAdminCollections || + organization.canUseAdminCollections || (await this.cipherService.get(cipherId)) != null ) { this.editCipherId(cipherId); @@ -125,30 +379,58 @@ export class VaultComponent implements OnInit, OnDestroy { ) .subscribe(); - if (!this.organization.canUseAdminCollections) { - this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { - this.ngZone.run(async () => { - switch (message.command) { - case "syncCompleted": - if (message.successfully) { - await Promise.all([ - this.vaultFilterService.reloadCollections(), - this.vaultItemsComponent.refresh(), - ]); - this.changeDetectorRef.detectChanges(); - } - break; - } - }); - }); - await this.syncService.fullSync(false); - } + firstSetup$ + .pipe( + switchMap(() => this.refresh$), + tap(() => (this.refreshing = true)), + switchMap(() => + combineLatest([ + organization$, + filter$, + allCollections$, + allGroups$, + ciphers$, + collections$, + selectedCollection$, + showMissingCollectionPermissionMessage$, + ]) + ), + takeUntil(this.destroy$) + ) + .subscribe( + ([ + organization, + filter, + allCollections, + allGroups, + ciphers, + collections, + selectedCollection, + showMissingCollectionPermissionMessage, + ]) => { + this.organization = organization; + this.filter = filter; + this.allCollections = allCollections; + this.allGroups = allGroups; + this.ciphers = ciphers; + this.collections = collections; + this.selectedCollection = selectedCollection; + this.showMissingCollectionPermissionMessage = showMissingCollectionPermissionMessage; - this.routedVaultFilterBridgeService.activeFilter$ - .pipe(takeUntil(this.destroy$)) - .subscribe((activeFilter) => { - this.activeFilter = activeFilter; - }); + this.isEmpty = collections?.length === 0 && ciphers?.length === 0; + + // This is a temporary fix to avoid double fetching collections. + // TODO: Remove when implementing new VVR menu + this.vaultFilterService.reloadCollections(allCollections); + + this.refreshing = false; + this.performingInitialLoad = false; + } + ); + } + + get loading() { + return this.refreshing || this.processingEvent; } ngOnDestroy() { @@ -157,15 +439,50 @@ export class VaultComponent implements OnInit, OnDestroy { this.destroy$.complete(); } - async refreshItems() { - this.vaultItemsComponent.actionPromise = this.vaultItemsComponent.refresh(); - await this.vaultItemsComponent.actionPromise; - this.vaultItemsComponent.actionPromise = null; + async onVaultItemsEvent(event: VaultItemEvent) { + this.processingEvent = true; + + try { + if (event.type === "viewAttachments") { + await this.editCipherAttachments(event.item); + } else if (event.type === "viewCollections") { + await this.editCipherCollections(event.item); + } else if (event.type === "clone") { + await this.cloneCipher(event.item); + } else if (event.type === "restore") { + if (event.items.length === 1) { + await this.restore(event.items[0]); + } else { + await this.bulkRestore(event.items); + } + } else if (event.type === "delete") { + const ciphers = event.items.filter((i) => i.collection === undefined).map((i) => i.cipher); + const collections = event.items + .filter((i) => i.cipher === undefined) + .map((i) => i.collection); + if (ciphers.length === 1 && collections.length === 0) { + await this.deleteCipher(ciphers[0]); + } else if (ciphers.length === 0 && collections.length === 1) { + await this.deleteCollection(collections[0]); + } else { + await this.bulkDelete(ciphers, collections, this.organization); + } + } else if (event.type === "copyField") { + await this.copy(event.item, event.field); + } else if (event.type === "edit") { + await this.editCollection(event.item, CollectionDialogTabType.Info); + } else if (event.type === "viewAccess") { + await this.editCollection(event.item, CollectionDialogTabType.Access); + } else if (event.type === "viewEvents") { + await this.viewEvents(event.item); + } + } finally { + this.processingEvent = false; + } } filterSearchText(searchText: string) { - this.vaultItemsComponent.searchText = searchText; - this.vaultItemsComponent.search(200); + this.searchText$.next(searchText); } async editCipherAttachments(cipher: CipherView) { @@ -182,17 +499,18 @@ export class VaultComponent implements OnInit, OnDestroy { (comp) => { comp.organization = this.organization; comp.cipherId = cipher.id; - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - comp.onUploadedAttachment.subscribe(() => (madeAttachmentChanges = true)); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - comp.onDeletedAttachment.subscribe(() => (madeAttachmentChanges = true)); + comp.onUploadedAttachment + .pipe(takeUntil(this.destroy$)) + .subscribe(() => (madeAttachmentChanges = true)); + comp.onDeletedAttachment + .pipe(takeUntil(this.destroy$)) + .subscribe(() => (madeAttachmentChanges = true)); } ); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - modal.onClosed.subscribe(async () => { + modal.onClosed.pipe(takeUntil(this.destroy$)).subscribe(() => { if (madeAttachmentChanges) { - await this.vaultItemsComponent.refresh(); + this.refresh(); } madeAttachmentChanges = false; }); @@ -208,10 +526,9 @@ export class VaultComponent implements OnInit, OnDestroy { comp.collections = currCollections.filter((c) => !c.readOnly && c.id != null); comp.organization = this.organization; comp.cipherId = cipher.id; - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onSavedCollections.subscribe(async () => { + comp.onSavedCollections.pipe(takeUntil(this.destroy$)).subscribe(() => { modal.close(); - await this.vaultItemsComponent.refresh(); + this.refresh(); }); } ); @@ -258,20 +575,17 @@ export class VaultComponent implements OnInit, OnDestroy { comp.organization = this.organization; comp.organizationId = this.organization.id; comp.cipherId = cipherId; - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onSavedCipher.subscribe(async () => { + comp.onSavedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => { modal.close(); - await this.vaultItemsComponent.refresh(); + this.refresh(); }); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onDeletedCipher.subscribe(async () => { + comp.onDeletedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => { modal.close(); - await this.vaultItemsComponent.refresh(); + this.refresh(); }); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onRestoredCipher.subscribe(async () => { + comp.onRestoredCipher.pipe(takeUntil(this.destroy$)).subscribe(() => { modal.close(); - await this.vaultItemsComponent.refresh(); + this.refresh(); }); }; @@ -305,6 +619,241 @@ export class VaultComponent implements OnInit, OnDestroy { }); } + async restore(c: CipherView): Promise { + if (!(await this.repromptCipher([c]))) { + return; + } + + if (!c.isDeleted) { + return; + } + const confirmed = await this.platformUtilsService.showDialog( + this.i18nService.t("restoreItemConfirmation"), + this.i18nService.t("restoreItem"), + this.i18nService.t("yes"), + this.i18nService.t("no"), + "warning" + ); + if (!confirmed) { + return false; + } + + try { + await this.cipherService.restoreWithServer(c.id); + this.platformUtilsService.showToast("success", null, this.i18nService.t("restoredItem")); + this.refresh(); + } catch (e) { + this.logService.error(e); + } + } + + async bulkRestore(ciphers: CipherView[]) { + if (!(await this.repromptCipher(ciphers))) { + return; + } + + const selectedCipherIds = ciphers.map((cipher) => cipher.id); + if (selectedCipherIds.length === 0) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("nothingSelected") + ); + return; + } + + const dialog = openBulkRestoreDialog(this.dialogService, { + data: { cipherIds: selectedCipherIds }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === BulkRestoreDialogResult.Restored) { + this.refresh(); + } + } + + async deleteCipher(c: CipherView): Promise { + if (!(await this.repromptCipher([c]))) { + return; + } + + const permanent = c.isDeleted; + const confirmed = await this.platformUtilsService.showDialog( + this.i18nService.t( + permanent ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation" + ), + this.i18nService.t(permanent ? "permanentlyDeleteItem" : "deleteItem"), + this.i18nService.t("yes"), + this.i18nService.t("no"), + "warning" + ); + if (!confirmed) { + return false; + } + + try { + await this.deleteCipherWithServer(c.id, permanent); + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t(permanent ? "permanentlyDeletedItem" : "deletedItem") + ); + this.refresh(); + } catch (e) { + this.logService.error(e); + } + } + + async deleteCollection(collection: CollectionView): Promise { + if ( + !this.organization.canDeleteAssignedCollections && + !this.organization.canDeleteAnyCollection + ) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("missingPermissions") + ); + return; + } + const confirmed = await this.platformUtilsService.showDialog( + this.i18nService.t("deleteCollectionConfirmation"), + collection.name, + this.i18nService.t("yes"), + this.i18nService.t("no"), + "warning" + ); + if (!confirmed) { + return; + } + try { + await this.apiService.deleteCollection(this.organization?.id, collection.id); + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("deletedCollectionId", collection.name) + ); + + // Navigate away if we deleted the colletion we were viewing + if (this.selectedCollection?.node.id === collection.id) { + this.router.navigate([], { + queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + } + + this.refresh(); + } catch (e) { + this.logService.error(e); + } + } + + async bulkDelete( + ciphers: CipherView[], + collections: CollectionView[], + organization: Organization + ) { + if (!(await this.repromptCipher(ciphers))) { + return; + } + + if (ciphers.length === 0 && collections.length === 0) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("nothingSelected") + ); + return; + } + const dialog = openBulkDeleteDialog(this.dialogService, { + data: { + permanent: this.filter.type === "trash", + cipherIds: ciphers.map((c) => c.id), + collectionIds: collections.map((c) => c.id), + organization, + }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === BulkDeleteDialogResult.Deleted) { + this.refresh(); + } + } + + async copy(cipher: CipherView, field: "username" | "password" | "totp") { + let aType; + let value; + let typeI18nKey; + + if (field === "username") { + aType = "Username"; + value = cipher.login.username; + typeI18nKey = "username"; + } else if (field === "password") { + aType = "Password"; + value = cipher.login.password; + typeI18nKey = "password"; + } else if (field === "totp") { + aType = "TOTP"; + value = await this.totpService.getCode(cipher.login.totp); + typeI18nKey = "verificationCodeTotp"; + } else { + this.platformUtilsService.showToast("info", null, this.i18nService.t("unexpectedError")); + return; + } + + if ( + this.passwordRepromptService.protectedFields().includes(aType) && + !(await this.repromptCipher([cipher])) + ) { + return; + } + + if (!cipher.viewPassword) { + return; + } + + this.platformUtilsService.copyToClipboard(value, { window: window }); + this.platformUtilsService.showToast( + "info", + null, + this.i18nService.t("valueCopied", this.i18nService.t(typeI18nKey)) + ); + + if (field === "password" || field === "totp") { + this.eventCollectionService.collect( + EventType.Cipher_ClientToggledHiddenFieldVisible, + cipher.id + ); + } + } + + async addCollection(): Promise { + const dialog = openCollectionDialog(this.dialogService, { + data: { + organizationId: this.organization?.id, + parentCollectionId: this.selectedCollection?.node.id, + }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) { + this.refresh(); + } + } + + async editCollection(c: CollectionView, tab: CollectionDialogTabType): Promise { + const dialog = openCollectionDialog(this.dialogService, { + data: { collectionId: c?.id, organizationId: this.organization?.id, initialTab: tab }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) { + this.refresh(); + } + } + async viewEvents(cipher: CipherView) { await this.modalService.openViewRef(EntityEventsComponent, this.eventsModalRef, (comp) => { comp.name = cipher.name; @@ -315,6 +864,22 @@ export class VaultComponent implements OnInit, OnDestroy { }); } + protected deleteCipherWithServer(id: string, permanent: boolean) { + return permanent + ? this.cipherService.deleteWithServer(id) + : this.cipherService.softDeleteWithServer(id); + } + + protected async repromptCipher(ciphers: CipherView[]) { + const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None); + + return notProtected || (await this.passwordRepromptService.showPasswordPrompt()); + } + + private refresh() { + this.refresh$.next(); + } + private go(queryParams: any = null) { if (queryParams == null) { queryParams = { diff --git a/apps/web/src/app/vault/org-vault/vault.module.ts b/apps/web/src/app/vault/org-vault/vault.module.ts index d50498a937..070898b06c 100644 --- a/apps/web/src/app/vault/org-vault/vault.module.ts +++ b/apps/web/src/app/vault/org-vault/vault.module.ts @@ -6,12 +6,12 @@ import { LooseComponentsModule } from "../../shared/loose-components.module"; import { SharedModule } from "../../shared/shared.module"; import { OrganizationBadgeModule } from "../../vault/individual-vault/organization-badge/organization-badge.module"; import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module"; +import { VaultItemsModule } from "../components/vault-items/vault-items.module"; import { CollectionBadgeModule } from "./collection-badge/collection-badge.module"; import { GroupBadgeModule } from "./group-badge/group-badge.module"; import { VaultFilterModule } from "./vault-filter/vault-filter.module"; import { VaultHeaderComponent } from "./vault-header/vault-header.component"; -import { VaultItemsComponent } from "./vault-items.component"; import { VaultRoutingModule } from "./vault-routing.module"; import { VaultComponent } from "./vault.component"; @@ -26,8 +26,9 @@ import { VaultComponent } from "./vault.component"; OrganizationBadgeModule, PipesModule, BreadcrumbsModule, + VaultItemsModule, ], - declarations: [VaultComponent, VaultItemsComponent, VaultHeaderComponent], + declarations: [VaultComponent, VaultHeaderComponent], exports: [VaultComponent], }) export class VaultModule {} diff --git a/apps/web/src/app/vault/utils/collection-utils.ts b/apps/web/src/app/vault/utils/collection-utils.ts new file mode 100644 index 0000000000..8e83939897 --- /dev/null +++ b/apps/web/src/app/vault/utils/collection-utils.ts @@ -0,0 +1,56 @@ +import { + CollectionView, + NestingDelimiter, +} from "@bitwarden/common/admin-console/models/view/collection.view"; +import { ServiceUtils } from "@bitwarden/common/misc/serviceUtils"; +import { TreeNode } from "@bitwarden/common/models/domain/tree-node"; + +import { CollectionAdminView } from "../../admin-console/organizations/core"; + +export function getNestedCollectionTree( + collections: CollectionAdminView[] +): TreeNode[]; +export function getNestedCollectionTree(collections: CollectionView[]): TreeNode[]; +export function getNestedCollectionTree( + collections: (CollectionView | CollectionAdminView)[] +): TreeNode[] { + // Collections need to be cloned because ServiceUtils.nestedTraverse actively + // modifies the names of collections. + // These changes risk affecting collections store in StateService. + const clonedCollections = collections.map(cloneCollection); + + const nodes: TreeNode[] = []; + clonedCollections.forEach((collection) => { + const parts = + collection.name != null + ? collection.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) + : []; + ServiceUtils.nestedTraverse(nodes, 0, parts, collection, null, NestingDelimiter); + }); + return nodes; +} + +function cloneCollection(collection: CollectionView): CollectionView; +function cloneCollection(collection: CollectionAdminView): CollectionAdminView; +function cloneCollection( + collection: CollectionView | CollectionAdminView +): CollectionView | CollectionAdminView { + let cloned; + + if (collection instanceof CollectionAdminView) { + cloned = new CollectionAdminView(); + cloned.groups = [...collection.groups]; + cloned.users = [...collection.users]; + cloned.assigned = collection.assigned; + } else { + cloned = new CollectionView(); + } + + cloned.id = collection.id; + cloned.externalId = collection.externalId; + cloned.hidePasswords = collection.hidePasswords; + cloned.name = collection.name; + cloned.organizationId = collection.organizationId; + cloned.readOnly = collection.readOnly; + return cloned; +} diff --git a/libs/common/src/admin-console/models/view/collection.view.ts b/libs/common/src/admin-console/models/view/collection.view.ts index 24c53055d2..52644035c0 100644 --- a/libs/common/src/admin-console/models/view/collection.view.ts +++ b/libs/common/src/admin-console/models/view/collection.view.ts @@ -3,6 +3,8 @@ import { View } from "../../../models/view/view"; import { Collection } from "../domain/collection"; import { CollectionAccessDetailsResponse } from "../response/collection.response"; +export const NestingDelimiter = "/"; + export class CollectionView implements View, ITreeNodeObject { id: string = null; organizationId: string = null; diff --git a/libs/common/src/misc/serviceUtils.ts b/libs/common/src/misc/serviceUtils.ts index eb035a77ba..7fe07f7253 100644 --- a/libs/common/src/misc/serviceUtils.ts +++ b/libs/common/src/misc/serviceUtils.ts @@ -94,14 +94,14 @@ export class ServiceUtils { /** * Searches an array of tree nodes for a node with a matching `id` - * @param {TreeNode} nodeTree - An array of TreeNode branches that will be searched + * @param {TreeNode} nodeTree - An array of TreeNode branches that will be searched * @param {string} id - The id of the node to be found - * @returns {TreeNode} The node with a matching `id` + * @returns {TreeNode} The node with a matching `id` */ - static getTreeNodeObjectFromList( - nodeTree: TreeNode[], + static getTreeNodeObjectFromList( + nodeTree: TreeNode[], id: string - ): TreeNode { + ): TreeNode { for (let i = 0; i < nodeTree.length; i++) { if (nodeTree[i].node.id === id) { return nodeTree[i]; diff --git a/libs/common/src/misc/utils.ts b/libs/common/src/misc/utils.ts index 5fdcaf1f71..4c30822cc8 100644 --- a/libs/common/src/misc/utils.ts +++ b/libs/common/src/misc/utils.ts @@ -1,6 +1,7 @@ /* eslint-disable no-useless-escape */ import * as path from "path"; +import { Observable, of, switchMap } from "rxjs"; import { getHostname, parse } from "tldts"; import { Merge } from "type-fest"; @@ -526,6 +527,17 @@ export class Utils { return new Promise((resolve) => setTimeout(resolve, ms)); } + /** + * Generate an observable from a function that returns a promise. + * Similar to the rxjs function {@link from} with one big exception: + * {@link from} will not re-execute the function when observers resubscribe. + * {@link Util.asyncToObservable} will execute `generator` for every + * subscribe, making it ideal if the value ever needs to be refreshed. + * */ + static asyncToObservable(generator: () => Promise): Observable { + return of(undefined).pipe(switchMap(() => generator())); + } + private static isAppleMobile(win: Window) { return ( win.navigator.userAgent.match(/iPhone/i) != null || diff --git a/libs/components/src/breadcrumbs/breadcrumb.component.ts b/libs/components/src/breadcrumbs/breadcrumb.component.ts index 060154b4f6..82803f2515 100644 --- a/libs/components/src/breadcrumbs/breadcrumb.component.ts +++ b/libs/components/src/breadcrumbs/breadcrumb.component.ts @@ -1,4 +1,5 @@ import { Component, EventEmitter, Input, Output, TemplateRef, ViewChild } from "@angular/core"; +import { QueryParamsHandling } from "@angular/router"; @Component({ selector: "bit-breadcrumb", @@ -14,6 +15,9 @@ export class BreadcrumbComponent { @Input() queryParams?: Record = {}; + @Input() + queryParamsHandling?: QueryParamsHandling; + @Output() click = new EventEmitter(); diff --git a/libs/components/src/breadcrumbs/breadcrumbs.component.html b/libs/components/src/breadcrumbs/breadcrumbs.component.html index b75bf4ac51..502bb0bb8e 100644 --- a/libs/components/src/breadcrumbs/breadcrumbs.component.html +++ b/libs/components/src/breadcrumbs/breadcrumbs.component.html @@ -6,6 +6,7 @@ class="tw-my-2 tw-inline-block" [routerLink]="breadcrumb.route" [queryParams]="breadcrumb.queryParams" + [queryParamsHandling]="breadcrumb.queryParamsHandling" > @@ -43,6 +44,7 @@ linkType="primary" [routerLink]="breadcrumb.route" [queryParams]="breadcrumb.queryParams" + [queryParamsHandling]="breadcrumb.queryParamsHandling" > @@ -64,6 +66,7 @@ class="tw-my-2 tw-inline-block" [routerLink]="breadcrumb.route" [queryParams]="breadcrumb.queryParams" + [queryParamsHandling]="breadcrumb.queryParamsHandling" > diff --git a/libs/components/src/table/table.component.html b/libs/components/src/table/table.component.html index 49201e101e..b7ae674310 100644 --- a/libs/components/src/table/table.component.html +++ b/libs/components/src/table/table.component.html @@ -1,6 +1,6 @@ - +
diff --git a/libs/components/src/table/table.component.ts b/libs/components/src/table/table.component.ts index 40a2da7092..9f36d0a70f 100644 --- a/libs/components/src/table/table.component.ts +++ b/libs/components/src/table/table.component.ts @@ -26,6 +26,7 @@ export class TableBodyDirective { }) export class TableComponent implements OnDestroy, AfterContentChecked { @Input() dataSource: TableDataSource; + @Input() layout: "auto" | "fixed" = "auto"; @ContentChild(TableBodyDirective) templateVariable: TableBodyDirective; @@ -33,6 +34,15 @@ export class TableComponent implements OnDestroy, AfterContentChecked { private _initialized = false; + get tableClass() { + return [ + "tw-w-full", + "tw-leading-normal", + "tw-text-main", + this.layout === "auto" ? "tw-table-auto" : "tw-table-fixed", + ]; + } + ngAfterContentChecked(): void { if (!this._initialized && isDataSource(this.dataSource)) { this._initialized = true;