From 141ade3c381cb9ec8e89f15061b92330cb32d403 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Mon, 9 May 2022 08:09:46 -0400 Subject: [PATCH] [feat] End User Vault Refresh (#790) * Move access logic to org model (#713) * [feature] Allow for top level groupings to be collapsed (#712) * [End User Vault Refresh] Refactor route permission checking (#727) * Update admin access logic * Centralize route permission handling * Add permission check for disabled orgs * [EndUserVaultRefresh] Add base routing guard (#732) * Add a base class for Angular routing guards * Update Guard naming convention * Bump node-forge to 1.2.1 (#722) * Remove Internet Explorer logic (#723) * Username generator (#734) * add support for username generation * remove unused Router * pr feedback * Bump electron and related dependencies (#736) * PS-91 make isMacAppStore return true/false (#735) * return false if undefined from isMacAppStore * PS-91 use strict equality instead of null coalescing Co-authored-by: Chad Scharf <3904944+cscharf@users.noreply.github.com> Co-authored-by: Chad Scharf <3904944+cscharf@users.noreply.github.com> * [bug] Fix Safari CSV importer for URL and Notes (#730) * Fix import path for safari importer (#740) * Force updates to be silent (#739) * support for username gen website setting (#738) * Fix jslibModule forms (#742) * Add DatePipe provider to JslibModule (#741) * Feature/move to jest (#744) * Switch to jest * Fix jslib-angular package name * Make angular test project * Split up tests by jslib project * Remove obsolete node test script * Use legacy deps with jest-preset-angular * Move web tests to common * Remove build from pipeline This was only being used because we were not using ts runners. We are now, so build is unnecessary * Remove the VerifyMasterPasswordComponent from jslib module (#747) * Add ellipsis pipe to jslib module (#746) * add ellipsis pipe to jslib module * Add ellipsis pipe to exports * Add ColorPasswordCountPipe to JslibModule (#751) * Generator cleanup (#753) * type is null by default * rename generator component * remove showWebsiteOption * shorthand if check * EC-134 Fix api token refresh (#749) * Fix apikey token refresh * Refactor: use class for TokenRequestTwoFactor * Remove keytar and biometric logic (#706) * [bug] CL - fix default button display and callout header class (#756) * [EC-142] Fix error during import of 1pux containing new email field format (#758) * Add support for complex email field type * Ensure complex email field type gets imported on identities * [euvr] Separate Billing Payment/History APIs (#750) * [euvr] Separate Billing Payment/History APIs * Updated to new accounts billing API * Removed getUserBilling as it will become obsolete once merged * [end user vault refresh] Base Changes For Vault Filters (#737) * [dependency] Update icons * Avoid duplicate fullSync api calls (#716) * Tweak component library slightly (#715) * Check runtime name vs mangled name (#724) * Add Chromatic (#719) * Update SECURITY.md (#725) * Update SECURITY.md Add link to our HackerOne program for submitting potential security issues. * Revise language on SECURITY.md * Remove error Response type check (#731) * Remove error Response type check Minimization is impacting type checking in a non-consistent way. The previous type check works locally, but not from build artifacts :shrug:. We only set `captchaRequired` on our errors when we want a resubmit with captcha included, so we're safe keying off that * linter * [JslibModule] Add JslibModule (#733) * Add ellipsis pipe (#728) * add ellipsis pipe * run prettier * Account for ellipsis length in returned string * Fix complete words case * Fix another complete words issue * fix for if there are not spaces in long value * extract length check to beginning of method * condense if statements * remove log * [refactor] Add optional folders param to folderService.getAllNested() This will be used later for use cases where the vault filters service needs to build a list of nested folders that have been filtered by organization * [feature] Add organization filters This is an MVP implementation of the changes needed for the vault refresh. This includes collapsable top level groupings, and organization based filters that dynamically adjust folders and collections. * [refactor] Break down vault filter into several components These changes rename and rewrite the GroupingsComponent into a VaultFiltersModule. The module follows typical angular patterns for structure and purpose, and contain components for each filter type. The mostly communicate via Input and Output, and depend on a VaultFilterService for sending and recieving data from other parts of the product. * [bug] Add missing events for folder add/edit * [refactor] Dont directly change activeFilter in VaultFilterComponent * [refactor] Move DisplayMode to a dedicated file Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Co-authored-by: Oscar Hinton Co-authored-by: Matt Gibson Co-authored-by: Chad Scharf <3904944+cscharf@users.noreply.github.com> Co-authored-by: Robyn MacCallum * [CL-16 Component Library] Menu Dropdown (#761) * [bug] Add missing null check in vault filters (#769) * [bug] Add @Injectable to VaultFilterService (#781) * [fix] Ran prettier * [fix] Fix merge issue I used createUrlTree when merging guards because I knew that was the angular standard, didn't notice that redirect was a helper method from us * Remove BaseGuard (#791) Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Co-authored-by: Thomas Rittson Co-authored-by: Oscar Hinton Co-authored-by: Kyle Spearrin Co-authored-by: Jake Fink Co-authored-by: Chad Scharf <3904944+cscharf@users.noreply.github.com> Co-authored-by: David Frankel <42774874+frankeld@users.noreply.github.com> Co-authored-by: Daniel James Smith Co-authored-by: Matt Gibson Co-authored-by: Robyn MacCallum Co-authored-by: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Co-authored-by: Vincent Salucci --- angular/src/components/groupings.component.ts | 157 ------------------ .../auth.guard.ts} | 2 +- .../lock.guard.ts} | 5 +- .../unauth.guard.ts} | 2 +- .../components/collection-filter.component.ts | 51 ++++++ .../components/folder-filter.component.ts | 58 +++++++ .../organization-filter.component.ts | 78 +++++++++ .../components/status-filter.component.ts | 22 +++ .../components/type-filter.component.ts | 40 +++++ .../models/cipher-status.model.ts | 1 + .../vault-filter/models/display-mode.ts | 6 + .../models/dynamic-tree-node.model.ts | 16 ++ .../models/top-level-tree-node.model.ts | 7 + .../vault-filter/models/vault-filter.model.ts | 32 ++++ .../vault-filter/vault-filter.component.ts | 108 ++++++++++++ .../vault-filter/vault-filter.service.ts | 82 +++++++++ angular/src/scss/bwicons/fonts/bwi-font.svg | 6 +- angular/src/scss/bwicons/fonts/bwi-font.ttf | Bin 68412 -> 70536 bytes angular/src/scss/bwicons/fonts/bwi-font.woff | Bin 68488 -> 70612 bytes angular/src/scss/bwicons/fonts/bwi-font.woff2 | Bin 29820 -> 30592 bytes angular/src/scss/bwicons/styles/style.scss | 4 + angular/src/services/jslib-services.module.ts | 13 +- common/src/abstractions/api.service.ts | 7 +- common/src/abstractions/folder.service.ts | 2 +- common/src/models/domain/organization.ts | 23 +++ .../models/response/billingHistoryResponse.ts | 23 +++ .../models/response/billingPaymentResponse.ts | 14 ++ common/src/services/api.service.ts | 19 ++- common/src/services/folder.service.ts | 4 +- components/.storybook/preview.js | 12 +- components/package-lock.json | 1 + components/package.json | 3 +- components/src/index.ts | 1 + components/src/menu/index.ts | 5 + .../src/menu/menu-divider.component.html | 4 + components/src/menu/menu-divider.component.ts | 7 + components/src/menu/menu-item.component.ts | 37 +++++ .../src/menu/menu-trigger-for.directive.ts | 119 +++++++++++++ components/src/menu/menu.component.html | 9 + components/src/menu/menu.component.spec.ts | 77 +++++++++ components/src/menu/menu.component.ts | 30 ++++ components/src/menu/menu.module.ts | 15 ++ components/src/menu/menu.stories.ts | 69 ++++++++ components/src/styles.scss | 2 + 44 files changed, 992 insertions(+), 181 deletions(-) delete mode 100644 angular/src/components/groupings.component.ts rename angular/src/{services/auth-guard.service.ts => guards/auth.guard.ts} (96%) rename angular/src/{services/lock-guard.service.ts => guards/lock.guard.ts} (86%) rename angular/src/{services/unauth-guard.service.ts => guards/unauth.guard.ts} (92%) create mode 100644 angular/src/modules/vault-filter/components/collection-filter.component.ts create mode 100644 angular/src/modules/vault-filter/components/folder-filter.component.ts create mode 100644 angular/src/modules/vault-filter/components/organization-filter.component.ts create mode 100644 angular/src/modules/vault-filter/components/status-filter.component.ts create mode 100644 angular/src/modules/vault-filter/components/type-filter.component.ts create mode 100644 angular/src/modules/vault-filter/models/cipher-status.model.ts create mode 100644 angular/src/modules/vault-filter/models/display-mode.ts create mode 100644 angular/src/modules/vault-filter/models/dynamic-tree-node.model.ts create mode 100644 angular/src/modules/vault-filter/models/top-level-tree-node.model.ts create mode 100644 angular/src/modules/vault-filter/models/vault-filter.model.ts create mode 100644 angular/src/modules/vault-filter/vault-filter.component.ts create mode 100644 angular/src/modules/vault-filter/vault-filter.service.ts create mode 100644 common/src/models/response/billingHistoryResponse.ts create mode 100644 common/src/models/response/billingPaymentResponse.ts create mode 100644 components/src/menu/index.ts create mode 100644 components/src/menu/menu-divider.component.html create mode 100644 components/src/menu/menu-divider.component.ts create mode 100644 components/src/menu/menu-item.component.ts create mode 100644 components/src/menu/menu-trigger-for.directive.ts create mode 100644 components/src/menu/menu.component.html create mode 100644 components/src/menu/menu.component.spec.ts create mode 100644 components/src/menu/menu.component.ts create mode 100644 components/src/menu/menu.module.ts create mode 100644 components/src/menu/menu.stories.ts diff --git a/angular/src/components/groupings.component.ts b/angular/src/components/groupings.component.ts deleted file mode 100644 index aa5a743f331..00000000000 --- a/angular/src/components/groupings.component.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { Directive, EventEmitter, Input, Output } from "@angular/core"; - -import { CollectionService } from "jslib-common/abstractions/collection.service"; -import { FolderService } from "jslib-common/abstractions/folder.service"; -import { StateService } from "jslib-common/abstractions/state.service"; -import { CipherType } from "jslib-common/enums/cipherType"; -import { TreeNode } from "jslib-common/models/domain/treeNode"; -import { CollectionView } from "jslib-common/models/view/collectionView"; -import { FolderView } from "jslib-common/models/view/folderView"; - -@Directive() -export class GroupingsComponent { - @Input() showFolders = true; - @Input() showCollections = true; - @Input() showFavorites = true; - @Input() showTrash = true; - - @Output() onAllClicked = new EventEmitter(); - @Output() onFavoritesClicked = new EventEmitter(); - @Output() onTrashClicked = new EventEmitter(); - @Output() onCipherTypeClicked = new EventEmitter(); - @Output() onFolderClicked = new EventEmitter(); - @Output() onAddFolder = new EventEmitter(); - @Output() onEditFolder = new EventEmitter(); - @Output() onCollectionClicked = new EventEmitter(); - - folders: FolderView[]; - nestedFolders: TreeNode[]; - collections: CollectionView[]; - nestedCollections: TreeNode[]; - loaded = false; - cipherType = CipherType; - selectedAll = false; - selectedFavorites = false; - selectedTrash = false; - selectedType: CipherType = null; - selectedFolder = false; - selectedFolderId: string = null; - selectedCollectionId: string = null; - - private collapsedGroupings: Set; - - constructor( - protected collectionService: CollectionService, - protected folderService: FolderService, - protected stateService: StateService - ) {} - - async load(setLoaded = true) { - const collapsedGroupings = await this.stateService.getCollapsedGroupings(); - if (collapsedGroupings == null) { - this.collapsedGroupings = new Set(); - } else { - this.collapsedGroupings = new Set(collapsedGroupings); - } - - await this.loadFolders(); - await this.loadCollections(); - - if (setLoaded) { - this.loaded = true; - } - } - - async loadCollections(organizationId?: string) { - if (!this.showCollections) { - return; - } - const collections = await this.collectionService.getAllDecrypted(); - if (organizationId != null) { - this.collections = collections.filter((c) => c.organizationId === organizationId); - } else { - this.collections = collections; - } - this.nestedCollections = await this.collectionService.getAllNested(this.collections); - } - - async loadFolders() { - if (!this.showFolders) { - return; - } - this.folders = await this.folderService.getAllDecrypted(); - this.nestedFolders = await this.folderService.getAllNested(); - } - - selectAll() { - this.clearSelections(); - this.selectedAll = true; - this.onAllClicked.emit(); - } - - selectFavorites() { - this.clearSelections(); - this.selectedFavorites = true; - this.onFavoritesClicked.emit(); - } - - selectTrash() { - this.clearSelections(); - this.selectedTrash = true; - this.onTrashClicked.emit(); - } - - selectType(type: CipherType) { - this.clearSelections(); - this.selectedType = type; - this.onCipherTypeClicked.emit(type); - } - - selectFolder(folder: FolderView) { - this.clearSelections(); - this.selectedFolder = true; - this.selectedFolderId = folder.id; - this.onFolderClicked.emit(folder); - } - - addFolder() { - this.onAddFolder.emit(); - } - - editFolder(folder: FolderView) { - this.onEditFolder.emit(folder); - } - - selectCollection(collection: CollectionView) { - this.clearSelections(); - this.selectedCollectionId = collection.id; - this.onCollectionClicked.emit(collection); - } - - clearSelections() { - this.selectedAll = false; - this.selectedFavorites = false; - this.selectedTrash = false; - this.selectedType = null; - this.selectedFolder = false; - this.selectedFolderId = null; - this.selectedCollectionId = null; - } - - async collapse(grouping: FolderView | CollectionView, idPrefix = "") { - if (grouping.id == null) { - return; - } - const id = idPrefix + grouping.id; - if (this.isCollapsed(grouping, idPrefix)) { - this.collapsedGroupings.delete(id); - } else { - this.collapsedGroupings.add(id); - } - await this.stateService.setCollapsedGroupings(Array.from(this.collapsedGroupings)); - } - - isCollapsed(grouping: FolderView | CollectionView, idPrefix = "") { - return this.collapsedGroupings.has(idPrefix + grouping.id); - } -} diff --git a/angular/src/services/auth-guard.service.ts b/angular/src/guards/auth.guard.ts similarity index 96% rename from angular/src/services/auth-guard.service.ts rename to angular/src/guards/auth.guard.ts index b9f8ebfdcfe..355f797bf3a 100644 --- a/angular/src/services/auth-guard.service.ts +++ b/angular/src/guards/auth.guard.ts @@ -7,7 +7,7 @@ import { MessagingService } from "jslib-common/abstractions/messaging.service"; import { AuthenticationStatus } from "jslib-common/enums/authenticationStatus"; @Injectable() -export class AuthGuardService implements CanActivate { +export class AuthGuard implements CanActivate { constructor( private authService: AuthService, private router: Router, diff --git a/angular/src/services/lock-guard.service.ts b/angular/src/guards/lock.guard.ts similarity index 86% rename from angular/src/services/lock-guard.service.ts rename to angular/src/guards/lock.guard.ts index 2a44db72039..05f2bac6e91 100644 --- a/angular/src/services/lock-guard.service.ts +++ b/angular/src/guards/lock.guard.ts @@ -5,7 +5,7 @@ import { AuthService } from "jslib-common/abstractions/auth.service"; import { AuthenticationStatus } from "jslib-common/enums/authenticationStatus"; @Injectable() -export class LockGuardService implements CanActivate { +export class LockGuard implements CanActivate { protected homepage = "vault"; protected loginpage = "login"; constructor(private authService: AuthService, private router: Router) {} @@ -20,7 +20,6 @@ export class LockGuardService implements CanActivate { const redirectUrl = authStatus === AuthenticationStatus.LoggedOut ? [this.loginpage] : [this.homepage]; - this.router.navigate(redirectUrl); - return false; + return this.router.createUrlTree([redirectUrl]); } } diff --git a/angular/src/services/unauth-guard.service.ts b/angular/src/guards/unauth.guard.ts similarity index 92% rename from angular/src/services/unauth-guard.service.ts rename to angular/src/guards/unauth.guard.ts index 9c309d9afdb..3335d636d4f 100644 --- a/angular/src/services/unauth-guard.service.ts +++ b/angular/src/guards/unauth.guard.ts @@ -5,7 +5,7 @@ import { AuthService } from "jslib-common/abstractions/auth.service"; import { AuthenticationStatus } from "jslib-common/enums/authenticationStatus"; @Injectable() -export class UnauthGuardService implements CanActivate { +export class UnauthGuard implements CanActivate { protected homepage = "vault"; constructor(private authService: AuthService, private router: Router) {} diff --git a/angular/src/modules/vault-filter/components/collection-filter.component.ts b/angular/src/modules/vault-filter/components/collection-filter.component.ts new file mode 100644 index 00000000000..67a21200a5b --- /dev/null +++ b/angular/src/modules/vault-filter/components/collection-filter.component.ts @@ -0,0 +1,51 @@ +import { Directive, EventEmitter, Input, Output } from "@angular/core"; + +import { ITreeNodeObject } from "jslib-common/models/domain/treeNode"; +import { CollectionView } from "jslib-common/models/view/collectionView"; + +import { DynamicTreeNode } from "../models/dynamic-tree-node.model"; +import { TopLevelTreeNode } from "../models/top-level-tree-node.model"; +import { VaultFilter } from "../models/vault-filter.model"; + +@Directive() +export class CollectionFilterComponent { + @Input() hide = false; + @Input() collapsedFilterNodes: Set; + @Input() collectionNodes: DynamicTreeNode; + @Input() activeFilter: VaultFilter; + + @Output() onNodeCollapseStateChange: EventEmitter = + new EventEmitter(); + @Output() onFilterChange: EventEmitter = new EventEmitter(); + + readonly collectionsGrouping: TopLevelTreeNode = { + id: "collections", + name: "collections", + }; + + get collections() { + return this.collectionNodes?.fullList; + } + + get nestedCollections() { + return this.collectionNodes?.nestedList; + } + + get show() { + return !this.hide && this.collections != null && this.collections.length > 0; + } + + isCollapsed(node: ITreeNodeObject) { + return this.collapsedFilterNodes.has(node.id); + } + + applyFilter(collection: CollectionView) { + this.activeFilter.resetFilter(); + this.activeFilter.selectedCollectionId = collection.id; + this.onFilterChange.emit(this.activeFilter); + } + + async toggleCollapse(node: ITreeNodeObject) { + this.onNodeCollapseStateChange.emit(node); + } +} diff --git a/angular/src/modules/vault-filter/components/folder-filter.component.ts b/angular/src/modules/vault-filter/components/folder-filter.component.ts new file mode 100644 index 00000000000..fe9537a7056 --- /dev/null +++ b/angular/src/modules/vault-filter/components/folder-filter.component.ts @@ -0,0 +1,58 @@ +import { Directive, EventEmitter, Input, Output } from "@angular/core"; + +import { ITreeNodeObject } from "jslib-common/models/domain/treeNode"; +import { FolderView } from "jslib-common/models/view/folderView"; + +import { DynamicTreeNode } from "../models/dynamic-tree-node.model"; +import { TopLevelTreeNode } from "../models/top-level-tree-node.model"; +import { VaultFilter } from "../models/vault-filter.model"; + +@Directive() +export class FolderFilterComponent { + @Input() hide = false; + @Input() collapsedFilterNodes: Set; + @Input() folderNodes: DynamicTreeNode; + @Input() activeFilter: VaultFilter; + + @Output() onNodeCollapseStateChange: EventEmitter = + new EventEmitter(); + @Output() onFilterChange: EventEmitter = new EventEmitter(); + @Output() onAddFolder = new EventEmitter(); + @Output() onEditFolder = new EventEmitter(); + + get folders() { + return this.folderNodes?.fullList; + } + + get nestedFolders() { + return this.folderNodes?.nestedList; + } + + readonly foldersGrouping: TopLevelTreeNode = { + id: "folders", + name: "folders", + }; + + applyFilter(folder: FolderView) { + this.activeFilter.resetFilter(); + this.activeFilter.selectedFolder = true; + this.activeFilter.selectedFolderId = folder.id; + this.onFilterChange.emit(this.activeFilter); + } + + addFolder() { + this.onAddFolder.emit(); + } + + editFolder(folder: FolderView) { + this.onEditFolder.emit(folder); + } + + isCollapsed(node: ITreeNodeObject) { + return this.collapsedFilterNodes.has(node.id); + } + + async toggleCollapse(node: ITreeNodeObject) { + this.onNodeCollapseStateChange.emit(node); + } +} diff --git a/angular/src/modules/vault-filter/components/organization-filter.component.ts b/angular/src/modules/vault-filter/components/organization-filter.component.ts new file mode 100644 index 00000000000..79c2c955165 --- /dev/null +++ b/angular/src/modules/vault-filter/components/organization-filter.component.ts @@ -0,0 +1,78 @@ +import { Directive, EventEmitter, Input, Output } from "@angular/core"; + +import { Organization } from "jslib-common/models/domain/organization"; +import { ITreeNodeObject } from "jslib-common/models/domain/treeNode"; + +import { DisplayMode } from "../models/display-mode"; +import { TopLevelTreeNode } from "../models/top-level-tree-node.model"; +import { VaultFilter } from "../models/vault-filter.model"; + +@Directive() +export class OrganizationFilterComponent { + @Input() hide = false; + @Input() collapsedFilterNodes: Set; + @Input() organizations: Organization[]; + @Input() activeFilter: VaultFilter; + @Input() activePersonalOwnershipPolicy: boolean; + @Input() activeSingleOrganizationPolicy: boolean; + + @Output() onNodeCollapseStateChange: EventEmitter = + new EventEmitter(); + @Output() onFilterChange: EventEmitter = new EventEmitter(); + + get displayMode(): DisplayMode { + let displayMode: DisplayMode = "organizationMember"; + if (this.organizations == null || this.organizations.length < 1) { + displayMode = "noOrganizations"; + } else if (this.activePersonalOwnershipPolicy && !this.activeSingleOrganizationPolicy) { + displayMode = "personalOwnershipPolicy"; + } else if (!this.activePersonalOwnershipPolicy && this.activeSingleOrganizationPolicy) { + displayMode = "singleOrganizationPolicy"; + } else if (this.activePersonalOwnershipPolicy && this.activeSingleOrganizationPolicy) { + displayMode = "singleOrganizationAndPersonalOwnershipPolicies"; + } + + return displayMode; + } + + get hasActiveFilter() { + return this.activeFilter.myVaultOnly || this.activeFilter.selectedOrganizationId != null; + } + + readonly organizationGrouping: TopLevelTreeNode = { + id: "vaults", + name: "allVaults", + }; + + async applyOrganizationFilter(organization: Organization) { + this.activeFilter.selectedOrganizationId = organization.id; + this.activeFilter.myVaultOnly = false; + this.activeFilter.refreshCollectionsAndFolders = true; + this.applyFilter(this.activeFilter); + } + + async applyMyVaultFilter() { + this.activeFilter.selectedOrganizationId = null; + this.activeFilter.myVaultOnly = true; + this.activeFilter.refreshCollectionsAndFolders = true; + this.applyFilter(this.activeFilter); + } + + clearFilter() { + this.activeFilter.myVaultOnly = false; + this.activeFilter.selectedOrganizationId = null; + this.applyFilter(new VaultFilter(this.activeFilter)); + } + + private applyFilter(filter: VaultFilter) { + this.onFilterChange.emit(filter); + } + + async toggleCollapse() { + this.onNodeCollapseStateChange.emit(this.organizationGrouping); + } + + get isCollapsed() { + return this.collapsedFilterNodes.has(this.organizationGrouping.id); + } +} diff --git a/angular/src/modules/vault-filter/components/status-filter.component.ts b/angular/src/modules/vault-filter/components/status-filter.component.ts new file mode 100644 index 00000000000..fe182ad0771 --- /dev/null +++ b/angular/src/modules/vault-filter/components/status-filter.component.ts @@ -0,0 +1,22 @@ +import { Directive, EventEmitter, Input, Output } from "@angular/core"; + +import { CipherStatus } from "../models/cipher-status.model"; +import { VaultFilter } from "../models/vault-filter.model"; + +@Directive() +export class StatusFilterComponent { + @Input() hideFavorites = false; + @Input() hideTrash = false; + @Output() onFilterChange: EventEmitter = new EventEmitter(); + @Input() activeFilter: VaultFilter; + + get show() { + return !this.hideFavorites && !this.hideTrash; + } + + applyFilter(cipherStatus: CipherStatus) { + this.activeFilter.resetFilter(); + this.activeFilter.status = cipherStatus; + this.onFilterChange.emit(this.activeFilter); + } +} diff --git a/angular/src/modules/vault-filter/components/type-filter.component.ts b/angular/src/modules/vault-filter/components/type-filter.component.ts new file mode 100644 index 00000000000..49aeaa81ff0 --- /dev/null +++ b/angular/src/modules/vault-filter/components/type-filter.component.ts @@ -0,0 +1,40 @@ +import { Directive, EventEmitter, Input, Output } from "@angular/core"; + +import { CipherType } from "jslib-common/enums/cipherType"; +import { ITreeNodeObject } from "jslib-common/models/domain/treeNode"; + +import { TopLevelTreeNode } from "../models/top-level-tree-node.model"; +import { VaultFilter } from "../models/vault-filter.model"; + +@Directive() +export class TypeFilterComponent { + @Input() hide = false; + @Input() collapsedFilterNodes: Set; + @Input() selectedCipherType: CipherType = null; + @Input() activeFilter: VaultFilter; + + @Output() onNodeCollapseStateChange: EventEmitter = + new EventEmitter(); + @Output() onFilterChange: EventEmitter = new EventEmitter(); + + readonly typesNode: TopLevelTreeNode = { + id: "types", + name: "types", + }; + + cipherTypeEnum = CipherType; // used in the template + + get isCollapsed() { + return this.collapsedFilterNodes.has(this.typesNode.id); + } + + applyFilter(cipherType: CipherType) { + this.activeFilter.resetFilter(); + this.activeFilter.cipherType = cipherType; + this.onFilterChange.emit(this.activeFilter); + } + + async toggleCollapse() { + this.onNodeCollapseStateChange.emit(this.typesNode); + } +} diff --git a/angular/src/modules/vault-filter/models/cipher-status.model.ts b/angular/src/modules/vault-filter/models/cipher-status.model.ts new file mode 100644 index 00000000000..f93cd8b2107 --- /dev/null +++ b/angular/src/modules/vault-filter/models/cipher-status.model.ts @@ -0,0 +1 @@ +export type CipherStatus = "all" | "favorites" | "trash"; diff --git a/angular/src/modules/vault-filter/models/display-mode.ts b/angular/src/modules/vault-filter/models/display-mode.ts new file mode 100644 index 00000000000..3395df4fbe2 --- /dev/null +++ b/angular/src/modules/vault-filter/models/display-mode.ts @@ -0,0 +1,6 @@ +export type DisplayMode = + | "noOrganizations" + | "organizationMember" + | "singleOrganizationPolicy" + | "personalOwnershipPolicy" + | "singleOrganizationAndPersonalOwnershipPolicies"; diff --git a/angular/src/modules/vault-filter/models/dynamic-tree-node.model.ts b/angular/src/modules/vault-filter/models/dynamic-tree-node.model.ts new file mode 100644 index 00000000000..d63a518f7c0 --- /dev/null +++ b/angular/src/modules/vault-filter/models/dynamic-tree-node.model.ts @@ -0,0 +1,16 @@ +import { TreeNode } from "jslib-common/models/domain/treeNode"; +import { CollectionView } from "jslib-common/models/view/collectionView"; +import { FolderView } from "jslib-common/models/view/folderView"; + +export class DynamicTreeNode { + fullList: T[]; + nestedList: TreeNode[]; + + hasId(id: string): boolean { + return this.fullList != null && this.fullList.filter((i: T) => i.id === id).length > 0; + } + + constructor(init?: Partial>) { + Object.assign(this, init); + } +} diff --git a/angular/src/modules/vault-filter/models/top-level-tree-node.model.ts b/angular/src/modules/vault-filter/models/top-level-tree-node.model.ts new file mode 100644 index 00000000000..3e9870de2d4 --- /dev/null +++ b/angular/src/modules/vault-filter/models/top-level-tree-node.model.ts @@ -0,0 +1,7 @@ +import { ITreeNodeObject } from "jslib-common/models/domain/treeNode"; + +export type TopLevelTreeNodeId = "vaults" | "types" | "collections" | "folders"; +export class TopLevelTreeNode implements ITreeNodeObject { + id: TopLevelTreeNodeId; + name: string; // localizationString +} diff --git a/angular/src/modules/vault-filter/models/vault-filter.model.ts b/angular/src/modules/vault-filter/models/vault-filter.model.ts new file mode 100644 index 00000000000..b65729c5830 --- /dev/null +++ b/angular/src/modules/vault-filter/models/vault-filter.model.ts @@ -0,0 +1,32 @@ +import { CipherType } from "jslib-common/enums/cipherType"; + +import { CipherStatus } from "./cipher-status.model"; + +export class VaultFilter { + cipherType?: CipherType; + selectedCollectionId?: string; + status?: CipherStatus; + selectedFolder = false; // This is needed because of how the "No Folder" folder works. It has a null id. + selectedFolderId?: string; + selectedOrganizationId?: string; + myVaultOnly = false; + refreshCollectionsAndFolders = false; + + constructor(init?: Partial) { + Object.assign(this, init); + } + + resetFilter() { + this.cipherType = null; + this.status = null; + this.selectedCollectionId = null; + this.selectedFolder = false; + this.selectedFolderId = null; + } + + resetOrganization() { + this.myVaultOnly = false; + this.selectedOrganizationId = null; + this.resetFilter(); + } +} diff --git a/angular/src/modules/vault-filter/vault-filter.component.ts b/angular/src/modules/vault-filter/vault-filter.component.ts new file mode 100644 index 00000000000..5d743fcb1cd --- /dev/null +++ b/angular/src/modules/vault-filter/vault-filter.component.ts @@ -0,0 +1,108 @@ +import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; + +import { Organization } from "jslib-common/models/domain/organization"; +import { ITreeNodeObject } from "jslib-common/models/domain/treeNode"; +import { CollectionView } from "jslib-common/models/view/collectionView"; +import { FolderView } from "jslib-common/models/view/folderView"; + +import { DynamicTreeNode } from "./models/dynamic-tree-node.model"; +import { VaultFilter } from "./models/vault-filter.model"; +import { VaultFilterService } from "./vault-filter.service"; + +@Directive() +export class VaultFilterComponent implements OnInit { + @Input() activeFilter: VaultFilter = new VaultFilter(); + @Input() hideFolders = false; + @Input() hideCollections = false; + @Input() hideFavorites = false; + @Input() hideTrash = false; + @Input() hideOrganizations = false; + + @Output() onFilterChange = new EventEmitter(); + @Output() onAddFolder = new EventEmitter(); + @Output() onEditFolder = new EventEmitter(); + + isLoaded = false; + collapsedFilterNodes: Set; + organizations: Organization[]; + activePersonalOwnershipPolicy: boolean; + activeSingleOrganizationPolicy: boolean; + collections: DynamicTreeNode; + folders: DynamicTreeNode; + + constructor(protected vaultFilterService: VaultFilterService) {} + + get displayCollections() { + return this.collections?.fullList != null && this.collections.fullList.length > 0; + } + + async ngOnInit(): Promise { + this.collapsedFilterNodes = await this.vaultFilterService.buildCollapsedFilterNodes(); + this.organizations = await this.vaultFilterService.buildOrganizations(); + if (this.organizations != null && this.organizations.length > 0) { + this.activePersonalOwnershipPolicy = + await this.vaultFilterService.checkForPersonalOwnershipPolicy(); + this.activeSingleOrganizationPolicy = + await this.vaultFilterService.checkForSingleOrganizationPolicy(); + } + this.folders = await this.vaultFilterService.buildFolders(); + this.collections = await this.vaultFilterService.buildCollections(); + this.isLoaded = true; + } + + async toggleFilterNodeCollapseState(node: ITreeNodeObject) { + if (this.collapsedFilterNodes.has(node.id)) { + this.collapsedFilterNodes.delete(node.id); + } else { + this.collapsedFilterNodes.add(node.id); + } + await this.vaultFilterService.storeCollapsedFilterNodes(this.collapsedFilterNodes); + } + + async applyFilter(filter: VaultFilter) { + if (filter.refreshCollectionsAndFolders) { + await this.reloadCollectionsAndFolders(filter); + filter = this.pruneInvalidatedFilterSelections(filter); + } + this.onFilterChange.emit(filter); + } + + async reloadCollectionsAndFolders(filter: VaultFilter) { + this.folders = await this.vaultFilterService.buildFolders(filter.selectedOrganizationId); + this.collections = filter.myVaultOnly + ? null + : await this.vaultFilterService.buildCollections(filter.selectedOrganizationId); + } + + addFolder() { + this.onAddFolder.emit(); + } + + editFolder(folder: FolderView) { + this.onEditFolder.emit(folder); + } + + protected pruneInvalidatedFilterSelections(filter: VaultFilter): VaultFilter { + filter = this.pruneInvalidFolderSelection(filter); + filter = this.pruneInvalidCollectionSelection(filter); + return filter; + } + + protected pruneInvalidFolderSelection(filter: VaultFilter): VaultFilter { + if (filter.selectedFolder && !this.folders?.hasId(filter.selectedFolderId)) { + filter.selectedFolder = false; + filter.selectedFolderId = null; + } + return filter; + } + + protected pruneInvalidCollectionSelection(filter: VaultFilter): VaultFilter { + if ( + filter.selectedCollectionId != null && + !this.collections?.hasId(filter.selectedCollectionId) + ) { + filter.selectedCollectionId = null; + } + return filter; + } +} diff --git a/angular/src/modules/vault-filter/vault-filter.service.ts b/angular/src/modules/vault-filter/vault-filter.service.ts new file mode 100644 index 00000000000..b05cce1742f --- /dev/null +++ b/angular/src/modules/vault-filter/vault-filter.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from "@angular/core"; + +import { CipherService } from "jslib-common/abstractions/cipher.service"; +import { CollectionService } from "jslib-common/abstractions/collection.service"; +import { FolderService } from "jslib-common/abstractions/folder.service"; +import { OrganizationService } from "jslib-common/abstractions/organization.service"; +import { PolicyService } from "jslib-common/abstractions/policy.service"; +import { StateService } from "jslib-common/abstractions/state.service"; +import { PolicyType } from "jslib-common/enums/policyType"; +import { Organization } from "jslib-common/models/domain/organization"; +import { CollectionView } from "jslib-common/models/view/collectionView"; +import { FolderView } from "jslib-common/models/view/folderView"; + +import { DynamicTreeNode } from "./models/dynamic-tree-node.model"; + +@Injectable() +export class VaultFilterService { + constructor( + protected stateService: StateService, + protected organizationService: OrganizationService, + protected folderService: FolderService, + protected cipherService: CipherService, + protected collectionService: CollectionService, + protected policyService: PolicyService + ) {} + + async storeCollapsedFilterNodes(collapsedFilterNodes: Set): Promise { + await this.stateService.setCollapsedGroupings(Array.from(collapsedFilterNodes)); + } + + async buildCollapsedFilterNodes(): Promise> { + return new Set(await this.stateService.getCollapsedGroupings()); + } + + async buildOrganizations(): Promise { + return await this.organizationService.getAll(); + } + + async buildFolders(organizationId?: string): Promise> { + const storedFolders = await this.folderService.getAllDecrypted(); + let folders: FolderView[]; + if (organizationId != null) { + const ciphers = await this.cipherService.getAllDecrypted(); + const orgCiphers = ciphers.filter((c) => c.organizationId == organizationId); + folders = storedFolders.filter( + (f) => + orgCiphers.filter((oc) => oc.folderId == f.id).length > 0 || + ciphers.filter((c) => c.folderId == f.id).length < 1 + ); + } else { + folders = storedFolders; + } + const nestedFolders = await this.folderService.getAllNested(folders); + return new DynamicTreeNode({ + fullList: folders, + nestedList: nestedFolders, + }); + } + + async buildCollections(organizationId?: string): Promise> { + const storedCollections = await this.collectionService.getAllDecrypted(); + let collections: CollectionView[]; + if (organizationId != null) { + collections = storedCollections.filter((c) => c.organizationId === organizationId); + } else { + collections = storedCollections; + } + const nestedCollections = await this.collectionService.getAllNested(collections); + return new DynamicTreeNode({ + fullList: collections, + nestedList: nestedCollections, + }); + } + + async checkForSingleOrganizationPolicy(): Promise { + return await this.policyService.policyAppliesToUser(PolicyType.SingleOrg); + } + + async checkForPersonalOwnershipPolicy(): Promise { + return await this.policyService.policyAppliesToUser(PolicyType.PersonalOwnership); + } +} diff --git a/angular/src/scss/bwicons/fonts/bwi-font.svg b/angular/src/scss/bwicons/fonts/bwi-font.svg index 8b37358b76c..8642f2d64aa 100644 --- a/angular/src/scss/bwicons/fonts/bwi-font.svg +++ b/angular/src/scss/bwicons/fonts/bwi-font.svg @@ -159,11 +159,15 @@ - + + + + + diff --git a/angular/src/scss/bwicons/fonts/bwi-font.ttf b/angular/src/scss/bwicons/fonts/bwi-font.ttf index 9b1cfde3ddc312197d8c433ce8863490df35e649..f58764e334e8e6f2bd8fe376951d46262022d5fc 100644 GIT binary patch delta 2339 zcmbVNTWB0r7(QoiJF~MhJ2Pi?H@n%{OZJj2NvqjpFXqN{!9q>7fryu)wV}4nLz1Sn z4^fdBs>MnXD?$5cz2F6l6#J0c_F+MN6KO%wBHDrv7ExcUqL57d&ukXl8iKg&%sK!6 zf8Rgn@}KWqd5t^yE_XWTe2x%KB7`10G<|ddS`F+)XvIUbPaS-CV)Pi;F2eHFnFG^L zTFL9P2rH8iug-vjyTYx2eG=^a%-pfX-Mo1X>~jb)56&LmKmF~Sx!VXsN`(98rWY5` zd#r}A-Vct<{Pf&`-0A3$2aa7pNG=>cdJM4?OJ-iVxP)I?+J(FE+ZSf=cYp8#yUJWz zy6?{~@uj6BD^uV;`qy63k&wOSvQ5@SCfb5V(HwdPy@<}B_t9m9h;Bv)v*l_Ad4Y#=+} ziuz&5&ijlJ+yVrKs?eqEKHO7`b;M}yB=*x+E29|lvH$Ye^%B!8)x)aygOVfO!rrty^Y~VWX4bwlM4=HHuAM7F6Esi zir>O3Lv7#i{5Lh%A2zcB!+}ASNM6BRs#-63YYrb0R==SL)>a75dAIO(I*{%H<H!n}v`E&QautBRP%^ZEfA5mY zqzVCl<70Hk{1htWDz*@G_h>p?ph$-u$Ac*hRT494t;8U0me2VBz|_`b5U4?N-`!%@ zTBIyHnOi;?j0M;&&Scapj08g;+u0HHnu6 zLFOfm7X&sWi4pJAX3R0in756brYB{A=fe_=+53d4-R2Z2U@A&7EQGs5Au}LCc9?(^ z<^?`vX?otsHxh|7>wSvfFa|7ZEM=7q0zac{rN%64zz|eXiHZRc{!v9#g^f|f8A_*j z*1D-*suGjXcbCSu;p*f|nQ*tU&Sn2`%Ja*=|%L6>VV zrztdz4x?w$DRd59L|>yDFvkvwtZia-ifL~L5>}}#v<@h3C$ux^W--!UV>pT+>2N*J z6T6Kr@t}WCV0s(0v|k`PpjvlB9|lnXi{3L1+#CKS19Be7g5+SAC^lU+c){ZS=kEXH zIpi#rXzzX@$grmAH8hox9~drfU6=&nwpVUWR4NnnX>J);`{=kbTZ>ki;A^zM+Ej== zHm~DFeSVC&NnIN}7dG%w9QE(}C*e*Y^Gm(7YM1IpZ=@^HqfI*5#MHp#0*32p6^hRv z8velhN*&)qKRhs%(93D4Hy9O+ZQsE?dk&)IFED%GB9BKH#ioGvvwS{)`><6!{^4`c=Nl)UKJn3sTL=T-RaQj?O@Np)Ss9aM JR>Jo%e*+?Re^~$k delta 285 zcmeBJ&a!73%LL{6BMc0TG7Jn1G3klL1wdK=$gcp>9O*fgX?vesUk>CqFfcOD$w*C1 zk-2Z|$H2%6Qg4<46kyrI@&m{Z0rFKca!V@gSw$uS`9S?lE;;$hi5I4ew=giW?*W>j zlABmjz_5f_fPsb}6>DFYgA6FP}O)mTs%-FKI_-7o)c2+LNU}hPWT&=J1{5D@1xLJVO z85r(aml?w7=_S03=8O}!ui<61X4?LppHYKRG@)@u<2eRKppuEqd<;S$r3_5d3xyel LxBCk+S~39u2~tY^ diff --git a/angular/src/scss/bwicons/fonts/bwi-font.woff b/angular/src/scss/bwicons/fonts/bwi-font.woff index fdf107376ec87c416b0ded358aba216dd20b1c05..94abd93dbf1f82c428895784ad8def9aaf9bbafb 100644 GIT binary patch delta 2415 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 literal 29820 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 diff --git a/angular/src/scss/bwicons/styles/style.scss b/angular/src/scss/bwicons/styles/style.scss index e8c05f230c0..671d9d850ba 100644 --- a/angular/src/scss/bwicons/styles/style.scss +++ b/angular/src/scss/bwicons/styles/style.scss @@ -241,6 +241,10 @@ $icons: ( "android": "\e944", "error": "\e981", "numbered-list": "\e989", + "billing": "\e98a", + "family": "\e98b", + "provider": "\e98c", + "business": "\e98d", ); @each $name, $glyph in $icons { diff --git a/angular/src/services/jslib-services.module.ts b/angular/src/services/jslib-services.module.ts index 3cd97d63362..900a38c0488 100644 --- a/angular/src/services/jslib-services.module.ts +++ b/angular/src/services/jslib-services.module.ts @@ -74,12 +74,13 @@ import { UsernameGenerationService } from "jslib-common/services/usernameGenerat import { VaultTimeoutService } from "jslib-common/services/vaultTimeout.service"; import { WebCryptoFunctionService } from "jslib-common/services/webCryptoFunction.service"; -import { AuthGuardService } from "./auth-guard.service"; +import { AuthGuard } from "../guards/auth.guard"; +import { LockGuard } from "../guards/lock.guard"; +import { UnauthGuard } from "../guards/unauth.guard"; + import { BroadcasterService } from "./broadcaster.service"; -import { LockGuardService } from "./lock-guard.service"; import { ModalService } from "./modal.service"; import { PasswordRepromptService } from "./passwordReprompt.service"; -import { UnauthGuardService } from "./unauth-guard.service"; import { ValidationService } from "./validation.service"; export const WINDOW = new InjectionToken("WINDOW"); @@ -98,9 +99,9 @@ export const SYSTEM_LANGUAGE = new InjectionToken("SYSTEM_LANGUAGE"); declarations: [], providers: [ ValidationService, - AuthGuardService, - UnauthGuardService, - LockGuardService, + AuthGuard, + UnauthGuard, + LockGuard, ModalService, { provide: WINDOW, useValue: window }, { diff --git a/common/src/abstractions/api.service.ts b/common/src/abstractions/api.service.ts index c9970ce81bd..364c9f0e551 100644 --- a/common/src/abstractions/api.service.ts +++ b/common/src/abstractions/api.service.ts @@ -1,3 +1,6 @@ +import { BillingHistoryResponse } from "jslib-common/models/response/billingHistoryResponse"; +import { BillingPaymentResponse } from "jslib-common/models/response/billingPaymentResponse"; + import { PolicyType } from "../enums/policyType"; import { SetKeyConnectorKeyRequest } from "../models/request/account/setKeyConnectorKeyRequest"; import { VerifyOTPRequest } from "../models/request/account/verifyOTPRequest"; @@ -174,7 +177,6 @@ export abstract class ApiService { refreshIdentityToken: () => Promise; getProfile: () => Promise; - getUserBilling: () => Promise; getUserSubscription: () => Promise; getTaxInfo: () => Promise; putProfile: (request: UpdateProfileRequest) => Promise; @@ -212,6 +214,9 @@ export abstract class ApiService { postAccountVerifyOTP: (request: VerifyOTPRequest) => Promise; postConvertToKeyConnector: () => Promise; + getUserBillingHistory: () => Promise; + getUserBillingPayment: () => Promise; + getFolder: (id: string) => Promise; postFolder: (request: FolderRequest) => Promise; putFolder: (id: string, request: FolderRequest) => Promise; diff --git a/common/src/abstractions/folder.service.ts b/common/src/abstractions/folder.service.ts index 574cebfcd7a..f848de90920 100644 --- a/common/src/abstractions/folder.service.ts +++ b/common/src/abstractions/folder.service.ts @@ -10,7 +10,7 @@ export abstract class FolderService { get: (id: string) => Promise; getAll: () => Promise; getAllDecrypted: () => Promise; - getAllNested: () => Promise[]>; + getAllNested: (folders?: FolderView[]) => Promise[]>; getNested: (id: string) => Promise>; saveWithServer: (folder: Folder) => Promise; upsert: (folder: FolderData | FolderData[]) => Promise; diff --git a/common/src/models/domain/organization.ts b/common/src/models/domain/organization.ts index fe38c3c8540..ed09167b3c4 100644 --- a/common/src/models/domain/organization.ts +++ b/common/src/models/domain/organization.ts @@ -1,5 +1,6 @@ import { OrganizationUserStatusType } from "../../enums/organizationUserStatusType"; import { OrganizationUserType } from "../../enums/organizationUserType"; +import { Permissions } from "../../enums/permissions"; import { ProductType } from "../../enums/productType"; import { PermissionsApi } from "../api/permissionsApi"; import { OrganizationData } from "../data/organizationData"; @@ -182,6 +183,28 @@ export class Organization { return this.canManagePolicies; } + hasAnyPermission(permissions: Permissions[]) { + const specifiedPermissions = + (permissions.includes(Permissions.AccessEventLogs) && this.canAccessEventLogs) || + (permissions.includes(Permissions.AccessImportExport) && this.canAccessImportExport) || + (permissions.includes(Permissions.AccessReports) && this.canAccessReports) || + (permissions.includes(Permissions.CreateNewCollections) && this.canCreateNewCollections) || + (permissions.includes(Permissions.EditAnyCollection) && this.canEditAnyCollection) || + (permissions.includes(Permissions.DeleteAnyCollection) && this.canDeleteAnyCollection) || + (permissions.includes(Permissions.EditAssignedCollections) && + this.canEditAssignedCollections) || + (permissions.includes(Permissions.DeleteAssignedCollections) && + this.canDeleteAssignedCollections) || + (permissions.includes(Permissions.ManageGroups) && this.canManageGroups) || + (permissions.includes(Permissions.ManageOrganization) && this.isOwner) || + (permissions.includes(Permissions.ManagePolicies) && this.canManagePolicies) || + (permissions.includes(Permissions.ManageUsers) && this.canManageUsers) || + (permissions.includes(Permissions.ManageUsersPassword) && this.canManageUsersPassword) || + (permissions.includes(Permissions.ManageSso) && this.canManageSso); + + return specifiedPermissions && (this.enabled || this.isOwner); + } + get canManageBilling() { return this.isOwner && (this.isProviderUser || !this.hasProvider); } diff --git a/common/src/models/response/billingHistoryResponse.ts b/common/src/models/response/billingHistoryResponse.ts new file mode 100644 index 00000000000..c7dead81f56 --- /dev/null +++ b/common/src/models/response/billingHistoryResponse.ts @@ -0,0 +1,23 @@ +import { BaseResponse } from "./baseResponse"; +import { BillingInvoiceResponse, BillingTransactionResponse } from "./billingResponse"; + +export class BillingHistoryResponse extends BaseResponse { + invoices: BillingInvoiceResponse[] = []; + transactions: BillingTransactionResponse[] = []; + + constructor(response: any) { + super(response); + const transactions = this.getResponseProperty("Transactions"); + const invoices = this.getResponseProperty("Invoices"); + if (transactions != null) { + this.transactions = transactions.map((t: any) => new BillingTransactionResponse(t)); + } + if (invoices != null) { + this.invoices = invoices.map((i: any) => new BillingInvoiceResponse(i)); + } + } + + get hasNoHistory() { + return this.invoices.length == 0 && this.transactions.length == 0; + } +} diff --git a/common/src/models/response/billingPaymentResponse.ts b/common/src/models/response/billingPaymentResponse.ts new file mode 100644 index 00000000000..869e5a0d3f7 --- /dev/null +++ b/common/src/models/response/billingPaymentResponse.ts @@ -0,0 +1,14 @@ +import { BaseResponse } from "./baseResponse"; +import { BillingSourceResponse } from "./billingResponse"; + +export class BillingPaymentResponse extends BaseResponse { + balance: number; + paymentSource: BillingSourceResponse; + + constructor(response: any) { + super(response); + this.balance = this.getResponseProperty("Balance"); + const paymentSource = this.getResponseProperty("PaymentSource"); + this.paymentSource = paymentSource == null ? null : new BillingSourceResponse(paymentSource); + } +} diff --git a/common/src/services/api.service.ts b/common/src/services/api.service.ts index da2fcd5d64e..afd388fbdc5 100644 --- a/common/src/services/api.service.ts +++ b/common/src/services/api.service.ts @@ -1,6 +1,8 @@ import { AppIdService } from "jslib-common/abstractions/appId.service"; import { DeviceRequest } from "jslib-common/models/request/deviceRequest"; import { TokenRequestTwoFactor } from "jslib-common/models/request/identityToken/tokenRequestTwoFactor"; +import { BillingHistoryResponse } from "jslib-common/models/response/billingHistoryResponse"; +import { BillingPaymentResponse } from "jslib-common/models/response/billingPaymentResponse"; import { ApiService as ApiServiceAbstraction } from "../abstractions/api.service"; import { EnvironmentService } from "../abstractions/environment.service"; @@ -280,11 +282,6 @@ export class ApiService implements ApiServiceAbstraction { return new ProfileResponse(r); } - async getUserBilling(): Promise { - const r = await this.send("GET", "/accounts/billing", null, true, true); - return new BillingResponse(r); - } - async getUserSubscription(): Promise { const r = await this.send("GET", "/accounts/subscription", null, true, true); return new SubscriptionResponse(r); @@ -467,6 +464,18 @@ export class ApiService implements ApiServiceAbstraction { return this.send("POST", "/accounts/convert-to-key-connector", null, true, false); } + // Account Billing APIs + + async getUserBillingHistory(): Promise { + const r = await this.send("GET", "/accounts/billing/history", null, true, true); + return new BillingHistoryResponse(r); + } + + async getUserBillingPayment(): Promise { + const r = await this.send("GET", "/accounts/billing/payment-method", null, true, true); + return new BillingPaymentResponse(r); + } + // Folder APIs async getFolder(id: string): Promise { diff --git a/common/src/services/folder.service.ts b/common/src/services/folder.service.ts index 47db0cb7357..f791c07f4d2 100644 --- a/common/src/services/folder.service.ts +++ b/common/src/services/folder.service.ts @@ -88,8 +88,8 @@ export class FolderService implements FolderServiceAbstraction { return decFolders; } - async getAllNested(): Promise[]> { - const folders = await this.getAllDecrypted(); + async getAllNested(folders?: FolderView[]): Promise[]> { + folders = folders ?? (await this.getAllDecrypted()); const nodes: TreeNode[] = []; folders.forEach((f) => { const folderCopy = new FolderView(); diff --git a/components/.storybook/preview.js b/components/.storybook/preview.js index 98d2d5b833b..8bc2510a082 100644 --- a/components/.storybook/preview.js +++ b/components/.storybook/preview.js @@ -15,10 +15,18 @@ export const parameters = { docs: { inlineStories: true }, }; +// ng-template is used to scope any template reference variables and isolate the previews const decorator = componentWrapperDecorator( (story) => ` -
${story}
-
${story}
+ +
${story}
+
+ +
${story}
+
+ + + ` ); diff --git a/components/package-lock.json b/components/package-lock.json index 5064c216fac..a817f8b29c5 100644 --- a/components/package-lock.json +++ b/components/package-lock.json @@ -18,6 +18,7 @@ "@angular/platform-browser-dynamic": "^12.2.13", "@bitwarden/jslib-angular": "file:../angular", "bootstrap": "4.6.0", + "rxjs": "^7.4.0", "tslib": "^2.3.0" }, "devDependencies": { diff --git a/components/package.json b/components/package.json index 42682325e55..a9a401f849f 100644 --- a/components/package.json +++ b/components/package.json @@ -24,7 +24,8 @@ "@angular/platform-browser-dynamic": "^12.2.13", "@bitwarden/jslib-angular": "file:../angular", "bootstrap": "4.6.0", - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "rxjs": "^7.4.0" }, "devDependencies": { "@angular-devkit/build-angular": "^12.2.13", diff --git a/components/src/index.ts b/components/src/index.ts index f8fef9a94c3..ac56e57a09d 100644 --- a/components/src/index.ts +++ b/components/src/index.ts @@ -2,3 +2,4 @@ export * from "./badge"; export * from "./banner"; export * from "./button"; export * from "./callout"; +export * from "./menu"; diff --git a/components/src/menu/index.ts b/components/src/menu/index.ts new file mode 100644 index 00000000000..69b71b8ee24 --- /dev/null +++ b/components/src/menu/index.ts @@ -0,0 +1,5 @@ +export * from "./menu.module"; +export * from "./menu.component"; +export * from "./menu-trigger-for.directive"; +export * from "./menu-item.component"; +export * from "./menu-divider.component"; diff --git a/components/src/menu/menu-divider.component.html b/components/src/menu/menu-divider.component.html new file mode 100644 index 00000000000..7cc020ef50a --- /dev/null +++ b/components/src/menu/menu-divider.component.html @@ -0,0 +1,4 @@ + diff --git a/components/src/menu/menu-divider.component.ts b/components/src/menu/menu-divider.component.ts new file mode 100644 index 00000000000..194506ee50f --- /dev/null +++ b/components/src/menu/menu-divider.component.ts @@ -0,0 +1,7 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: "bit-menu-divider", + templateUrl: "./menu-divider.component.html", +}) +export class MenuDividerComponent {} diff --git a/components/src/menu/menu-item.component.ts b/components/src/menu/menu-item.component.ts new file mode 100644 index 00000000000..9520b6483a7 --- /dev/null +++ b/components/src/menu/menu-item.component.ts @@ -0,0 +1,37 @@ +import { FocusableOption } from "@angular/cdk/a11y"; +import { Component, ElementRef, HostBinding } from "@angular/core"; + +@Component({ + selector: "[bit-menu-item]", + template: ``, +}) +export class MenuItemComponent implements FocusableOption { + @HostBinding("class") classList = [ + "tw-block", + "tw-py-1", + "tw-px-4", + "!tw-text-main", + "!tw-no-underline", + "tw-cursor-pointer", + "tw-border-none", + "tw-bg-background", + "tw-text-left", + "hover:tw-bg-secondary-100", + "focus:tw-bg-secondary-100", + "focus:tw-z-50", + "focus:tw-outline-none", + "focus:tw-ring", + "focus:tw-ring-offset-2", + "focus:tw-ring-primary-700", + "active:!tw-ring-0", + "active:!tw-ring-offset-0", + ].join(" "); + @HostBinding("attr.role") role = "menuitem"; + @HostBinding("tabIndex") tabIndex = "-1"; + + constructor(private elementRef: ElementRef) {} + + focus() { + this.elementRef.nativeElement.focus(); + } +} diff --git a/components/src/menu/menu-trigger-for.directive.ts b/components/src/menu/menu-trigger-for.directive.ts new file mode 100644 index 00000000000..059e6f812e6 --- /dev/null +++ b/components/src/menu/menu-trigger-for.directive.ts @@ -0,0 +1,119 @@ +import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay"; +import { TemplatePortal } from "@angular/cdk/portal"; +import { + Directive, + ElementRef, + HostBinding, + HostListener, + Input, + OnDestroy, + ViewContainerRef, +} from "@angular/core"; +import { Observable, Subscription } from "rxjs"; +import { filter, mergeWith } from "rxjs/operators"; + +import { MenuComponent } from "./menu.component"; + +@Directive({ + selector: "[bitMenuTriggerFor]", +}) +export class MenuTriggerForDirective implements OnDestroy { + @HostBinding("attr.aria-expanded") isOpen = false; + @HostBinding("attr.aria-haspopup") hasPopup = "menu"; + @HostBinding("attr.role") role = "button"; + + @Input("bitMenuTriggerFor") menu: MenuComponent; + + private overlayRef: OverlayRef; + private defaultMenuConfig: OverlayConfig = { + panelClass: "bit-menu-panel", + hasBackdrop: true, + backdropClass: "cdk-overlay-transparent-backdrop", + scrollStrategy: this.overlay.scrollStrategies.reposition(), + positionStrategy: this.overlay + .position() + .flexibleConnectedTo(this.elementRef) + .withPositions([ + { + originX: "start", + originY: "bottom", + overlayX: "start", + overlayY: "top", + }, + { + originX: "end", + originY: "bottom", + overlayX: "end", + overlayY: "top", + }, + ]) + .withLockedPosition(true) + .withFlexibleDimensions(false) + .withPush(false), + }; + private closedEventsSub: Subscription; + private keyDownEventsSub: Subscription; + + constructor( + private elementRef: ElementRef, + private viewContainerRef: ViewContainerRef, + private overlay: Overlay + ) {} + + @HostListener("click") toggleMenu() { + this.isOpen ? this.destroyMenu() : this.openMenu(); + } + + ngOnDestroy() { + this.disposeAll(); + } + + private openMenu() { + if (this.menu == null) { + throw new Error("Cannot find bit-menu element"); + } + + this.isOpen = true; + this.overlayRef = this.overlay.create(this.defaultMenuConfig); + + const templatePortal = new TemplatePortal(this.menu.templateRef, this.viewContainerRef); + this.overlayRef.attach(templatePortal); + + this.closedEventsSub = this.getClosedEvents().subscribe((event: KeyboardEvent | undefined) => { + if (event?.key === "Tab") { + // Required to ensure tab order resumes correctly + this.elementRef.nativeElement.focus(); + } + this.destroyMenu(); + }); + this.keyDownEventsSub = this.overlayRef + .keydownEvents() + .subscribe((event: KeyboardEvent) => this.menu.keyManager.onKeydown(event)); + } + + private destroyMenu() { + if (this.overlayRef == null || !this.isOpen) { + return; + } + + this.isOpen = false; + this.disposeAll(); + } + + private getClosedEvents(): Observable { + const detachments = this.overlayRef.detachments(); + const escKey = this.overlayRef + .keydownEvents() + .pipe(filter((event: KeyboardEvent) => event.key === "Escape" || event.key === "Tab")); + const backdrop = this.overlayRef.backdropClick(); + const menuClosed = this.menu.closed; + + return detachments.pipe(mergeWith(escKey, backdrop, menuClosed)); + } + + private disposeAll() { + this.closedEventsSub?.unsubscribe(); + this.overlayRef?.dispose(); + this.keyDownEventsSub?.unsubscribe(); + } +} diff --git a/components/src/menu/menu.component.html b/components/src/menu/menu.component.html new file mode 100644 index 00000000000..8123c7be408 --- /dev/null +++ b/components/src/menu/menu.component.html @@ -0,0 +1,9 @@ + + + diff --git a/components/src/menu/menu.component.spec.ts b/components/src/menu/menu.component.spec.ts new file mode 100644 index 00000000000..ca412e33990 --- /dev/null +++ b/components/src/menu/menu.component.spec.ts @@ -0,0 +1,77 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; + +import { MenuTriggerForDirective } from "./menu-trigger-for.directive"; + +import { MenuModule } from "./index"; + +describe("Menu", () => { + let fixture: ComponentFixture; + const getMenuTriggerDirective = () => { + const buttonDebugElement = fixture.debugElement.query(By.directive(MenuTriggerForDirective)); + return buttonDebugElement.injector.get(MenuTriggerForDirective); + }; + + // The overlay is created outside the root debugElement, so we need to query its parent + const getBitMenuPanel = () => fixture.debugElement.parent.query(By.css(".bit-menu-panel")); + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [MenuModule], + declarations: [TestApp], + }); + + TestBed.compileComponents(); + + fixture = TestBed.createComponent(TestApp); + fixture.detectChanges(); + }) + ); + + it("should open when the trigger is clicked", () => { + const buttonDebugElement = fixture.debugElement.query(By.directive(MenuTriggerForDirective)); + (buttonDebugElement.nativeElement as HTMLButtonElement).click(); + + expect(getBitMenuPanel()).toBeTruthy(); + }); + + it("should close when the trigger is clicked", () => { + getMenuTriggerDirective().toggleMenu(); + + const buttonDebugElement = fixture.debugElement.query(By.directive(MenuTriggerForDirective)); + (buttonDebugElement.nativeElement as HTMLButtonElement).click(); + + expect(getBitMenuPanel()).toBeFalsy(); + }); + + it("should close when a menu item is clicked", () => { + getMenuTriggerDirective().toggleMenu(); + + fixture.debugElement.parent.query(By.css("#item1")).nativeElement.click(); + + expect(getBitMenuPanel()).toBeFalsy(); + }); + + it("should close when the backdrop is clicked", () => { + getMenuTriggerDirective().toggleMenu(); + + fixture.debugElement.parent.query(By.css(".cdk-overlay-backdrop")).nativeElement.click(); + + expect(getBitMenuPanel()).toBeFalsy(); + }); +}); + +@Component({ + selector: "test-app", + template: ` + + + + Item 1 + Item 2 + + `, +}) +class TestApp {} diff --git a/components/src/menu/menu.component.ts b/components/src/menu/menu.component.ts new file mode 100644 index 00000000000..29a9862f72f --- /dev/null +++ b/components/src/menu/menu.component.ts @@ -0,0 +1,30 @@ +import { FocusKeyManager } from "@angular/cdk/a11y"; +import { + Component, + Output, + TemplateRef, + ViewChild, + EventEmitter, + ContentChildren, + QueryList, + AfterContentInit, +} from "@angular/core"; + +import { MenuItemComponent } from "./menu-item.component"; + +@Component({ + selector: "bit-menu", + templateUrl: "./menu.component.html", + exportAs: "menuComponent", +}) +export class MenuComponent implements AfterContentInit { + @ViewChild(TemplateRef) templateRef: TemplateRef; + @Output() closed = new EventEmitter(); + @ContentChildren(MenuItemComponent, { descendants: true }) + menuItems: QueryList; + keyManager: FocusKeyManager; + + ngAfterContentInit() { + this.keyManager = new FocusKeyManager(this.menuItems).withWrap(); + } +} diff --git a/components/src/menu/menu.module.ts b/components/src/menu/menu.module.ts new file mode 100644 index 00000000000..53575a35354 --- /dev/null +++ b/components/src/menu/menu.module.ts @@ -0,0 +1,15 @@ +import { OverlayModule } from "@angular/cdk/overlay"; +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; + +import { MenuDividerComponent } from "./menu-divider.component"; +import { MenuItemComponent } from "./menu-item.component"; +import { MenuTriggerForDirective } from "./menu-trigger-for.directive"; +import { MenuComponent } from "./menu.component"; + +@NgModule({ + imports: [CommonModule, OverlayModule], + declarations: [MenuComponent, MenuTriggerForDirective, MenuItemComponent, MenuDividerComponent], + exports: [MenuComponent, MenuTriggerForDirective, MenuItemComponent, MenuDividerComponent], +}) +export class MenuModule {} diff --git a/components/src/menu/menu.stories.ts b/components/src/menu/menu.stories.ts new file mode 100644 index 00000000000..3f296ee8b02 --- /dev/null +++ b/components/src/menu/menu.stories.ts @@ -0,0 +1,69 @@ +import { OverlayModule } from "@angular/cdk/overlay"; +import { Meta, moduleMetadata, Story } from "@storybook/angular"; + +import { ButtonModule } from "../button/button.module"; + +import { MenuDividerComponent } from "./menu-divider.component"; +import { MenuItemComponent } from "./menu-item.component"; +import { MenuTriggerForDirective } from "./menu-trigger-for.directive"; +import { MenuComponent } from "./menu.component"; + +export default { + title: "Jslib/Menu", + component: MenuTriggerForDirective, + decorators: [ + moduleMetadata({ + declarations: [ + MenuTriggerForDirective, + MenuComponent, + MenuItemComponent, + MenuDividerComponent, + ], + imports: [OverlayModule, ButtonModule], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/file/f32LSg3jaegICkMu7rPARm/Tailwind-Component-Library-Update?node-id=1881%3A17952", + }, + }, +} as Meta; + +const Template: Story = (args: MenuTriggerForDirective) => ({ + props: args, + template: ` + + Anchor link + Another link + + + + + +
+
+ +
+
+ `, +}); + +const TemplateWithButton: Story = (args: MenuTriggerForDirective) => ({ + props: args, + template: ` +
+ +
+ + + Anchor link + Another link + + + + `, +}); + +export const OpenMenu = Template.bind({}); +export const ClosedMenu = TemplateWithButton.bind({}); diff --git a/components/src/styles.scss b/components/src/styles.scss index 8729b921b8a..401b801e0e1 100644 --- a/components/src/styles.scss +++ b/components/src/styles.scss @@ -3,6 +3,8 @@ @import "../../angular/src/scss/bwicons/styles/style.scss"; @import "../../angular/src/scss/icons.scss"; +@import "@angular/cdk/overlay-prebuilt.css"; + @import "~bootstrap/scss/_functions"; @import "~bootstrap/scss/_variables"; @import "~bootstrap/scss/_mixins";