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 {}