From 50cee3cd9a19a69a7c49a30882bfba611b7d0b3e Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Wed, 11 Jun 2025 11:39:47 -0400 Subject: [PATCH 01/17] [PM-22099] expose default collection in clients collection service (#15122) * Add types * rename types * fix types * fix model and tests --- .../src/common/collections/models/collection.data.ts | 3 +++ .../common/collections/models/collection.response.ts | 4 ++++ .../src/common/collections/models/collection.spec.ts | 7 ++++++- .../src/common/collections/models/collection.ts | 11 ++++++++++- .../src/common/collections/models/collection.view.ts | 4 +++- .../item-details-section.component.spec.ts | 3 ++- 6 files changed, 28 insertions(+), 4 deletions(-) diff --git a/libs/admin-console/src/common/collections/models/collection.data.ts b/libs/admin-console/src/common/collections/models/collection.data.ts index 4a2b78543a5..b28a066509c 100644 --- a/libs/admin-console/src/common/collections/models/collection.data.ts +++ b/libs/admin-console/src/common/collections/models/collection.data.ts @@ -2,6 +2,7 @@ import { Jsonify } from "type-fest"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +import { CollectionType } from "./collection"; import { CollectionDetailsResponse } from "./collection.response"; export class CollectionData { @@ -12,6 +13,7 @@ export class CollectionData { readOnly: boolean; manage: boolean; hidePasswords: boolean; + type: CollectionType; constructor(response: CollectionDetailsResponse) { this.id = response.id; @@ -21,6 +23,7 @@ export class CollectionData { this.readOnly = response.readOnly; this.manage = response.manage; this.hidePasswords = response.hidePasswords; + this.type = response.type; } static fromJSON(obj: Jsonify) { diff --git a/libs/admin-console/src/common/collections/models/collection.response.ts b/libs/admin-console/src/common/collections/models/collection.response.ts index e2b8bfd08f6..c9b8ccf0456 100644 --- a/libs/admin-console/src/common/collections/models/collection.response.ts +++ b/libs/admin-console/src/common/collections/models/collection.response.ts @@ -2,11 +2,14 @@ import { SelectionReadOnlyResponse } from "@bitwarden/common/admin-console/model import { BaseResponse } from "@bitwarden/common/models/response/base.response"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +import { CollectionType } from "./collection"; + export class CollectionResponse extends BaseResponse { id: CollectionId; organizationId: OrganizationId; name: string; externalId: string; + type: CollectionType; constructor(response: any) { super(response); @@ -14,6 +17,7 @@ export class CollectionResponse extends BaseResponse { this.organizationId = this.getResponseProperty("OrganizationId"); this.name = this.getResponseProperty("Name"); this.externalId = this.getResponseProperty("ExternalId"); + this.type = this.getResponseProperty("Type"); } } diff --git a/libs/admin-console/src/common/collections/models/collection.spec.ts b/libs/admin-console/src/common/collections/models/collection.spec.ts index a21ce573512..925490d22b9 100644 --- a/libs/admin-console/src/common/collections/models/collection.spec.ts +++ b/libs/admin-console/src/common/collections/models/collection.spec.ts @@ -2,7 +2,7 @@ import { makeSymmetricCryptoKey, mockEnc } from "@bitwarden/common/spec"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; -import { Collection } from "./collection"; +import { Collection, CollectionTypes } from "./collection"; import { CollectionData } from "./collection.data"; describe("Collection", () => { @@ -17,6 +17,7 @@ describe("Collection", () => { readOnly: true, manage: true, hidePasswords: true, + type: CollectionTypes.DefaultUserCollection, }; }); @@ -32,6 +33,7 @@ describe("Collection", () => { organizationId: null, readOnly: null, manage: null, + type: null, }); }); @@ -46,6 +48,7 @@ describe("Collection", () => { readOnly: true, manage: true, hidePasswords: true, + type: CollectionTypes.DefaultUserCollection, }); }); @@ -58,6 +61,7 @@ describe("Collection", () => { collection.readOnly = false; collection.hidePasswords = false; collection.manage = true; + collection.type = CollectionTypes.DefaultUserCollection; const key = makeSymmetricCryptoKey(); @@ -72,6 +76,7 @@ describe("Collection", () => { readOnly: false, manage: true, assigned: true, + type: CollectionTypes.DefaultUserCollection, }); }); }); diff --git a/libs/admin-console/src/common/collections/models/collection.ts b/libs/admin-console/src/common/collections/models/collection.ts index 5b6f1a6fb7a..4d87130c162 100644 --- a/libs/admin-console/src/common/collections/models/collection.ts +++ b/libs/admin-console/src/common/collections/models/collection.ts @@ -7,6 +7,13 @@ import { OrgKey } from "@bitwarden/common/types/key"; import { CollectionData } from "./collection.data"; import { CollectionView } from "./collection.view"; +export const CollectionTypes = { + SharedCollection: 0, + DefaultUserCollection: 1, +} as const; + +export type CollectionType = (typeof CollectionTypes)[keyof typeof CollectionTypes]; + export class Collection extends Domain { id: string; organizationId: string; @@ -15,6 +22,7 @@ export class Collection extends Domain { readOnly: boolean; hidePasswords: boolean; manage: boolean; + type: CollectionType; constructor(obj?: CollectionData) { super(); @@ -33,8 +41,9 @@ export class Collection extends Domain { readOnly: null, hidePasswords: null, manage: null, + type: null, }, - ["id", "organizationId", "readOnly", "hidePasswords", "manage"], + ["id", "organizationId", "readOnly", "hidePasswords", "manage", "type"], ); } diff --git a/libs/admin-console/src/common/collections/models/collection.view.ts b/libs/admin-console/src/common/collections/models/collection.view.ts index 1ce76608df1..7baf2e2b718 100644 --- a/libs/admin-console/src/common/collections/models/collection.view.ts +++ b/libs/admin-console/src/common/collections/models/collection.view.ts @@ -6,7 +6,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { View } from "@bitwarden/common/models/view/view"; import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node"; -import { Collection } from "./collection"; +import { Collection, CollectionType } from "./collection"; import { CollectionAccessDetailsResponse } from "./collection.response"; export const NestingDelimiter = "/"; @@ -21,6 +21,7 @@ export class CollectionView implements View, ITreeNodeObject { hidePasswords: boolean = null; manage: boolean = null; assigned: boolean = null; + type: CollectionType = null; constructor(c?: Collection | CollectionAccessDetailsResponse) { if (!c) { @@ -39,6 +40,7 @@ export class CollectionView implements View, ITreeNodeObject { if (c instanceof CollectionAccessDetailsResponse) { this.assigned = c.assigned; } + this.type = c.type; } canEditItems(org: Organization): boolean { diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts index 1e9916e76a4..42b29193c85 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -7,7 +7,7 @@ import { BehaviorSubject } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -35,6 +35,7 @@ const createMockCollection = ( hidePasswords: false, manage: true, assigned: true, + type: CollectionTypes.DefaultUserCollection, canEditItems: jest.fn().mockReturnValue(canEdit), canEdit: jest.fn(), canDelete: jest.fn(), From 2e4b7854d09a6473e9291b60781f9ab817851151 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Wed, 11 Jun 2025 16:48:39 +0100 Subject: [PATCH 02/17] [PM-21184] Migrate free-bitwarden-families.component.html to Tailwind complying (#14628) * Resolve the tw issues * Resolve the merge syntax * Remove the image and use the icon * Move the free compoent to standalone * minified and use tailwind classes * Remove the ngcontainer that is not needed * Remove the no-item changes * Add the compoenet to export * Add the missing export * Remove the package file * Removed the added changes on json file * revert the change * revert the change * Remove package-lock.json from branch * Reset package-lock.json to match main branch * Remove package-lock.json from branch * revert the package file changes --- .../free-bitwarden-families.component.html | 29 ++++++++++--------- .../src/app/shared/loose-components.module.ts | 2 +- apps/web/src/images/search.svg | 2 +- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/apps/web/src/app/billing/members/free-bitwarden-families.component.html b/apps/web/src/app/billing/members/free-bitwarden-families.component.html index 243cf612c73..cedadb09318 100644 --- a/apps/web/src/app/billing/members/free-bitwarden-families.component.html +++ b/apps/web/src/app/billing/members/free-bitwarden-families.component.html @@ -56,20 +56,21 @@ appA11yTitle="{{ 'options' | i18n }}" > - + @if (!isSelfHosted && !sponsoredFamily.validUntil) { + + } - + @if (!isSelfHosted && !sponsoredFamily.validUntil) {
-
+ }
@@ -87,7 +88,7 @@ } -
+
} @else if (!loading()) {
diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index 44323614f17..63e54c46a8f 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -26,6 +26,7 @@ import { UpdatePasswordComponent } from "../auth/update-password.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; import { VerifyEmailTokenComponent } from "../auth/verify-email-token.component"; import { VerifyRecoverDeleteComponent } from "../auth/verify-recover-delete.component"; +import { FreeBitwardenFamiliesComponent } from "../billing/members/free-bitwarden-families.component"; import { SponsoredFamiliesComponent } from "../billing/settings/sponsored-families.component"; import { SponsoringOrgRowComponent } from "../billing/settings/sponsoring-org-row.component"; // eslint-disable-next-line no-restricted-imports -- Temporarily disabled until DIRT refactors these out of this module @@ -46,7 +47,6 @@ import { OrganizationBadgeModule } from "../vault/individual-vault/organization- import { PipesModule } from "../vault/individual-vault/pipes/pipes.module"; import { PurgeVaultComponent } from "../vault/settings/purge-vault.component"; -import { FreeBitwardenFamiliesComponent } from "./../billing/members/free-bitwarden-families.component"; import { AccountFingerprintComponent } from "./components/account-fingerprint/account-fingerprint.component"; import { SharedModule } from "./shared.module"; diff --git a/apps/web/src/images/search.svg b/apps/web/src/images/search.svg index 36e0ea4bd23..7f1521fdd04 100644 --- a/apps/web/src/images/search.svg +++ b/apps/web/src/images/search.svg @@ -9,4 +9,4 @@ - + \ No newline at end of file From 8b42edf9dc96baef3b6174f2f9d27baf520af9f8 Mon Sep 17 00:00:00 2001 From: Bryan Cunningham Date: Wed, 11 Jun 2025 11:54:15 -0400 Subject: [PATCH 03/17] [CL-687] Updated dark mode color variables (#15123) * updated dark mode color variables * update light versions to match --- libs/components/src/tw-theme.css | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/libs/components/src/tw-theme.css b/libs/components/src/tw-theme.css index 24f0b7adaad..103b90e0752 100644 --- a/libs/components/src/tw-theme.css +++ b/libs/components/src/tw-theme.css @@ -29,19 +29,19 @@ --color-info-100: 219 229 246; --color-info-600: 121 161 233; - --color-info-700: 26 65 172; + --color-info-700: 13 36 123; - --color-warning-100: 255 248 228; + --color-warning-100: 255 244 212; --color-warning-600: 255 191 0; - --color-warning-700: 172 88 0; + --color-warning-700: 142 64 0; --color-danger-100: 255 236 239; --color-danger-600: 203 38 58; --color-danger-700: 149 27 42; - --color-success-100: 191 236 195; + --color-success-100: 213 243 216; --color-success-600: 12 128 24; - --color-success-700: 11 111 21; + --color-success-700: 8 81 15; --color-notification-100: 255 225 247; --color-notification-600: 192 17 118; @@ -85,19 +85,19 @@ --color-secondary-600: 143 152 166; --color-secondary-700: 158 167 181; - --color-success-100: 11 111 21; + --color-success-100: 8 81 15; --color-success-600: 107 241 120; - --color-success-700: 191 236 195; + --color-success-700: 213 243 216; --color-danger-100: 149 27 42; --color-danger-600: 255 78 99; --color-danger-700: 255 236 239; - --color-warning-100: 172 88 0; + --color-warning-100: 142 64 0; --color-warning-600: 255 191 0; - --color-warning-700: 255 248 228; + --color-warning-700: 255 244 212; - --color-info-100: 26 65 172; + --color-info-100: 13 36 123; --color-info-600: 121 161 233; --color-info-700: 219 229 246; From 1175da38459fcf0dd4aedd075d9766516880832b Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Wed, 11 Jun 2025 09:30:12 -0700 Subject: [PATCH 04/17] [PM-20642] - [Vault] [Web App] Front End Changes to Enforce "Remove card item type policy" (#15097) * add restricted item types service and apply it to filter web cipher * code cleanup. add shareReplay * account for multiple orgs when restricting item types * restrict item types for specific orgs * clean up logic. use policiesByType$ * track by item.type * clean up filtering. prefer observable. do not exempt owners for restricted item types * simplify in vault-filter. move item filter logic to vault. fix tests * don't return early in filter-function --- .../vault-filter/vault-filter.component.ts | 3 + .../restricted-item-types.component.ts | 2 +- .../vault-items/vault-items.component.ts | 2 - .../vault-items/vault-items.stories.ts | 7 + .../components/vault-filter.component.ts | 146 +++++++++++------- .../shared/models/filter-function.spec.ts | 41 +++++ .../shared/models/filter-function.ts | 24 ++- .../vault-header/vault-header.component.html | 26 +--- .../vault-header/vault-header.component.ts | 32 ++-- .../vault/individual-vault/vault.component.ts | 7 +- .../admin-console/enums/policy-type.enum.ts | 2 +- .../services/policy/default-policy.service.ts | 12 +- libs/vault/src/index.ts | 4 + .../restricted-item-types.service.spec.ts | 137 ++++++++++++++++ .../services/restricted-item-types.service.ts | 80 ++++++++++ 15 files changed, 423 insertions(+), 102 deletions(-) create mode 100644 libs/vault/src/services/restricted-item-types.service.spec.ts create mode 100644 libs/vault/src/services/restricted-item-types.service.ts diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts index f7d7acfdc2d..ff6ec9af0af 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts @@ -12,6 +12,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { DialogService, ToastService } from "@bitwarden/components"; +import { RestrictedItemTypesService } from "@bitwarden/vault"; import { VaultFilterComponent as BaseVaultFilterComponent } from "../../../../vault/individual-vault/vault-filter/components/vault-filter.component"; import { VaultFilterService } from "../../../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service"; @@ -51,6 +52,7 @@ export class VaultFilterComponent protected dialogService: DialogService, protected configService: ConfigService, protected accountService: AccountService, + protected restrictedItemTypesService: RestrictedItemTypesService, ) { super( vaultFilterService, @@ -62,6 +64,7 @@ export class VaultFilterComponent dialogService, configService, accountService, + restrictedItemTypesService, ); } diff --git a/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts b/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts index 8dd8720a220..406014973f0 100644 --- a/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts @@ -7,7 +7,7 @@ import { BasePolicy, BasePolicyComponent } from "./base-policy.component"; export class RestrictedItemTypesPolicy extends BasePolicy { name = "restrictedItemTypesPolicy"; description = "restrictedItemTypesPolicyDesc"; - type = PolicyType.RestrictedItemTypesPolicy; + type = PolicyType.RestrictedItemTypes; component = RestrictedItemTypesPolicyComponent; } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 9679f0879b9..9d94fb044b5 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -342,8 +342,6 @@ export class VaultItemsComponent { const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ cipher })); const items: VaultItem[] = [].concat(collections).concat(ciphers); - this.selection.clear(); - // All ciphers are selectable, collections only if they can be edited or deleted this.editableItems = items.filter( (item) => diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts index 55807ed855f..e2c6f204d72 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -29,6 +29,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { RestrictedItemTypesService } from "@bitwarden/vault"; import { GroupView } from "../../../admin-console/organizations/core"; import { PreloadedEnglishI18nModule } from "../../../core/tests"; @@ -125,6 +126,12 @@ export default { }, }, }, + { + provide: RestrictedItemTypesService, + useValue: { + restricted$: of([]), // No restricted item types for this story + }, + }, ], }), applicationConfig({ diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index 6b974296f21..d21896e26fe 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -1,8 +1,15 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { Router } from "@angular/router"; -import { firstValueFrom, merge, Subject, switchMap, takeUntil } from "rxjs"; +import { + distinctUntilChanged, + firstValueFrom, + map, + merge, + shareReplay, + Subject, + switchMap, + takeUntil, +} from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; @@ -16,6 +23,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { DialogService, ToastService } from "@bitwarden/components"; +import { RestrictedItemTypesService } from "@bitwarden/vault"; import { TrialFlowService } from "../../../../billing/services/trial-flow.service"; import { VaultFilterService } from "../services/abstractions/vault-filter.service"; @@ -56,6 +64,45 @@ export class VaultFilterComponent implements OnInit, OnDestroy { return this.filters ? Object.values(this.filters) : []; } + allTypeFilters: CipherTypeFilter[] = [ + { + id: "favorites", + name: this.i18nService.t("favorites"), + type: "favorites", + icon: "bwi-star", + }, + { + id: "login", + name: this.i18nService.t("typeLogin"), + type: CipherType.Login, + icon: "bwi-globe", + }, + { + id: "card", + name: this.i18nService.t("typeCard"), + type: CipherType.Card, + icon: "bwi-credit-card", + }, + { + id: "identity", + name: this.i18nService.t("typeIdentity"), + type: CipherType.Identity, + icon: "bwi-id-card", + }, + { + id: "note", + name: this.i18nService.t("note"), + type: CipherType.SecureNote, + icon: "bwi-sticky-note", + }, + { + id: "sshKey", + name: this.i18nService.t("typeSshKey"), + type: CipherType.SshKey, + icon: "bwi-key", + }, + ]; + get searchPlaceholder() { if (this.activeFilter.isFavorites) { return "searchFavorites"; @@ -107,12 +154,17 @@ export class VaultFilterComponent implements OnInit, OnDestroy { protected dialogService: DialogService, protected configService: ConfigService, protected accountService: AccountService, + protected restrictedItemTypesService: RestrictedItemTypesService, ) {} async ngOnInit(): Promise { this.filters = await this.buildAllFilters(); - this.activeFilter.selectedCipherTypeNode = - (await this.getDefaultFilter()) as TreeNode; + if (this.filters?.typeFilter?.data$) { + this.activeFilter.selectedCipherTypeNode = (await firstValueFrom( + this.filters?.typeFilter.data$, + )) as TreeNode; + } + this.isLoaded = true; // Without refactoring the entire component, we need to manually update the organization filter whenever the policies update @@ -133,6 +185,9 @@ export class VaultFilterComponent implements OnInit, OnDestroy { takeUntil(this.destroy$), ) .subscribe((orgFilters) => { + if (!this.filters) { + return; + } this.filters.organizationFilter = orgFilters; }); } @@ -151,7 +206,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy { if (!orgNode?.node.enabled) { this.toastService.showToast({ variant: "error", - title: null, message: this.i18nService.t("disabledOrganizationFilterError"), }); const metadata = await this.billingApiService.getOrganizationBillingMetadata(orgNode.node.id); @@ -190,10 +244,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy { this.onEditFolder.emit(folder); }; - async getDefaultFilter(): Promise> { - return await firstValueFrom(this.filters?.typeFilter.data$); - } - async buildAllFilters(): Promise { const builderFilter = {} as VaultFilterList; builderFilter.organizationFilter = await this.addOrganizationFilter(); @@ -225,7 +275,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { const addAction = !singleOrgPolicy ? { text: "newOrganization", route: "/create-organization" } - : null; + : undefined; const orgFilterSection: VaultFilterSection = { data$: this.vaultFilterService.organizationTree$, @@ -233,7 +283,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { showHeader: !(singleOrgPolicy && personalVaultPolicy), isSelectable: true, }, - action: this.applyOrganizationFilter, + action: this.applyOrganizationFilter as (orgNode: TreeNode) => Promise, options: { component: OrganizationOptionsComponent }, add: addAction, divider: true, @@ -243,55 +293,31 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } protected async addTypeFilter(excludeTypes: CipherStatus[] = []): Promise { - const allTypeFilters: CipherTypeFilter[] = [ - { - id: "favorites", - name: this.i18nService.t("favorites"), - type: "favorites", - icon: "bwi-star", - }, - { - id: "login", - name: this.i18nService.t("typeLogin"), - type: CipherType.Login, - icon: "bwi-globe", - }, - { - id: "card", - name: this.i18nService.t("typeCard"), - type: CipherType.Card, - icon: "bwi-credit-card", - }, - { - id: "identity", - name: this.i18nService.t("typeIdentity"), - type: CipherType.Identity, - icon: "bwi-id-card", - }, - { - id: "note", - name: this.i18nService.t("note"), - type: CipherType.SecureNote, - icon: "bwi-sticky-note", - }, - { - id: "sshKey", - name: this.i18nService.t("typeSshKey"), - type: CipherType.SshKey, - icon: "bwi-key", - }, - ]; + const allFilter: CipherTypeFilter = { id: "AllItems", name: "allItems", type: "all", icon: "" }; + + const data$ = this.restrictedItemTypesService.restricted$.pipe( + map((restricted) => { + // List of types restricted by all orgs + const restrictedByAll = restricted + .filter((r) => r.allowViewOrgIds.length === 0) + .map((r) => r.cipherType); + const toExclude = [...excludeTypes, ...restrictedByAll]; + return this.allTypeFilters.filter( + (f) => typeof f.type !== "string" && !toExclude.includes(f.type), + ); + }), + switchMap((allowed) => this.vaultFilterService.buildTypeTree(allFilter, allowed)), + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: true }), + ); const typeFilterSection: VaultFilterSection = { - data$: this.vaultFilterService.buildTypeTree( - { id: "AllItems", name: "allItems", type: "all", icon: "" }, - allTypeFilters.filter((f) => !excludeTypes.includes(f.type)), - ), + data$, header: { showHeader: true, isSelectable: true, }, - action: this.applyTypeFilter, + action: this.applyTypeFilter as (filterNode: TreeNode) => Promise, }; return typeFilterSection; } @@ -303,10 +329,10 @@ export class VaultFilterComponent implements OnInit, OnDestroy { showHeader: true, isSelectable: false, }, - action: this.applyFolderFilter, + action: this.applyFolderFilter as (filterNode: TreeNode) => Promise, edit: { filterName: this.i18nService.t("folder"), - action: this.editFolder, + action: this.editFolder as (filter: VaultFilterType) => void, }, }; return folderFilterSection; @@ -319,7 +345,9 @@ export class VaultFilterComponent implements OnInit, OnDestroy { showHeader: true, isSelectable: true, }, - action: this.applyCollectionFilter, + action: this.applyCollectionFilter as ( + filterNode: TreeNode, + ) => Promise, }; return collectionFilterSection; } @@ -346,7 +374,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { showHeader: false, isSelectable: true, }, - action: this.applyTypeFilter, + action: this.applyTypeFilter as (filterNode: TreeNode) => Promise, }; return trashFilterSection; } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts index 3082d7cb809..660aeb293a4 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts @@ -3,6 +3,7 @@ import { Unassigned } from "@bitwarden/admin-console/common"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { RestrictedCipherType } from "@bitwarden/vault"; import { createFilterFunction } from "./filter-function"; import { All } from "./routed-vault-filter.model"; @@ -214,6 +215,46 @@ describe("createFilter", () => { expect(result).toBe(true); }); }); + + describe("given restricted types", () => { + const restrictedTypes: RestrictedCipherType[] = [ + { cipherType: CipherType.Login, allowViewOrgIds: [] }, + ]; + + it("should filter out a cipher whose type is fully restricted", () => { + const cipher = createCipher({ type: CipherType.Login }); + const filterFunction = createFilterFunction({}, restrictedTypes); + + expect(filterFunction(cipher)).toBe(false); + }); + + it("should allow a cipher when the cipher's organization allows it", () => { + const cipher = createCipher({ type: CipherType.Login, organizationId: "org1" }); + const restricted: RestrictedCipherType[] = [ + { cipherType: CipherType.Login, allowViewOrgIds: ["org1"] }, + ]; + const filterFunction2 = createFilterFunction({}, restricted); + + expect(filterFunction2(cipher)).toBe(true); + }); + + it("should filter out a personal vault cipher when the owning orgs does not allow it", () => { + const cipher = createCipher({ type: CipherType.Card, organizationId: "org1" }); + const restricted2: RestrictedCipherType[] = [ + { cipherType: CipherType.Card, allowViewOrgIds: [] }, + ]; + const filterFunction3 = createFilterFunction({}, restricted2); + + expect(filterFunction3(cipher)).toBe(false); + }); + + it("should not filter a cipher if there are no restricted types", () => { + const cipher = createCipher({ type: CipherType.Login }); + const filterFunction = createFilterFunction({}, []); + + expect(filterFunction(cipher)).toBe(true); + }); + }); }); function createCipher(options: Partial = {}) { diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts index a39918df4a7..61305fa5e49 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts @@ -1,12 +1,16 @@ import { Unassigned } from "@bitwarden/admin-console/common"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { RestrictedCipherType } from "@bitwarden/vault"; import { All, RoutedVaultFilterModel } from "./routed-vault-filter.model"; export type FilterFunction = (cipher: CipherView) => boolean; -export function createFilterFunction(filter: RoutedVaultFilterModel): FilterFunction { +export function createFilterFunction( + filter: RoutedVaultFilterModel, + restrictedTypes?: RestrictedCipherType[], +): FilterFunction { return (cipher) => { if (filter.type === "favorites" && !cipher.favorite) { return false; @@ -80,6 +84,24 @@ export function createFilterFunction(filter: RoutedVaultFilterModel): FilterFunc return false; } + // Restricted types + if (restrictedTypes && restrictedTypes.length > 0) { + // Filter the cipher if that type is restricted unless + // - The cipher belongs to an organization and that organization allows viewing the cipher type + // OR + // - The cipher belongs to the user's personal vault and at least one other organization does not restrict that type + if ( + restrictedTypes.some( + (restrictedType) => + restrictedType.cipherType === cipher.type && + (cipher.organizationId + ? !restrictedType.allowViewOrgIds.includes(cipher.organizationId) + : restrictedType.allowViewOrgIds.length === 0), + ) + ) { + return false; + } + } return true; }; } diff --git a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html index af95a71ba8d..4ef8204cdfc 100644 --- a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html @@ -81,26 +81,12 @@ {{ "new" | i18n }} - - - - - + @for (item of cipherMenuItems$ | async; track item.type) { + + } `; } const actionButtonStyles = ({ disabled, - theme, fullWidth, + isLoading, + theme, }: { disabled: boolean; - theme: Theme; fullWidth: boolean; + isLoading: boolean; + theme: Theme; }) => css` ${typography.body2} user-select: none; + display: flex; + align-items: center; + justify-content: center; border: 1px solid transparent; border-radius: ${border.radius.full}; padding: ${spacing["1"]} ${spacing["3"]}; @@ -59,7 +67,7 @@ const actionButtonStyles = ({ text-overflow: ellipsis; font-weight: 700; - ${disabled + ${disabled || isLoading ? ` background-color: ${themes[theme].secondary["300"]}; color: ${themes[theme].text.muted}; @@ -81,7 +89,8 @@ const actionButtonStyles = ({ `} svg { - width: fit-content; + padding: 2px 0; /* Match line-height of button body2 typography */ + width: auto; height: 16px; } `; diff --git a/apps/browser/src/autofill/content/components/constants/styles.ts b/apps/browser/src/autofill/content/components/constants/styles.ts index 08c8671ce14..55130781808 100644 --- a/apps/browser/src/autofill/content/components/constants/styles.ts +++ b/apps/browser/src/autofill/content/components/constants/styles.ts @@ -174,6 +174,17 @@ export const buildIconColorRule = (color: string, rule: RuleName = ruleNames.fil ${rule}: ${color}; `; +export const animations = { + spin: ` + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(359deg); + } + `, +}; + export function scrollbarStyles(theme: Theme, color?: { thumb?: string; track?: string }) { const thumbColor = color?.thumb || themes[theme].secondary["500"]; const trackColor = color?.track || themes[theme].background.alt; diff --git a/apps/browser/src/autofill/content/components/icons/index.ts b/apps/browser/src/autofill/content/components/icons/index.ts index 65ec6301ac4..d1538e1543f 100644 --- a/apps/browser/src/autofill/content/components/icons/index.ts +++ b/apps/browser/src/autofill/content/components/icons/index.ts @@ -11,4 +11,5 @@ export { Folder } from "./folder"; export { Globe } from "./globe"; export { PencilSquare } from "./pencil-square"; export { Shield } from "./shield"; +export { Spinner } from "./spinner"; export { User } from "./user"; diff --git a/apps/browser/src/autofill/content/components/icons/spinner.ts b/apps/browser/src/autofill/content/components/icons/spinner.ts new file mode 100644 index 00000000000..20f53a43d44 --- /dev/null +++ b/apps/browser/src/autofill/content/components/icons/spinner.ts @@ -0,0 +1,34 @@ +import { css, keyframes } from "@emotion/css"; +import { html } from "lit"; + +import { IconProps } from "../common-types"; +import { buildIconColorRule, ruleNames, themes, animations } from "../constants/styles"; + +export function Spinner({ + ariaHidden = true, + color, + disabled, + theme, + disableSpin = false, +}: IconProps & { disableSpin?: boolean }) { + const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; + + return html` + + + + `; +} + +const animation = css` + animation: ${keyframes(animations.spin)} 2s infinite linear; +`; diff --git a/apps/browser/src/autofill/content/components/lit-stories/buttons/action-button.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/buttons/action-button.lit-stories.ts index 77769bc67dc..dc630e537b0 100644 --- a/apps/browser/src/autofill/content/components/lit-stories/buttons/action-button.lit-stories.ts +++ b/apps/browser/src/autofill/content/components/lit-stories/buttons/action-button.lit-stories.ts @@ -1,9 +1,12 @@ import { Meta, StoryObj } from "@storybook/web-components"; +import { html } from "lit"; import { ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; import { ActionButton, ActionButtonProps } from "../../buttons/action-button"; +type ComponentAndControls = ActionButtonProps & { width: number }; + export default { title: "Components/Buttons/Action Button", argTypes: { @@ -11,12 +14,15 @@ export default { disabled: { control: "boolean" }, theme: { control: "select", options: [...Object.values(ThemeTypes)] }, handleClick: { control: false }, + width: { control: "number", min: 10, max: 100, step: 1 }, }, args: { buttonText: "Click Me", disabled: false, + isLoading: false, theme: ThemeTypes.Light, handleClick: () => alert("Clicked"), + width: 150, }, parameters: { design: { @@ -24,10 +30,18 @@ export default { url: "https://www.figma.com/design/LEhqLAcBPY8uDKRfU99n9W/Autofill-notification-redesign?node-id=487-14755&t=2O7uCAkwRZCcjumm-4", }, }, -} as Meta; +} as Meta; -const Template = (args: ActionButtonProps) => ActionButton({ ...args }); +const Template = (args: ComponentAndControls) => { + const { width, ...componentProps } = args; + return html`
${ActionButton({ ...componentProps })}
`; +}; + +export const Default: StoryObj = { + args: { + isLoading: true, + theme: "dark", + }, -export const Default: StoryObj = { render: Template, }; diff --git a/apps/browser/src/autofill/content/components/lit-stories/icons/icons.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/icons/icons.lit-stories.ts index 3741ccbcb69..4e18008b94a 100644 --- a/apps/browser/src/autofill/content/components/lit-stories/icons/icons.lit-stories.ts +++ b/apps/browser/src/autofill/content/components/lit-stories/icons/icons.lit-stories.ts @@ -6,9 +6,10 @@ import { ThemeTypes } from "@bitwarden/common/platform/enums/theme-type.enum"; import { IconProps } from "../../common-types"; import * as Icons from "../../icons"; +const { Spinner, ...StaticIcons } = Icons; + type Args = IconProps & { size: number; - iconLink: URL; }; export default { @@ -26,7 +27,10 @@ export default { }, } as Meta; -const Template = (args: Args, IconComponent: (props: IconProps) => ReturnType) => html` +const Template = ( + args: Args, + IconComponent: (props: IconProps & { disableSpin?: boolean }) => ReturnType, +) => html`
@@ -34,18 +38,26 @@ const Template = (args: Args, IconComponent: (props: IconProps) => ReturnType `; -const createIconStory = (iconName: keyof typeof Icons): StoryObj => { +const createIconStory = ( + iconName: keyof typeof StaticIcons, +): StoryObj => { const story = { - render: (args) => Template(args, Icons[iconName]), + render: (args) => Template(args, StaticIcons[iconName]), } as StoryObj; - story.argTypes = { - iconLink: { table: { disable: true } }, - }; - return story; }; +const SpinnerIconStory: StoryObj = { + render: (args) => Template(args, Spinner), + argTypes: { + disableSpin: { control: "boolean" }, + }, + args: { + disableSpin: false, + }, +}; + export const AngleDownIcon = createIconStory("AngleDown"); export const AngleUpIcon = createIconStory("AngleUp"); export const BusinessIcon = createIconStory("Business"); @@ -58,4 +70,5 @@ export const FolderIcon = createIconStory("Folder"); export const GlobeIcon = createIconStory("Globe"); export const PencilSquareIcon = createIconStory("PencilSquare"); export const ShieldIcon = createIconStory("Shield"); +export const SpinnerIcon = SpinnerIconStory; export const UserIcon = createIconStory("User"); diff --git a/apps/browser/src/autofill/content/components/notification/button-row.ts b/apps/browser/src/autofill/content/components/notification/button-row.ts index 470147cb469..04b79c1951a 100644 --- a/apps/browser/src/autofill/content/components/notification/button-row.ts +++ b/apps/browser/src/autofill/content/components/notification/button-row.ts @@ -34,6 +34,7 @@ export type NotificationButtonRowProps = { organizations?: OrgView[]; primaryButton: { text: string; + isLoading?: boolean; handlePrimaryButtonClick: (args: any) => void; }; personalVaultIsAllowed: boolean; diff --git a/apps/browser/src/autofill/content/components/notification/container.ts b/apps/browser/src/autofill/content/components/notification/container.ts index cc7f0fc72c0..0c70e0da63c 100644 --- a/apps/browser/src/autofill/content/components/notification/container.ts +++ b/apps/browser/src/autofill/content/components/notification/container.ts @@ -29,6 +29,7 @@ export type NotificationContainerProps = NotificationBarIframeInitData & { folders?: FolderView[]; headerMessage?: string; i18n: I18n; + isLoading?: boolean; organizations?: OrgView[]; personalVaultIsAllowed?: boolean; notificationTestId: string; @@ -44,6 +45,7 @@ export function NotificationContainer({ folders, headerMessage, i18n, + isLoading, organizations, personalVaultIsAllowed = true, notificationTestId, @@ -74,6 +76,7 @@ export function NotificationContainer({ collections, folders, i18n, + isLoading, notificationType: type, organizations, personalVaultIsAllowed, diff --git a/apps/browser/src/autofill/content/components/notification/footer.ts b/apps/browser/src/autofill/content/components/notification/footer.ts index b47dd5cc094..d37547a6fae 100644 --- a/apps/browser/src/autofill/content/components/notification/footer.ts +++ b/apps/browser/src/autofill/content/components/notification/footer.ts @@ -16,6 +16,7 @@ export type NotificationFooterProps = { collections?: CollectionView[]; folders?: FolderView[]; i18n: I18n; + isLoading?: boolean; notificationType?: NotificationType; organizations?: OrgView[]; personalVaultIsAllowed: boolean; @@ -27,6 +28,7 @@ export function NotificationFooter({ collections, folders, i18n, + isLoading, notificationType, organizations, personalVaultIsAllowed, @@ -52,6 +54,7 @@ export function NotificationFooter({ i18n, primaryButton: { handlePrimaryButtonClick: handleSaveAction, + isLoading, text: primaryButtonText, }, personalVaultIsAllowed, diff --git a/apps/browser/src/autofill/content/components/rows/button-row.ts b/apps/browser/src/autofill/content/components/rows/button-row.ts index 041d0a6b696..8b4eabfec50 100644 --- a/apps/browser/src/autofill/content/components/rows/button-row.ts +++ b/apps/browser/src/autofill/content/components/rows/button-row.ts @@ -12,6 +12,7 @@ export type ButtonRowProps = { theme: Theme; primaryButton: { text: string; + isLoading?: boolean; handlePrimaryButtonClick: (args: any) => void; }; selectButtons?: { @@ -29,6 +30,7 @@ export function ButtonRow({ theme, primaryButton, selectButtons }: ButtonRowProp ${ActionButton({ handleClick: primaryButton.handlePrimaryButtonClick, buttonText: primaryButton.text, + isLoading: primaryButton.isLoading, theme, })}
diff --git a/apps/browser/src/autofill/notification/bar.ts b/apps/browser/src/autofill/notification/bar.ts index 275e6cb0721..285ae4aa257 100644 --- a/apps/browser/src/autofill/notification/bar.ts +++ b/apps/browser/src/autofill/notification/bar.ts @@ -249,25 +249,34 @@ async function initNotificationBar(message: NotificationBarWindowMessage) { document.head.querySelectorAll('link[rel="stylesheet"]').forEach((node) => node.remove()); if (isVaultLocked) { - return render( - NotificationContainer({ - ...notificationBarIframeInitData, - headerMessage, - type: resolvedType, - notificationTestId, - theme: resolvedTheme, - personalVaultIsAllowed: !personalVaultDisallowed, - handleCloseNotification, - handleSaveAction: (e) => { - sendSaveCipherMessage(true); + const notificationConfig = { + ...notificationBarIframeInitData, + headerMessage, + type: resolvedType, + notificationTestId, + theme: resolvedTheme, + personalVaultIsAllowed: !personalVaultDisallowed, + handleCloseNotification, + handleEditOrUpdateAction, + i18n, + }; - // @TODO can't close before vault has finished decrypting, but can't leave open during long decrypt because it looks like the experience has failed - }, - handleEditOrUpdateAction, - i18n, - }), - document.body, - ); + const handleSaveAction = () => { + sendSaveCipherMessage(true); + + render( + NotificationContainer({ + ...notificationConfig, + handleSaveAction: () => {}, + isLoading: true, + }), + document.body, + ); + }; + + const UnlockNotification = NotificationContainer({ ...notificationConfig, handleSaveAction }); + + return render(UnlockNotification, document.body); } // Handle AtRiskPasswordNotification render From 381e7fa45ec009b229112969c366d574dfc8f600 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:11:44 -0500 Subject: [PATCH 14/17] [PM-22563] Add awaiting the SDK to be ready to EncryptService (#15138) --- .../encrypt.service.implementation.ts | 3 ++ .../crypto/services/encrypt.service.spec.ts | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts index 5bb946b25bf..525e8a6b5f7 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts @@ -26,6 +26,7 @@ import { getFeatureFlagValue, } from "../../../enums/feature-flag.enum"; import { ServerConfig } from "../../../platform/abstractions/config/server-config"; +import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service"; import { EncryptService } from "../abstractions/encrypt.service"; export class EncryptServiceImplementation implements EncryptService { @@ -242,6 +243,7 @@ export class EncryptServiceImplementation implements EncryptService { if (encString == null || encString.encryptedString == null) { throw new Error("encString is null or undefined"); } + await SdkLoadService.Ready; return PureCrypto.symmetric_decrypt(encString.encryptedString, key.toEncoded()); } this.logService.debug("decrypting with javascript"); @@ -324,6 +326,7 @@ export class EncryptServiceImplementation implements EncryptService { encThing.dataBytes, encThing.macBytes, ).buffer; + await SdkLoadService.Ready; return PureCrypto.symmetric_decrypt_array_buffer(buffer, key.toEncoded()); } this.logService.debug("[EncryptService] Decrypting bytes with javascript"); diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts index d19de6c0414..813dd693dd9 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts @@ -11,10 +11,12 @@ import { SymmetricCryptoKey, } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { PureCrypto } from "@bitwarden/sdk-internal"; import { makeStaticByteArray } from "../../../../spec"; import { DefaultFeatureFlagValue, FeatureFlag } from "../../../enums/feature-flag.enum"; import { ServerConfig } from "../../../platform/abstractions/config/server-config"; +import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service"; import { EncryptServiceImplementation } from "./encrypt.service.implementation"; @@ -343,6 +345,24 @@ describe("EncryptService", () => { ); }); + it("calls PureCrypto when useSDKForDecryption is true", async () => { + (encryptService as any).useSDKForDecryption = true; + const decryptedBytes = makeStaticByteArray(10, 200); + Object.defineProperty(SdkLoadService, "Ready", { + value: Promise.resolve(), + configurable: true, + }); + jest.spyOn(PureCrypto, "symmetric_decrypt_array_buffer").mockReturnValue(decryptedBytes); + + const actual = await encryptService.decryptToBytes(encBuffer, key); + + expect(PureCrypto.symmetric_decrypt_array_buffer).toHaveBeenCalledWith( + encBuffer.buffer, + key.toEncoded(), + ); + expect(actual).toEqualBuffer(decryptedBytes); + }); + it("decrypts data with provided key for Aes256CbcHmac", async () => { const decryptedBytes = makeStaticByteArray(10, 200); @@ -450,6 +470,25 @@ describe("EncryptService", () => { ); }); + it("calls PureCrypto when useSDKForDecryption is true", async () => { + (encryptService as any).useSDKForDecryption = true; + const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0)); + const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac"); + Object.defineProperty(SdkLoadService, "Ready", { + value: Promise.resolve(), + configurable: true, + }); + jest.spyOn(PureCrypto, "symmetric_decrypt").mockReturnValue("data"); + + const actual = await encryptService.decryptToUtf8(encString, key); + + expect(actual).toEqual("data"); + expect(PureCrypto.symmetric_decrypt).toHaveBeenCalledWith( + encString.encryptedString, + key.toEncoded(), + ); + }); + it("decrypts data with provided key for AesCbc256_HmacSha256", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0)); const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac"); From 6a579ed99f65e7fb7111c1f0a2aa853f11c984a0 Mon Sep 17 00:00:00 2001 From: Leslie Tilton <23057410+Banrion@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:52:04 -0500 Subject: [PATCH 15/17] [PM-15001] Replace throttle decorator (#15015) * Add comments to AuditService Abstraction * Replace throttle usage with rxjs mergeMap with concurrent limit * Add test cases for audit service * Remove throttle --- libs/common/src/abstractions/audit.service.ts | 17 +++- .../common/src/platform/misc/throttle.spec.ts | 97 ------------------- libs/common/src/platform/misc/throttle.ts | 71 -------------- .../common/src/services/audit.service.spec.ts | 81 ++++++++++++++++ libs/common/src/services/audit.service.ts | 43 +++++++- 5 files changed, 134 insertions(+), 175 deletions(-) delete mode 100644 libs/common/src/platform/misc/throttle.spec.ts delete mode 100644 libs/common/src/platform/misc/throttle.ts create mode 100644 libs/common/src/services/audit.service.spec.ts diff --git a/libs/common/src/abstractions/audit.service.ts b/libs/common/src/abstractions/audit.service.ts index a54beb59a78..b019ebe1fe8 100644 --- a/libs/common/src/abstractions/audit.service.ts +++ b/libs/common/src/abstractions/audit.service.ts @@ -1,8 +1,17 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { BreachAccountResponse } from "../models/response/breach-account.response"; export abstract class AuditService { - passwordLeaked: (password: string) => Promise; - breachedAccounts: (username: string) => Promise; + /** + * Checks how many times a password has been leaked. + * @param password The password to check. + * @returns A promise that resolves to the number of times the password has been leaked. + */ + abstract passwordLeaked: (password: string) => Promise; + + /** + * Retrieves accounts that have been breached for a given username. + * @param username The username to check for breaches. + * @returns A promise that resolves to an array of BreachAccountResponse objects. + */ + abstract breachedAccounts: (username: string) => Promise; } diff --git a/libs/common/src/platform/misc/throttle.spec.ts b/libs/common/src/platform/misc/throttle.spec.ts deleted file mode 100644 index 1c1ff6324a6..00000000000 --- a/libs/common/src/platform/misc/throttle.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { throttle } from "./throttle"; - -describe("throttle decorator", () => { - it("should call the function once at a time", async () => { - const foo = new Foo(); - const promises = []; - for (let i = 0; i < 10; i++) { - promises.push(foo.bar(1)); - } - await Promise.all(promises); - - expect(foo.calls).toBe(10); - }); - - it("should call the function once at a time for each object", async () => { - const foo = new Foo(); - const foo2 = new Foo(); - const promises = []; - for (let i = 0; i < 10; i++) { - promises.push(foo.bar(1)); - promises.push(foo2.bar(1)); - } - await Promise.all(promises); - - expect(foo.calls).toBe(10); - expect(foo2.calls).toBe(10); - }); - - it("should call the function limit at a time", async () => { - const foo = new Foo(); - const promises = []; - for (let i = 0; i < 10; i++) { - promises.push(foo.baz(1)); - } - await Promise.all(promises); - - expect(foo.calls).toBe(10); - }); - - it("should call the function limit at a time for each object", async () => { - const foo = new Foo(); - const foo2 = new Foo(); - const promises = []; - for (let i = 0; i < 10; i++) { - promises.push(foo.baz(1)); - promises.push(foo2.baz(1)); - } - await Promise.all(promises); - - expect(foo.calls).toBe(10); - expect(foo2.calls).toBe(10); - }); -}); - -class Foo { - calls = 0; - inflight = 0; - - @throttle(1, () => "bar") - bar(a: number) { - this.calls++; - this.inflight++; - return new Promise((res) => { - setTimeout(() => { - expect(this.inflight).toBe(1); - this.inflight--; - res(a * 2); - }, Math.random() * 10); - }); - } - - @throttle(5, () => "baz") - baz(a: number) { - this.calls++; - this.inflight++; - return new Promise((res) => { - setTimeout(() => { - expect(this.inflight).toBeLessThanOrEqual(5); - this.inflight--; - res(a * 3); - }, Math.random() * 10); - }); - } - - @throttle(1, () => "qux") - qux(a: number) { - this.calls++; - this.inflight++; - return new Promise((res) => { - setTimeout(() => { - expect(this.inflight).toBe(1); - this.inflight--; - res(a * 3); - }, Math.random() * 10); - }); - } -} diff --git a/libs/common/src/platform/misc/throttle.ts b/libs/common/src/platform/misc/throttle.ts deleted file mode 100644 index 643cce8f6ba..00000000000 --- a/libs/common/src/platform/misc/throttle.ts +++ /dev/null @@ -1,71 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -/** - * Use as a Decorator on async functions, it will limit how many times the function can be - * in-flight at a time. - * - * Calls beyond the limit will be queued, and run when one of the active calls finishes - */ -export function throttle(limit: number, throttleKey: (args: any[]) => string) { - return ( - target: any, - propertyKey: string | symbol, - descriptor: TypedPropertyDescriptor<(...args: any[]) => Promise>, - ) => { - const originalMethod: () => Promise = descriptor.value; - const allThrottles = new Map void)[]>>(); - - const getThrottles = (obj: any) => { - let throttles = allThrottles.get(obj); - if (throttles != null) { - return throttles; - } - throttles = new Map void)[]>(); - allThrottles.set(obj, throttles); - return throttles; - }; - - return { - value: function (...args: any[]) { - const throttles = getThrottles(this); - const argsThrottleKey = throttleKey(args); - let queue = throttles.get(argsThrottleKey); - if (queue == null) { - queue = []; - throttles.set(argsThrottleKey, queue); - } - - return new Promise((resolve, reject) => { - const exec = () => { - const onFinally = () => { - queue.splice(queue.indexOf(exec), 1); - if (queue.length >= limit) { - queue[limit - 1](); - } else if (queue.length === 0) { - throttles.delete(argsThrottleKey); - if (throttles.size === 0) { - allThrottles.delete(this); - } - } - }; - originalMethod - .apply(this, args) - .then((val: any) => { - onFinally(); - return val; - }) - .catch((err: any) => { - onFinally(); - throw err; - }) - .then(resolve, reject); - }; - queue.push(exec); - if (queue.length <= limit) { - exec(); - } - }); - }, - }; - }; -} diff --git a/libs/common/src/services/audit.service.spec.ts b/libs/common/src/services/audit.service.spec.ts new file mode 100644 index 00000000000..ce594823a7b --- /dev/null +++ b/libs/common/src/services/audit.service.spec.ts @@ -0,0 +1,81 @@ +import { ApiService } from "../abstractions/api.service"; +import { CryptoFunctionService } from "../key-management/crypto/abstractions/crypto-function.service"; +import { ErrorResponse } from "../models/response/error.response"; + +import { AuditService } from "./audit.service"; + +jest.useFakeTimers(); + +// Polyfill global Request for Jest environment if not present +if (typeof global.Request === "undefined") { + global.Request = jest.fn((input: string | URL, init?: RequestInit) => { + return { url: typeof input === "string" ? input : input.toString(), ...init }; + }) as any; +} + +describe("AuditService", () => { + let auditService: AuditService; + let mockCrypto: jest.Mocked; + let mockApi: jest.Mocked; + + beforeEach(() => { + mockCrypto = { + hash: jest.fn().mockResolvedValue(Buffer.from("AABBCCDDEEFF", "hex")), + } as unknown as jest.Mocked; + + mockApi = { + nativeFetch: jest.fn().mockResolvedValue({ + text: jest.fn().mockResolvedValue(`CDDEEFF:4\nDDEEFF:2\n123456:1`), + }), + getHibpBreach: jest.fn(), + } as unknown as jest.Mocked; + + auditService = new AuditService(mockCrypto, mockApi, 2); + }); + + it("should not exceed max concurrent passwordLeaked requests", async () => { + const inFlight: string[] = []; + const maxInFlight: number[] = []; + + // Patch fetchLeakedPasswordCount to track concurrency + const origFetch = (auditService as any).fetchLeakedPasswordCount.bind(auditService); + jest + .spyOn(auditService as any, "fetchLeakedPasswordCount") + .mockImplementation(async (password: string) => { + inFlight.push(password); + maxInFlight.push(inFlight.length); + // Simulate async work to allow concurrency limiter to take effect + await new Promise((resolve) => setTimeout(resolve, 100)); + inFlight.splice(inFlight.indexOf(password), 1); + return origFetch(password); + }); + + const p1 = auditService.passwordLeaked("password1"); + const p2 = auditService.passwordLeaked("password2"); + const p3 = auditService.passwordLeaked("password3"); + const p4 = auditService.passwordLeaked("password4"); + + jest.advanceTimersByTime(250); + + // Flush all pending timers and microtasks + await jest.runAllTimersAsync(); + await Promise.all([p1, p2, p3, p4]); + + // The max value in maxInFlight should not exceed 2 (the concurrency limit) + expect(Math.max(...maxInFlight)).toBeLessThanOrEqual(2); + expect((auditService as any).fetchLeakedPasswordCount).toHaveBeenCalledTimes(4); + expect(mockCrypto.hash).toHaveBeenCalledTimes(4); + expect(mockApi.nativeFetch).toHaveBeenCalledTimes(4); + }); + + it("should return empty array for breachedAccounts on 404", async () => { + mockApi.getHibpBreach.mockRejectedValueOnce({ statusCode: 404 } as ErrorResponse); + const result = await auditService.breachedAccounts("user@example.com"); + expect(result).toEqual([]); + }); + + it("should throw error for breachedAccounts on non-404 error", async () => { + mockApi.getHibpBreach.mockRejectedValueOnce({ statusCode: 500 } as ErrorResponse); + await expect(auditService.breachedAccounts("user@example.com")).rejects.toThrow(); + }); +}); diff --git a/libs/common/src/services/audit.service.ts b/libs/common/src/services/audit.service.ts index 10654267687..d1eddbbdf82 100644 --- a/libs/common/src/services/audit.service.ts +++ b/libs/common/src/services/audit.service.ts @@ -1,21 +1,58 @@ +import { Subject } from "rxjs"; +import { mergeMap } from "rxjs/operators"; + import { ApiService } from "../abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "../abstractions/audit.service"; import { CryptoFunctionService } from "../key-management/crypto/abstractions/crypto-function.service"; import { BreachAccountResponse } from "../models/response/breach-account.response"; import { ErrorResponse } from "../models/response/error.response"; -import { throttle } from "../platform/misc/throttle"; import { Utils } from "../platform/misc/utils"; const PwnedPasswordsApi = "https://api.pwnedpasswords.com/range/"; export class AuditService implements AuditServiceAbstraction { + private passwordLeakedSubject = new Subject<{ + password: string; + resolve: (count: number) => void; + reject: (err: any) => void; + }>(); + constructor( private cryptoFunctionService: CryptoFunctionService, private apiService: ApiService, - ) {} + private readonly maxConcurrent: number = 100, // default to 100, can be overridden + ) { + this.maxConcurrent = maxConcurrent; + this.passwordLeakedSubject + .pipe( + mergeMap( + // Handle each password leak request, resolving or rejecting the associated promise. + async (req) => { + try { + const count = await this.fetchLeakedPasswordCount(req.password); + req.resolve(count); + } catch (err) { + req.reject(err); + } + }, + this.maxConcurrent, // Limit concurrent API calls + ), + ) + .subscribe(); + } - @throttle(100, () => "passwordLeaked") async passwordLeaked(password: string): Promise { + return new Promise((resolve, reject) => { + this.passwordLeakedSubject.next({ password, resolve, reject }); + }); + } + + /** + * Fetches the count of leaked passwords from the Pwned Passwords API. + * @param password The password to check. + * @returns A promise that resolves to the number of times the password has been leaked. + */ + protected async fetchLeakedPasswordCount(password: string): Promise { const hashBytes = await this.cryptoFunctionService.hash(password, "sha1"); const hash = Utils.fromBufferToHex(hashBytes).toUpperCase(); const hashStart = hash.substr(0, 5); From bef6182243b45029ddd06dd3d94c8f2f50a80555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Thu, 12 Jun 2025 18:53:35 +0200 Subject: [PATCH 16/17] PM-22221: Fix a race condition with cipher creation (#15157) * PM-22221: Fix a race condition with cipher creation * Mocked ciphers$ in tests * Neater tests --------- Co-authored-by: Robyn MacCallum --- .../fido2/fido2-authenticator.service.spec.ts | 14 +++++++----- .../fido2/fido2-authenticator.service.ts | 22 +++++++++++++++++-- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts index 78ae8253ee2..fef64399b40 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts @@ -5,11 +5,12 @@ import { BehaviorSubject, of } from "rxjs"; import { mockAccountServiceWith } from "../../../../spec"; import { Account } from "../../../auth/abstractions/account.service"; -import { UserId } from "../../../types/guid"; +import { CipherId, UserId } from "../../../types/guid"; import { CipherService, EncryptionContext } from "../../../vault/abstractions/cipher.service"; import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction"; import { CipherRepromptType } from "../../../vault/enums/cipher-reprompt-type"; import { CipherType } from "../../../vault/enums/cipher-type"; +import { CipherData } from "../../../vault/models/data/cipher.data"; import { Cipher } from "../../../vault/models/domain/cipher"; import { CipherView } from "../../../vault/models/view/cipher.view"; import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view"; @@ -218,9 +219,11 @@ describe("FidoAuthenticatorService", () => { beforeEach(async () => { existingCipher = createCipherView({ type: CipherType.Login }); params = await createParams({ requireResidentKey: false }); - cipherService.get.mockImplementation(async (id) => - id === existingCipher.id ? ({ decrypt: () => existingCipher } as any) : undefined, + + cipherService.ciphers$.mockImplementation((userId: UserId) => + of({ [existingCipher.id as CipherId]: {} as CipherData }), ); + cipherService.getAllDecrypted.mockResolvedValue([existingCipher]); cipherService.decrypt.mockResolvedValue(existingCipher); }); @@ -351,9 +354,10 @@ describe("FidoAuthenticatorService", () => { cipherId, userVerified: false, }); - cipherService.get.mockImplementation(async (cipherId) => - cipherId === cipher.id ? ({ decrypt: () => cipher } as any) : undefined, + cipherService.ciphers$.mockImplementation((userId: UserId) => + of({ [cipher.id as CipherId]: {} as CipherData }), ); + cipherService.getAllDecrypted.mockResolvedValue([await cipher]); cipherService.decrypt.mockResolvedValue(cipher); cipherService.encrypt.mockImplementation(async (cipher) => { diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts index a605e466338..bac1b218657 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts @@ -1,13 +1,15 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom } from "rxjs"; +import { filter, firstValueFrom, map, timeout } from "rxjs"; import { AccountService } from "../../../auth/abstractions/account.service"; import { getUserId } from "../../../auth/services/account.service"; +import { CipherId } from "../../../types/guid"; import { CipherService } from "../../../vault/abstractions/cipher.service"; import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction"; import { CipherRepromptType } from "../../../vault/enums/cipher-reprompt-type"; import { CipherType } from "../../../vault/enums/cipher-type"; +import { Cipher } from "../../../vault/models/domain/cipher"; import { CipherView } from "../../../vault/models/view/cipher.view"; import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view"; import { @@ -149,7 +151,23 @@ export class Fido2AuthenticatorService const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(getUserId), ); - const encrypted = await this.cipherService.get(cipherId, activeUserId); + + const encrypted = await firstValueFrom( + this.cipherService.ciphers$(activeUserId).pipe( + map((ciphers) => ciphers[cipherId as CipherId]), + filter((c) => c !== undefined), + timeout({ + first: 5000, + with: () => { + this.logService?.error( + `[Fido2Authenticator] Aborting because cipher with ID ${cipherId} could not be found within timeout.`, + ); + throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown); + }, + }), + map((c) => new Cipher(c, null)), + ), + ); cipher = await this.cipherService.decrypt(encrypted, activeUserId); From 3881192753a8a951ba3ac2dee1cb11aaee51d468 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:40:43 -0700 Subject: [PATCH 17/17] ensure favorites is included in vault filters (#15166) --- .../vault-filter/components/vault-filter.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index d21896e26fe..8987fff04cf 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -303,7 +303,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { .map((r) => r.cipherType); const toExclude = [...excludeTypes, ...restrictedByAll]; return this.allTypeFilters.filter( - (f) => typeof f.type !== "string" && !toExclude.includes(f.type), + (f) => typeof f.type === "string" || !toExclude.includes(f.type), ); }), switchMap((allowed) => this.vaultFilterService.buildTypeTree(allFilter, allowed)),