From d3539a4a44ca58b3d57ac446818920a69da0d63b Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Thu, 2 Feb 2023 13:19:48 -0800 Subject: [PATCH] [EC-886] Add functionality to vault header when viewing collections (#4602) * [EC-886] Fix i18n key in vault filter section * [EC-886] Add shared functionality to vault-filter model Common patterns for determining the current filter status are used in the vault header, filter, and items components that could be extracted to the filter model to be consistent and less repetitive. * [EC-886] Add the individual vault header component Create a vault header component for encapsulation and to reduce the complexity of the vault component. * [EC-886] Add the organizational vault header component Create a vault header component for encapsulation and to reduce the complexity of the organizational vault component. * [EC-886] Use the new vault header component in the individual vault - Remove the old header template from the vault component and introduce the component instead. - Remove redundant logic from vault component that was moved to the header component and/or vault filter model. * [EC-886] Use the new vault header component in the organization vault - Remove the old header template from the org vault component and introduce the component instead. - Remove redundant logic from vault component that was moved to the header component and/or vault filter model. * [EC-886] Adjust vault header to make the word "vault" lowercase * [EC-886] Top align vault header to prevent button jumping * [EC-886] Center align collection icon/button with header text --- .../vault-header/vault-header.component.html | 112 ++++++++ .../vault-header/vault-header.component.ts | 243 ++++++++++++++++++ .../organizations/vault/vault.component.html | 67 +---- .../organizations/vault/vault.component.ts | 105 +------- .../app/organizations/vault/vault.module.ts | 3 +- .../vault-filter-section.component.html | 2 +- .../shared/models/vault-filter.model.ts | 42 +++ .../vault-header/vault-header.component.html | 42 +++ .../vault-header/vault-header.component.ts | 85 ++++++ .../src/vault/app/vault/vault.component.html | 43 +--- .../src/vault/app/vault/vault.component.ts | 26 +- apps/web/src/vault/app/vault/vault.module.ts | 5 +- 12 files changed, 555 insertions(+), 220 deletions(-) create mode 100644 apps/web/src/app/organizations/vault/vault-header/vault-header.component.html create mode 100644 apps/web/src/app/organizations/vault/vault-header/vault-header.component.ts create mode 100644 apps/web/src/vault/app/vault/vault-header/vault-header.component.html create mode 100644 apps/web/src/vault/app/vault/vault-header/vault-header.component.ts diff --git a/apps/web/src/app/organizations/vault/vault-header/vault-header.component.html b/apps/web/src/app/organizations/vault/vault-header/vault-header.component.html new file mode 100644 index 00000000000..b01280553f7 --- /dev/null +++ b/apps/web/src/app/organizations/vault/vault-header/vault-header.component.html @@ -0,0 +1,112 @@ +
+
+ + + + + {{ activeOrganizationId | orgNameFromId: (organizations$ | async) }} + {{ "vault" | i18n | lowercase }} + + {{ collection.node.name }} + + +

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

+
+ +
+
+ + + + + +
+ +
+
diff --git a/apps/web/src/app/organizations/vault/vault-header/vault-header.component.ts b/apps/web/src/app/organizations/vault/vault-header/vault-header.component.ts new file mode 100644 index 00000000000..a388f63c41e --- /dev/null +++ b/apps/web/src/app/organizations/vault/vault-header/vault-header.component.ts @@ -0,0 +1,243 @@ +import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { Router } from "@angular/router"; +import { firstValueFrom, lastValueFrom } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/abstractions/log.service"; +import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { ProductType } from "@bitwarden/common/enums/productType"; +import { Organization } from "@bitwarden/common/models/domain/organization"; +import { TreeNode } from "@bitwarden/common/models/domain/tree-node"; +import { CollectionView } from "@bitwarden/common/models/view/collection.view"; +import { + DialogService, + SimpleDialogCloseType, + SimpleDialogOptions, + SimpleDialogType, +} from "@bitwarden/components"; + +import { VaultFilterService } from "../../../../vault/app/vault/vault-filter/services/abstractions/vault-filter.service"; +import { VaultFilter } from "../../../../vault/app/vault/vault-filter/shared/models/vault-filter.model"; +import { CollectionFilter } from "../../../../vault/app/vault/vault-filter/shared/models/vault-filter.type"; +import { CollectionAdminService, CollectionAdminView } from "../../core"; +import { + CollectionDialogResult, + CollectionDialogTabType, + openCollectionDialog, +} from "../../shared"; + +@Component({ + selector: "app-org-vault-header", + templateUrl: "./vault-header.component.html", +}) +export class VaultHeaderComponent { + /** + * The organization currently being viewed + */ + @Input() organization: Organization; + + /** + * Promise that is used to determine the loading state of the header via the ApiAction directive. + * When the promise exists and is not resolved, the loading spinner will be shown. + */ + @Input() actionPromise: Promise; + + /** + * The filter being actively applied to the vault view + */ + @Input() activeFilter: VaultFilter; + + /** + * Emits when the active filter has been modified by the header + */ + @Output() activeFilterChanged = new EventEmitter(); + + /** + * Emits an event when a collection is modified or deleted via the header collection dropdown menu + */ + @Output() onCollectionChanged = new EventEmitter(); + + /** + * Emits an event when the new item button is clicked in the header + */ + @Output() onAddCipher = new EventEmitter(); + + protected organizations$ = this.organizationService.organizations$; + + constructor( + private organizationService: OrganizationService, + private i18nService: I18nService, + private dialogService: DialogService, + private vaultFilterService: VaultFilterService, + private platformUtilsService: PlatformUtilsService, + private apiService: ApiService, + private logService: LogService, + private collectionAdminService: CollectionAdminService, + private router: Router + ) {} + + /** + * The id of the organization that is currently being filtered on. + * This can come from a collection filter, organization filter, or the current organization when viewed + * in the organization admin console and no other filters are applied. + */ + get activeOrganizationId() { + if (this.activeFilter.selectedCollectionNode != null) { + return this.activeFilter.selectedCollectionNode.node.organizationId; + } + if (this.activeFilter.selectedOrganizationNode != null) { + return this.activeFilter.selectedOrganizationNode.node.id; + } + return this.organization.id; + } + + get title() { + if (this.activeFilter.isCollectionSelected) { + return this.activeFilter.selectedCollectionNode.node.name; + } + if (this.activeFilter.isUnassignedCollectionSelected) { + return this.i18nService.t("unassigned"); + } + return `${this.organization.name} ${this.i18nService.t("vault").toLowerCase()}`; + } + + private showFreeOrgUpgradeDialog(): void { + const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = { + title: this.i18nService.t("upgradeOrganization"), + content: this.i18nService.t( + this.organization.canManageBilling + ? "freeOrgMaxCollectionReachedManageBilling" + : "freeOrgMaxCollectionReachedNoManageBilling", + this.organization.maxCollections + ), + type: SimpleDialogType.PRIMARY, + }; + + if (this.organization.canManageBilling) { + orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("upgrade"); + } else { + orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("ok"); + orgUpgradeSimpleDialogOpts.cancelButtonText = null; // hide secondary btn + } + + const simpleDialog = this.dialogService.openSimpleDialog(orgUpgradeSimpleDialogOpts); + + firstValueFrom(simpleDialog.closed).then((result: SimpleDialogCloseType | undefined) => { + if (!result) { + return; + } + + if (result == SimpleDialogCloseType.ACCEPT && this.organization.canManageBilling) { + this.router.navigate(["/organizations", this.organization.id, "billing", "subscription"], { + queryParams: { upgrade: true }, + }); + } + }); + } + + applyCollectionFilter(collection: TreeNode) { + const filter = this.activeFilter; + filter.resetFilter(); + filter.selectedCollectionNode = collection; + this.activeFilterChanged.emit(filter); + } + + canEditCollection(c: CollectionAdminView): boolean { + // Only edit collections if we're in the org vault and not editing "Unassigned" + if (this.organization === undefined || c.id === null) { + return false; + } + + // Otherwise, check if we can edit the specified collection + return ( + this.organization.canEditAnyCollection || + (this.organization.canEditAssignedCollections && c.assigned) + ); + } + + addCipher() { + this.onAddCipher.emit(); + } + + async addCollection() { + if (this.organization.planProductType === ProductType.Free) { + const collections = await this.collectionAdminService.getAll(this.organization.id); + if (collections.length === this.organization.maxCollections) { + this.showFreeOrgUpgradeDialog(); + return; + } + } + + const dialog = openCollectionDialog(this.dialogService, { + data: { + organizationId: this.organization?.id, + parentCollectionId: this.activeFilter.collectionId, + }, + }); + const result = await lastValueFrom(dialog.closed); + if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) { + this.onCollectionChanged.emit(null); + } + } + + async editCollection(c: CollectionView, tab: "info" | "access"): Promise { + const tabType = tab == "info" ? CollectionDialogTabType.Info : CollectionDialogTabType.Access; + + const dialog = openCollectionDialog(this.dialogService, { + data: { collectionId: c?.id, organizationId: this.organization?.id, initialTab: tabType }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) { + this.onCollectionChanged.emit(c); + } + } + + canDeleteCollection(c: CollectionAdminView): boolean { + // Only delete collections if we're in the org vault and not deleting "Unassigned" + if (this.organization === undefined || c.id === null) { + return false; + } + + // Otherwise, check if we can delete the specified collection + return ( + this.organization?.canDeleteAnyCollection || + (this.organization?.canDeleteAssignedCollections && c.assigned) + ); + } + + async deleteCollection(collection: CollectionView): Promise { + if (!this.organization.canDeleteAssignedCollections) { + 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 { + this.actionPromise = this.apiService.deleteCollection(this.organization?.id, collection.id); + await this.actionPromise; + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("deletedCollectionId", collection.name) + ); + this.onCollectionChanged.emit(collection); + } catch (e) { + this.logService.error(e); + } + } +} diff --git a/apps/web/src/app/organizations/vault/vault.component.html b/apps/web/src/app/organizations/vault/vault.component.html index 669bd96c47c..f24d2a4c516 100644 --- a/apps/web/src/app/organizations/vault/vault.component.html +++ b/apps/web/src/app/organizations/vault/vault.component.html @@ -18,65 +18,14 @@
- - - - {{ collection.node.name | i18n }} - {{ collection.node.name }} - - -
-

- {{ "vaultItems" | i18n }} - - - - {{ "loading" | i18n }} - - -

-
- - -
-
+ { + switchMap(async ([qParams]) => { const cipherId = getCipherIdFromParams(qParams); if (!cipherId) { return; @@ -171,68 +157,17 @@ export class VaultComponent implements OnInit, OnDestroy { this.go(); } + async refreshItems() { + this.vaultItemsComponent.actionPromise = this.vaultItemsComponent.refresh(); + await this.vaultItemsComponent.actionPromise; + this.vaultItemsComponent.actionPromise = null; + } + filterSearchText(searchText: string) { this.vaultItemsComponent.searchText = searchText; this.vaultItemsComponent.search(200); } - private showFreeOrgUpgradeDialog(): void { - const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = { - title: this.i18nService.t("upgradeOrganization"), - content: this.i18nService.t( - this.organization.canManageBilling - ? "freeOrgMaxCollectionReachedManageBilling" - : "freeOrgMaxCollectionReachedNoManageBilling", - this.organization.maxCollections - ), - type: SimpleDialogType.PRIMARY, - }; - - if (this.organization.canManageBilling) { - orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("upgrade"); - } else { - orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("ok"); - orgUpgradeSimpleDialogOpts.cancelButtonText = null; // hide secondary btn - } - - const simpleDialog = this.dialogService.openSimpleDialog(orgUpgradeSimpleDialogOpts); - - firstValueFrom(simpleDialog.closed).then((result: SimpleDialogCloseType | undefined) => { - if (!result) { - return; - } - - if (result == SimpleDialogCloseType.ACCEPT && this.organization.canManageBilling) { - this.router.navigate(["/organizations", this.organization.id, "billing", "subscription"], { - queryParams: { upgrade: true }, - }); - } - }); - } - - async addCollection() { - if (this.organization.planProductType === ProductType.Free) { - const collections = await this.collectionAdminService.getAll(this.organization.id); - if (collections.length === this.organization.maxCollections) { - this.showFreeOrgUpgradeDialog(); - return; - } - } - - const dialog = openCollectionDialog(this.dialogService, { - data: { - organizationId: this.organization?.id, - parentCollectionId: this.activeFilter.collectionId, - }, - }); - const result = await lastValueFrom(dialog.closed); - if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) { - this.vaultItemsComponent.actionPromise = this.vaultItemsComponent.refresh(); - await this.vaultItemsComponent.actionPromise; - this.vaultItemsComponent.actionPromise = null; - } - } - async editCipherAttachments(cipher: CipherView) { if (this.organization.maxStorageGb == null || this.organization.maxStorageGb === 0) { this.messagingService.send("upgradeOrganization", { organizationId: cipher.organizationId }); @@ -362,26 +297,6 @@ export class VaultComponent implements OnInit, OnDestroy { }); } - get breadcrumbs(): TreeNode[] { - if (!this.activeFilter.selectedCollectionNode) { - return []; - } - - const collections = [this.activeFilter.selectedCollectionNode]; - while (collections[collections.length - 1].parent != undefined) { - collections.push(collections[collections.length - 1].parent); - } - - return collections.map((c) => c).reverse(); - } - - protected applyCollectionFilter(collection: TreeNode) { - const filter = this.activeFilter; - filter.resetFilter(); - filter.selectedCollectionNode = collection; - this.applyVaultFilter(filter); - } - private go(queryParams: any = null) { if (queryParams == null) { queryParams = { diff --git a/apps/web/src/app/organizations/vault/vault.module.ts b/apps/web/src/app/organizations/vault/vault.module.ts index 9bd25cb761e..b7a769237ab 100644 --- a/apps/web/src/app/organizations/vault/vault.module.ts +++ b/apps/web/src/app/organizations/vault/vault.module.ts @@ -10,6 +10,7 @@ import { SharedModule } from "../../shared/shared.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,7 +27,7 @@ import { VaultComponent } from "./vault.component"; PipesModule, BreadcrumbsModule, ], - declarations: [VaultComponent, VaultItemsComponent], + declarations: [VaultComponent, VaultItemsComponent, VaultHeaderComponent], exports: [VaultComponent], }) export class VaultModule {} diff --git a/apps/web/src/vault/app/vault/vault-filter/shared/components/vault-filter-section.component.html b/apps/web/src/vault/app/vault/vault-filter/shared/components/vault-filter-section.component.html index c01d1102258..2759ef077d1 100644 --- a/apps/web/src/vault/app/vault/vault-filter/shared/components/vault-filter-section.component.html +++ b/apps/web/src/vault/app/vault/vault-filter/shared/components/vault-filter-section.component.html @@ -17,7 +17,7 @@ +
+ diff --git a/apps/web/src/vault/app/vault/vault-header/vault-header.component.ts b/apps/web/src/vault/app/vault/vault-header/vault-header.component.ts new file mode 100644 index 00000000000..cd6e480f5da --- /dev/null +++ b/apps/web/src/vault/app/vault/vault-header/vault-header.component.ts @@ -0,0 +1,85 @@ +import { Component, EventEmitter, Input, Output } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; +import { TreeNode } from "@bitwarden/common/models/domain/tree-node"; + +import { VaultFilter } from "../vault-filter/shared/models/vault-filter.model"; +import { CollectionFilter } from "../vault-filter/shared/models/vault-filter.type"; + +@Component({ + selector: "app-vault-header", + templateUrl: "./vault-header.component.html", +}) +export class VaultHeaderComponent { + /** + * Promise that is used to determine the loading state of the header via the ApiAction directive. + * When the promise exists and is not resolved, the loading spinner will be shown. + */ + @Input() actionPromise: Promise; + + /** + * The filter being actively applied to the vault view + */ + @Input() activeFilter: VaultFilter; + + /** + * Emits when the active filter has been modified by the header + */ + @Output() activeFilterChanged = new EventEmitter(); + + /** + * Emits an event when the new item button is clicked in the header + */ + @Output() onAddCipher = new EventEmitter(); + + organizations$ = this.organizationService.organizations$; + + constructor(private organizationService: OrganizationService, private i18nService: I18nService) {} + + /** + * The id of the organization that is currently being filtered on. + * This can come from a collection filter or organization filter, if applied. + */ + get activeOrganizationId() { + if (this.activeFilter.selectedCollectionNode != null) { + return this.activeFilter.selectedCollectionNode.node.organizationId; + } + if (this.activeFilter.selectedOrganizationNode != null) { + return this.activeFilter.selectedOrganizationNode.node.id; + } + return undefined; + } + + get title() { + if (this.activeFilter.isCollectionSelected) { + if (this.activeFilter.isUnassignedCollectionSelected) { + return this.i18nService.t("unassigned"); + } + return this.activeFilter.selectedCollectionNode.node.name; + } + + if (this.activeFilter.isMyVaultSelected) { + return this.i18nService.t("myVault"); + } + + if (this.activeFilter?.selectedOrganizationNode != null) { + return `${this.activeFilter.selectedOrganizationNode.node.name} ${this.i18nService + .t("vault") + .toLowerCase()}`; + } + + return this.i18nService.t("allVaults"); + } + + applyCollectionFilter(collection: TreeNode) { + const filter = this.activeFilter; + filter.resetFilter(); + filter.selectedCollectionNode = collection; + this.activeFilterChanged.emit(filter); + } + + addCipher() { + this.onAddCipher.emit(); + } +} diff --git a/apps/web/src/vault/app/vault/vault.component.html b/apps/web/src/vault/app/vault/vault.component.html index 4cfee0d2fbf..ceb82cfa38c 100644 --- a/apps/web/src/vault/app/vault/vault.component.html +++ b/apps/web/src/vault/app/vault/vault.component.html @@ -17,43 +17,12 @@
- - - - {{ collection.node.name | i18n }} - {{ collection.node.name }} - - -
-

- {{ "vaultItems" | i18n }} - - - - {{ "loading" | i18n }} - - -

-
- -
-
+ {{ trashCleanupWarning }} diff --git a/apps/web/src/vault/app/vault/vault.component.ts b/apps/web/src/vault/app/vault/vault.component.ts index b58d53d8ff0..37204662120 100644 --- a/apps/web/src/vault/app/vault/vault.component.ts +++ b/apps/web/src/vault/app/vault/vault.component.ts @@ -38,11 +38,7 @@ import { ShareComponent } from "./share.component"; import { VaultFilterComponent } from "./vault-filter/components/vault-filter.component"; import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service"; import { VaultFilter } from "./vault-filter/shared/models/vault-filter.model"; -import { - CollectionFilter, - FolderFilter, - OrganizationFilter, -} from "./vault-filter/shared/models/vault-filter.type"; +import { FolderFilter, OrganizationFilter } from "./vault-filter/shared/models/vault-filter.type"; import { VaultItemsComponent } from "./vault-items.component"; const BroadcasterSubscriptionId = "VaultComponent"; @@ -394,26 +390,6 @@ export class VaultComponent implements OnInit, OnDestroy { return kdfType === KdfType.PBKDF2_SHA256 && kdfOptions.iterations < DEFAULT_PBKDF2_ITERATIONS; } - get breadcrumbs(): TreeNode[] { - if (!this.activeFilter.selectedCollectionNode) { - return []; - } - - const collections = [this.activeFilter.selectedCollectionNode]; - while (collections[collections.length - 1].parent != undefined) { - collections.push(collections[collections.length - 1].parent); - } - - return collections.map((c) => c).reverse(); - } - - protected applyCollectionFilter(collection: TreeNode) { - const filter = this.activeFilter; - filter.resetFilter(); - filter.selectedCollectionNode = collection; - this.applyVaultFilter(filter); - } - private go(queryParams: any = null) { if (queryParams == null) { queryParams = { diff --git a/apps/web/src/vault/app/vault/vault.module.ts b/apps/web/src/vault/app/vault/vault.module.ts index 4b3cdac4d0d..8d03a3cc00a 100644 --- a/apps/web/src/vault/app/vault/vault.module.ts +++ b/apps/web/src/vault/app/vault/vault.module.ts @@ -4,12 +4,13 @@ import { BreadcrumbsModule } from "@bitwarden/components"; import { CollectionBadgeModule } from "../../../app/organizations/vault/collection-badge/collection-badge.module"; import { GroupBadgeModule } from "../../../app/organizations/vault/group-badge/group-badge.module"; -import { SharedModule, LooseComponentsModule } from "../../../app/shared"; +import { LooseComponentsModule, SharedModule } from "../../../app/shared"; import { BulkDialogsModule } from "./bulk-action-dialogs/bulk-dialogs.module"; import { OrganizationBadgeModule } from "./organization-badge/organization-badge.module"; 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,7 +28,7 @@ import { VaultComponent } from "./vault.component"; BulkDialogsModule, BreadcrumbsModule, ], - declarations: [VaultComponent, VaultItemsComponent], + declarations: [VaultComponent, VaultItemsComponent, VaultHeaderComponent], exports: [VaultComponent], }) export class VaultModule {}