From 10a662cc356614fd721d46ff4cdda5519f997e4a Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Fri, 25 Oct 2024 18:15:48 +0200 Subject: [PATCH 01/17] [PM-14002] Fix username generator from vault item (#11714) * Fix "Forwarded email alias" not showing in drop-down * Fixed username and forwarder options not showing Options were being translated twice --------- Co-authored-by: Daniel James Smith --- .../generator/components/src/username-generator.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index 7ba4b254e98..5187a313d10 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -108,7 +108,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { map((algorithms) => { const usernames = algorithms.filter((a) => !isForwarderIntegration(a.id)); const usernameOptions = this.toOptions(usernames); - usernameOptions.push({ value: FORWARDER, label: this.i18nService.t("forwarder") }); + usernameOptions.push({ value: FORWARDER, label: this.i18nService.t("forwardedEmail") }); const forwarders = algorithms.filter((a) => isForwarderIntegration(a.id)); const forwarderOptions = this.toOptions(forwarders); @@ -413,7 +413,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { private toOptions(algorithms: AlgorithmInfo[]) { const options: Option[] = algorithms.map((algorithm) => ({ value: JSON.stringify(algorithm.id), - label: this.i18nService.t(algorithm.name), + label: algorithm.name, })); return options; From 85194fd1b51d971584f33104292520e08e4d7e2f Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Fri, 25 Oct 2024 12:46:02 -0400 Subject: [PATCH 02/17] [PM-12600] prevent verified user from deleting their account (#11665) --- apps/desktop/src/app/app.component.ts | 40 +++++++++++++++++++++-- apps/desktop/src/locales/en/messages.json | 6 ++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index cefcb9d7093..aba664e7a86 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -10,7 +10,17 @@ import { } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; -import { catchError, filter, firstValueFrom, map, of, Subject, takeUntil, timeout } from "rxjs"; +import { + catchError, + filter, + firstValueFrom, + map, + of, + Subject, + takeUntil, + timeout, + withLatestFrom, +} from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; @@ -22,6 +32,7 @@ import { NotificationsService } from "@bitwarden/common/abstractions/notificatio import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; @@ -155,6 +166,7 @@ export class AppComponent implements OnInit, OnDestroy { private stateEventRunnerService: StateEventRunnerService, private accountService: AccountService, private sdkService: SdkService, + private organizationService: OrganizationService, ) { if (flagEnabled("sdk")) { // Warn if the SDK for some reason can't be initialized @@ -309,7 +321,7 @@ export class AppComponent implements OnInit, OnDestroy { break; } case "deleteAccount": - DeleteAccountComponent.open(this.dialogService); + await this.deleteAccount(); break; case "openPasswordHistory": await this.openModal( @@ -863,4 +875,28 @@ export class AppComponent implements OnInit, OnDestroy { this.messagingService.send(message, { code: code, state: receivedState }); } + + private async deleteAccount() { + await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.AccountDeprovisioning).pipe( + withLatestFrom(this.organizationService.organizations$), + map(async ([accountDeprovisioningEnabled, organization]) => { + if ( + accountDeprovisioningEnabled && + organization.some((o) => o.userIsManagedByOrganization === true) + ) { + await this.dialogService.openSimpleDialog({ + title: { key: "cannotDeleteAccount" }, + content: { key: "cannotDeleteAccountDesc" }, + cancelButtonText: null, + acceptButtonText: { key: "close" }, + type: "danger", + }); + } else { + DeleteAccountComponent.open(this.dialogService); + } + }), + ), + ); + } } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 64109a052c6..d24616edab5 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -1683,6 +1683,12 @@ "deleteAccountWarning": { "message": "Deleting your account is permanent. It cannot be undone." }, + "cannotDeleteAccount":{ + "message": "Cannot delete account" + }, + "cannotDeleteAccountDesc":{ + "message": "This action cannot be completed because your account is owned by an organization. Contact your organization administrator for additional details." + }, "accountDeleted": { "message": "Account deleted" }, From 8b64f0de9cb7640d3d790f16e97e0785249fb32f Mon Sep 17 00:00:00 2001 From: cd-bitwarden <106776772+cd-bitwarden@users.noreply.github.com> Date: Fri, 25 Oct 2024 14:19:56 -0400 Subject: [PATCH 03/17] [PM-1992] in ac add current collection actions in menu and deprecate collections component (#11364) * Removing feature flag * Removing flag from feature-flag.enum.ts * suggested changes * prettier * fixing merge conflict issue * Removing unused code * suggested change from Gbubemi * Adding back merge conflict code * fixing prettier styling * Deprecating collections component, removing unused code now that we dont use collections component in AC * Removing all Collections component logic from loos-components module and removing the files themselves as they are no longer needed. --- .../src/app/shared/loose-components.module.ts | 6 - .../collections.component.html | 52 -------- .../individual-vault/collections.component.ts | 90 -------------- .../vault/org-vault/collections.component.ts | 115 ------------------ .../app/vault/org-vault/vault.component.ts | 35 ------ 5 files changed, 298 deletions(-) delete mode 100644 apps/web/src/app/vault/individual-vault/collections.component.html delete mode 100644 apps/web/src/app/vault/individual-vault/collections.component.ts delete mode 100644 apps/web/src/app/vault/org-vault/collections.component.ts diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index 29b4f2308c7..638a523cd4f 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -76,13 +76,11 @@ import { PremiumBadgeComponent } from "../vault/components/premium-badge.compone import { AddEditCustomFieldsComponent } from "../vault/individual-vault/add-edit-custom-fields.component"; import { AddEditComponent } from "../vault/individual-vault/add-edit.component"; import { AttachmentsComponent } from "../vault/individual-vault/attachments.component"; -import { CollectionsComponent } from "../vault/individual-vault/collections.component"; import { FolderAddEditComponent } from "../vault/individual-vault/folder-add-edit.component"; import { OrganizationBadgeModule } from "../vault/individual-vault/organization-badge/organization-badge.module"; import { PipesModule } from "../vault/individual-vault/pipes/pipes.module"; import { AddEditComponent as OrgAddEditComponent } from "../vault/org-vault/add-edit.component"; import { AttachmentsComponent as OrgAttachmentsComponent } from "../vault/org-vault/attachments.component"; -import { CollectionsComponent as OrgCollectionsComponent } from "../vault/org-vault/collections.component"; import { PurgeVaultComponent } from "../vault/settings/purge-vault.component"; import { EnvironmentSelectorModule } from "./../components/environment-selector/environment-selector.module"; @@ -123,7 +121,6 @@ import { SharedModule } from "./shared.module"; ApiKeyComponent, AttachmentsComponent, ChangeEmailComponent, - CollectionsComponent, DeauthorizeSessionsComponent, DeleteAccountDialogComponent, DomainRulesComponent, @@ -139,7 +136,6 @@ import { SharedModule } from "./shared.module"; HintComponent, OrgAddEditComponent, OrgAttachmentsComponent, - OrgCollectionsComponent, OrgEventsComponent, OrgExposedPasswordsReportComponent, OrgInactiveTwoFactorReportComponent, @@ -192,7 +188,6 @@ import { SharedModule } from "./shared.module"; ApiKeyComponent, AttachmentsComponent, ChangeEmailComponent, - CollectionsComponent, DeauthorizeSessionsComponent, DeleteAccountDialogComponent, DomainRulesComponent, @@ -210,7 +205,6 @@ import { SharedModule } from "./shared.module"; OrgAddEditComponent, OrganizationLayoutComponent, OrgAttachmentsComponent, - OrgCollectionsComponent, OrgEventsComponent, OrgExposedPasswordsReportComponent, OrgInactiveTwoFactorReportComponent, diff --git a/apps/web/src/app/vault/individual-vault/collections.component.html b/apps/web/src/app/vault/individual-vault/collections.component.html deleted file mode 100644 index 028d91ad346..00000000000 --- a/apps/web/src/app/vault/individual-vault/collections.component.html +++ /dev/null @@ -1,52 +0,0 @@ -
- - - {{ "collections" | i18n }} - {{ cipher.name }} - - -

{{ "collectionsDesc" | i18n }}

-
- -
- - -
-
-
- {{ "noCollectionsInList" | i18n }} -
- - - - - - {{ c.name }} - - - - -
- - - - -
-
diff --git a/apps/web/src/app/vault/individual-vault/collections.component.ts b/apps/web/src/app/vault/individual-vault/collections.component.ts deleted file mode 100644 index f527a74e368..00000000000 --- a/apps/web/src/app/vault/individual-vault/collections.component.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; -import { Component, Inject, OnDestroy } from "@angular/core"; - -import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; -import { CollectionsComponent as BaseCollectionsComponent } from "@bitwarden/angular/admin-console/components/collections.component"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { DialogService, ToastService } from "@bitwarden/components"; - -@Component({ - selector: "app-vault-collections", - templateUrl: "collections.component.html", -}) -export class CollectionsComponent extends BaseCollectionsComponent implements OnDestroy { - constructor( - collectionService: CollectionService, - platformUtilsService: PlatformUtilsService, - i18nService: I18nService, - cipherService: CipherService, - organizationSerivce: OrganizationService, - logService: LogService, - accountService: AccountService, - protected dialogRef: DialogRef, - @Inject(DIALOG_DATA) params: CollectionsDialogParams, - toastService: ToastService, - ) { - super( - collectionService, - platformUtilsService, - i18nService, - cipherService, - organizationSerivce, - logService, - accountService, - toastService, - ); - this.cipherId = params?.cipherId; - } - - override async submit(): Promise { - const success = await super.submit(); - if (success) { - this.dialogRef.close(CollectionsDialogResult.Saved); - return true; - } - return false; - } - - check(c: CollectionView, select?: boolean) { - if (!c.canEditItems(this.organization)) { - return; - } - (c as any).checked = select == null ? !(c as any).checked : select; - } - - selectAll(select: boolean) { - this.collections.forEach((c) => this.check(c, select)); - } - - ngOnDestroy() { - this.selectAll(false); - } -} - -export interface CollectionsDialogParams { - cipherId: string; -} - -export enum CollectionsDialogResult { - Saved = "saved", -} - -/** - * Strongly typed helper to open a Collections dialog - * @param dialogService Instance of the dialog service that will be used to open the dialog - * @param config Optional configuration for the dialog - */ -export function openIndividualVaultCollectionsDialog( - dialogService: DialogService, - config?: DialogConfig, -) { - return dialogService.open( - CollectionsComponent, - config, - ); -} diff --git a/apps/web/src/app/vault/org-vault/collections.component.ts b/apps/web/src/app/vault/org-vault/collections.component.ts deleted file mode 100644 index 47f5325c756..00000000000 --- a/apps/web/src/app/vault/org-vault/collections.component.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; -import { Component, Inject } from "@angular/core"; - -import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -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"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; -import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; -import { CipherCollectionsRequest } from "@bitwarden/common/vault/models/request/cipher-collections.request"; -import { DialogService, ToastService } from "@bitwarden/components"; - -import { - CollectionsComponent as BaseCollectionsComponent, - CollectionsDialogResult, -} from "../individual-vault/collections.component"; - -@Component({ - selector: "app-org-vault-collections", - templateUrl: "../../vault/individual-vault/collections.component.html", -}) -export class CollectionsComponent extends BaseCollectionsComponent { - organization: Organization; - - constructor( - collectionService: CollectionService, - platformUtilsService: PlatformUtilsService, - i18nService: I18nService, - cipherService: CipherService, - organizationService: OrganizationService, - private apiService: ApiService, - logService: LogService, - accountService: AccountService, - protected dialogRef: DialogRef, - @Inject(DIALOG_DATA) params: OrgVaultCollectionsDialogParams, - toastService: ToastService, - ) { - super( - collectionService, - platformUtilsService, - i18nService, - cipherService, - organizationService, - logService, - accountService, - dialogRef, - params, - toastService, - ); - this.allowSelectNone = true; - this.collectionIds = params?.collectionIds; - this.collections = params?.collections; - this.organization = params?.organization; - this.cipherId = params?.cipherId; - } - - protected async loadCipher() { - // if cipher is unassigned use apiService. We can see this by looking at this.collectionIds - if (!this.organization.canEditAllCiphers && this.collectionIds.length !== 0) { - return await super.loadCipher(); - } - const response = await this.apiService.getCipherAdmin(this.cipherId); - return new Cipher(new CipherData(response)); - } - - protected loadCipherCollections() { - if (!this.organization.canViewAllCollections) { - return super.loadCipherCollections(); - } - return this.collectionIds; - } - - protected loadCollections() { - if (!this.organization.canViewAllCollections) { - return super.loadCollections(); - } - return Promise.resolve(this.collections); - } - - protected saveCollections() { - if (this.organization.canEditAllCiphers || this.collectionIds.length === 0) { - const request = new CipherCollectionsRequest(this.cipherDomain.collectionIds); - return this.apiService.putCipherCollectionsAdmin(this.cipherId, request); - } else { - return super.saveCollections(); - } - } -} - -export interface OrgVaultCollectionsDialogParams { - collectionIds: string[]; - collections: CollectionView[]; - organization: Organization; - cipherId: string; -} - -/** - * Strongly typed helper to open a Collections dialog - * @param dialogService Instance of the dialog service that will be used to open the dialog - * @param config Optional configuration for the dialog - */ -export function openOrgVaultCollectionsDialog( - dialogService: DialogService, - config?: DialogConfig, -) { - return dialogService.open( - CollectionsComponent, - config, - ); -} diff --git a/apps/web/src/app/vault/org-vault/vault.component.ts b/apps/web/src/app/vault/org-vault/vault.component.ts index 87e40036d50..4c7e35ef6f4 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -93,7 +93,6 @@ import { BulkDeleteDialogResult, openBulkDeleteDialog, } from "../individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component"; -import { CollectionsDialogResult } from "../individual-vault/collections.component"; import { RoutedVaultFilterBridgeService } from "../individual-vault/vault-filter/services/routed-vault-filter-bridge.service"; import { RoutedVaultFilterService } from "../individual-vault/vault-filter/services/routed-vault-filter.service"; import { createFilterFunction } from "../individual-vault/vault-filter/shared/models/filter-function"; @@ -111,7 +110,6 @@ import { BulkCollectionsDialogResult, } from "./bulk-collections-dialog"; import { CollectionAccessRestrictedComponent } from "./collection-access-restricted.component"; -import { openOrgVaultCollectionsDialog } from "./collections.component"; import { AdminConsoleCipherFormConfigService } from "./services/admin-console-cipher-form-config.service"; import { VaultFilterModule } from "./vault-filter/vault-filter.module"; const BroadcasterSubscriptionId = "OrgVaultComponent"; @@ -718,39 +716,6 @@ export class VaultComponent implements OnInit, OnDestroy { }); } - async editCipherCollections(cipher: CipherView) { - let collections: CollectionAdminView[] = []; - - // Admins limited to only adding items to collections they have access to. - collections = await firstValueFrom( - this.allCollectionsWithoutUnassigned$.pipe( - map((c) => { - return c.sort((a, b) => { - if (a.canEditItems(this.organization) && !b.canEditItems(this.organization)) { - return -1; - } else if (!a.canEditItems(this.organization) && b.canEditItems(this.organization)) { - return 1; - } else { - return a.name.localeCompare(b.name); - } - }); - }), - ), - ); - const dialog = openOrgVaultCollectionsDialog(this.dialogService, { - data: { - collectionIds: cipher.collectionIds, - collections: collections, - organization: this.organization, - cipherId: cipher.id, - }, - }); - - if ((await lastValueFrom(dialog.closed)) == CollectionsDialogResult.Saved) { - this.refresh(); - } - } - async addCipher(cipherType?: CipherType) { if (this.extensionRefreshEnabled) { return this.addCipherV2(cipherType); From 2fb6a9e1eead16c4a454a6da1ba3b4937895dc20 Mon Sep 17 00:00:00 2001 From: Alec Rippberger <127791530+alec-livefront@users.noreply.github.com> Date: Fri, 25 Oct 2024 13:58:02 -0500 Subject: [PATCH 04/17] [PM-13150] Remove uses of row-main Bootstrap class in Account Security Settings (#11650) * Remove uses of row-main Bootstrap class in Account Security Settings component * Remove class from new component * Remove extra div * Remove extra markup --- .../src/auth/popup/settings/account-security.component.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/browser/src/auth/popup/settings/account-security.component.html b/apps/browser/src/auth/popup/settings/account-security.component.html index af6525daa8a..00e1fd17150 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.html +++ b/apps/browser/src/auth/popup/settings/account-security.component.html @@ -94,7 +94,7 @@ @@ -115,11 +115,11 @@ " > -
{{ "lockNow" | i18n }}
+ {{ "lockNow" | i18n }}
-
{{ "logOut" | i18n }}
+ {{ "logOut" | i18n }}
From 237887a368fbcc47c6db161a290950f5f01a9aa1 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Fri, 25 Oct 2024 15:18:28 -0400 Subject: [PATCH 05/17] Show toast when copying SCIM api key and URL (#11608) --- .../organizations/manage/scim.component.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.ts index 76e3caa145f..c900cfe28a6 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.ts @@ -82,6 +82,11 @@ export class ScimComponent implements OnInit { copyScimUrl = async () => { this.platformUtilsService.copyToClipboard(await this.getScimEndpointUrl()); + this.toastService.showToast({ + message: this.i18nService.t("valueCopied", this.i18nService.t("scimUrl")), + variant: "success", + title: null, + }); }; rotateScimKey = async () => { @@ -114,6 +119,11 @@ export class ScimComponent implements OnInit { copyScimKey = async () => { this.platformUtilsService.copyToClipboard(this.formData.get("clientSecret").value); + this.toastService.showToast({ + message: this.i18nService.t("valueCopied", this.i18nService.t("scimApiKey")), + variant: "success", + title: null, + }); }; submit = async () => { From 418889446801d868c5fb1c40b277b8fcae01db96 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Fri, 25 Oct 2024 13:04:22 -0700 Subject: [PATCH 06/17] [PM-13979] - add remaining components to access intelligence page (#11677) * add remaining components to access intelligence page * small css adjustments --- .../access-intelligence.component.html | 18 +++++++++++++++++- .../access-intelligence.component.ts | 15 ++++++++++++++- apps/web/src/locales/en/messages.json | 15 +++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/tools/access-intelligence/access-intelligence.component.html b/apps/web/src/app/tools/access-intelligence/access-intelligence.component.html index 78ddfb23929..44ca90cfaa5 100644 --- a/apps/web/src/app/tools/access-intelligence/access-intelligence.component.html +++ b/apps/web/src/app/tools/access-intelligence/access-intelligence.component.html @@ -1,4 +1,20 @@ - +
{{ "accessIntelligence" | i18n }}
+

{{ "passwordRisk" | i18n }}

+
{{ "discoverAtRiskPasswords" | i18n }}
+
+ + {{ + "dataLastUpdated" | i18n: (dataLastUpdated | date: "MMMM d, y 'at' h:mm a") + }} + + {{ "refresh" | i18n }} + +
diff --git a/apps/web/src/app/tools/access-intelligence/access-intelligence.component.ts b/apps/web/src/app/tools/access-intelligence/access-intelligence.component.ts index 3444e3a7ff1..90b2979d1b1 100644 --- a/apps/web/src/app/tools/access-intelligence/access-intelligence.component.ts +++ b/apps/web/src/app/tools/access-intelligence/access-intelligence.component.ts @@ -5,7 +5,7 @@ import { ActivatedRoute } from "@angular/router"; import { first } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { TabsModule } from "@bitwarden/components"; +import { AsyncActionsModule, ButtonModule, TabsModule } from "@bitwarden/components"; import { HeaderModule } from "../../layouts/header/header.module"; @@ -25,6 +25,8 @@ export enum AccessIntelligenceTabType { templateUrl: "./access-intelligence.component.html", imports: [ ApplicationTableComponent, + AsyncActionsModule, + ButtonModule, CommonModule, JslibModule, HeaderModule, @@ -36,11 +38,22 @@ export enum AccessIntelligenceTabType { }) export class AccessIntelligenceComponent { tabIndex: AccessIntelligenceTabType; + dataLastUpdated = new Date(); apps: any[] = []; priorityApps: any[] = []; notifiedMembers: any[] = []; + async refreshData() { + // TODO: Implement + return new Promise((resolve) => + setTimeout(() => { + this.dataLastUpdated = new Date(); + resolve(true); + }, 1000), + ); + } + constructor(route: ActivatedRoute) { route.queryParams.pipe(takeUntilDestroyed(), first()).subscribe(({ tabIndex }) => { this.tabIndex = !isNaN(tabIndex) ? tabIndex : AccessIntelligenceTabType.AllApps; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 470852dce40..d1c5bd97f3b 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -8,6 +8,21 @@ "accessIntelligence": { "message": "Access Intelligence" }, + "passwordRisk": { + "message": "Password Risk" + }, + "discoverAtRiskPasswords": { + "message": "Discover at-risk passwords and notify users to change those passwords." + }, + "dataLastUpdated": { + "message": "Data last updated: $DATE$", + "placeholders": { + "date": { + "content": "$1", + "example": "2021-01-01" + } + } + }, "notifiedMembers": { "message": "Notified members" }, From ad7fa71e76d0b9175a340c160af94babd528f87f Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Fri, 25 Oct 2024 13:04:36 -0700 Subject: [PATCH 07/17] [PM-13931] - UI - All applications report table (#11678) * add remaining components to access intelligence page * Revert "add remaining components to access intelligence page" This reverts commit cb8e8266561e32baa34a82241a05c5d38f1cdd97. * add remaining access intelligence table components --- .../no-priority-apps.component.html | 15 ++++++++ .../no-priority-apps.component.ts | 15 ++++++++ .../password-health-members.component.html | 28 ++++++++++++++- .../password-health-members.component.ts | 36 ++++++++++--------- .../password-health.component.html | 2 +- apps/web/src/locales/en/messages.json | 12 +++++++ libs/tools/card/src/card.component.ts | 2 +- 7 files changed, 91 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/app/tools/access-intelligence/no-priority-apps.component.html create mode 100644 apps/web/src/app/tools/access-intelligence/no-priority-apps.component.ts diff --git a/apps/web/src/app/tools/access-intelligence/no-priority-apps.component.html b/apps/web/src/app/tools/access-intelligence/no-priority-apps.component.html new file mode 100644 index 00000000000..9c3b4f48916 --- /dev/null +++ b/apps/web/src/app/tools/access-intelligence/no-priority-apps.component.html @@ -0,0 +1,15 @@ + + +

+ {{ "noPriorityApplicationsTitle" | i18n }} +

+
+ +

+ {{ "noPriorityApplicationsDescription" | i18n }} +

+
+ + + +
diff --git a/apps/web/src/app/tools/access-intelligence/no-priority-apps.component.ts b/apps/web/src/app/tools/access-intelligence/no-priority-apps.component.ts new file mode 100644 index 00000000000..e4e54ca2f13 --- /dev/null +++ b/apps/web/src/app/tools/access-intelligence/no-priority-apps.component.ts @@ -0,0 +1,15 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ButtonModule, NoItemsModule, Icons } from "@bitwarden/components"; + +@Component({ + standalone: true, + selector: "tools-no-priority-apps", + templateUrl: "no-priority-apps.component.html", + imports: [ButtonModule, CommonModule, JslibModule, NoItemsModule], +}) +export class NoPriorityAppsComponent { + noItemsIcon = Icons.NoResults; +} diff --git a/apps/web/src/app/tools/access-intelligence/password-health-members.component.html b/apps/web/src/app/tools/access-intelligence/password-health-members.component.html index f902011110b..885c21f0a2c 100644 --- a/apps/web/src/app/tools/access-intelligence/password-health-members.component.html +++ b/apps/web/src/app/tools/access-intelligence/password-health-members.component.html @@ -8,7 +8,33 @@ > {{ "loading" | i18n }} -
+
+ +
+
+
+ + + + +
+
+ + +
diff --git a/apps/web/src/app/tools/access-intelligence/password-health-members.component.ts b/apps/web/src/app/tools/access-intelligence/password-health-members.component.ts index 30c9ad8dba8..17b2456406b 100644 --- a/apps/web/src/app/tools/access-intelligence/password-health-members.component.ts +++ b/apps/web/src/app/tools/access-intelligence/password-health-members.component.ts @@ -1,10 +1,9 @@ -import { CommonModule } from "@angular/common"; import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormControl, FormsModule } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { map } from "rxjs"; +import { debounceTime, map } from "rxjs"; -import { JslibModule } from "@bitwarden/angular/jslib.module"; // eslint-disable-next-line no-restricted-imports import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/access-intelligence"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; @@ -12,33 +11,31 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { - BadgeModule, - BadgeVariant, - ContainerComponent, - TableDataSource, - TableModule, -} from "@bitwarden/components"; +import { BadgeVariant, SearchModule, TableDataSource, TableModule } from "@bitwarden/components"; +import { CardComponent } from "@bitwarden/tools-card"; -// eslint-disable-next-line no-restricted-imports import { HeaderModule } from "../../layouts/header/header.module"; // eslint-disable-next-line no-restricted-imports +import { SharedModule } from "../../shared"; import { OrganizationBadgeModule } from "../../vault/individual-vault/organization-badge/organization-badge.module"; // eslint-disable-next-line no-restricted-imports import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module"; +import { NoPriorityAppsComponent } from "./no-priority-apps.component"; + @Component({ standalone: true, selector: "tools-password-health-members", templateUrl: "password-health-members.component.html", imports: [ - BadgeModule, + CardComponent, OrganizationBadgeModule, - CommonModule, - ContainerComponent, PipesModule, - JslibModule, HeaderModule, + SearchModule, + FormsModule, + NoPriorityAppsComponent, + SharedModule, TableModule, ], providers: [PasswordHealthService], @@ -56,6 +53,8 @@ export class PasswordHealthMembersComponent implements OnInit { loading = true; + protected searchControl = new FormControl("", { nonNullable: true }); + private destroyRef = inject(DestroyRef); constructor( @@ -64,7 +63,11 @@ export class PasswordHealthMembersComponent implements OnInit { protected auditService: AuditService, protected i18nService: I18nService, protected activatedRoute: ActivatedRoute, - ) {} + ) { + this.searchControl.valueChanges + .pipe(debounceTime(200), takeUntilDestroyed()) + .subscribe((v) => (this.dataSource.filter = v)); + } ngOnInit() { this.activatedRoute.paramMap @@ -89,6 +92,7 @@ export class PasswordHealthMembersComponent implements OnInit { await passwordHealthService.generateReport(); this.dataSource.data = passwordHealthService.reportCiphers; + this.exposedPasswordMap = passwordHealthService.exposedPasswordMap; this.passwordStrengthMap = passwordHealthService.passwordStrengthMap; this.passwordUseMap = passwordHealthService.passwordUseMap; diff --git a/apps/web/src/app/tools/access-intelligence/password-health.component.html b/apps/web/src/app/tools/access-intelligence/password-health.component.html index 32459706449..5b1fe4610d9 100644 --- a/apps/web/src/app/tools/access-intelligence/password-health.component.html +++ b/apps/web/src/app/tools/access-intelligence/password-health.component.html @@ -8,7 +8,7 @@ > {{ "loading" | i18n }}
-
+
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index d1c5bd97f3b..9ea33149de8 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -53,6 +53,18 @@ } } }, + "noPriorityApplicationsTitle": { + "message": "You haven’t marked any applications as a priority" + }, + "noPriorityApplicationsDescription": { + "message": "Select your most critical applications to discover at-risk passwords, and notify users to change those passwords." + }, + "markPriorityApps": { + "message": "Mark priority apps" + }, + "markAppAsCritical": { + "message": "Mark app as critical" + }, "application": { "message": "Application" }, diff --git a/libs/tools/card/src/card.component.ts b/libs/tools/card/src/card.component.ts index 9305246c581..85db7eaa7b3 100644 --- a/libs/tools/card/src/card.component.ts +++ b/libs/tools/card/src/card.component.ts @@ -11,7 +11,7 @@ import { TypographyModule } from "@bitwarden/components"; imports: [CommonModule, TypographyModule, JslibModule], host: { class: - "tw-box-border tw-bg-background tw-block tw-text-main tw-border-solid tw-border tw-border-secondary-300 tw-border [&:not(bit-layout_*)]:tw-rounded-lg tw-p-6", + "tw-box-border tw-bg-background tw-block tw-text-main tw-border-solid tw-border tw-border-secondary-300 tw-border [&:not(bit-layout_*)]:tw-rounded-lg tw-rounded-lg tw-p-6", }, }) export class CardComponent { From 5f0755d74d90b773fc6e70d6ead5139c3691471f Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez Date: Fri, 25 Oct 2024 16:17:36 -0500 Subject: [PATCH 08/17] [PM-14045] Scrolling content outside of iframe bounds breaks inline menu position (#11716) * [PM-14045] Scrolling content outside of iframe bounds breaks inline menu position * [PM-14045] Scrolling content outside of iframe bounds breaks inline menu position * [PM-14045] Fixing jest test * [PM-14045] Adjusting how we determine if the inline menu should reposition on scroll --- .../abstractions/overlay.background.ts | 1 + .../autofill/background/overlay.background.ts | 16 ++++++++ .../autofill-overlay-content.service.spec.ts | 1 + .../autofill-overlay-content.service.ts | 37 ++++++++++++++++--- 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/apps/browser/src/autofill/background/abstractions/overlay.background.ts b/apps/browser/src/autofill/background/abstractions/overlay.background.ts index 68d3f32b80f..db50b784453 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay.background.ts @@ -216,6 +216,7 @@ export type OverlayBackgroundExtensionMessageHandlers = { getCurrentTabFrameId: ({ sender }: BackgroundSenderParam) => number; updateSubFrameData: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; triggerSubFrameFocusInRebuild: ({ sender }: BackgroundSenderParam) => void; + shouldRepositionSubFrameInlineMenuOnScroll: ({ sender }: BackgroundSenderParam) => void; destroyAutofillInlineMenuListeners: ({ message, sender, diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 2b8f2c273c7..6fb4589baa1 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -168,6 +168,8 @@ export class OverlayBackground implements OverlayBackgroundInterface { getCurrentTabFrameId: ({ sender }) => this.getSenderFrameId(sender), updateSubFrameData: ({ message, sender }) => this.updateSubFrameData(message, sender), triggerSubFrameFocusInRebuild: ({ sender }) => this.triggerSubFrameFocusInRebuild(sender), + shouldRepositionSubFrameInlineMenuOnScroll: ({ sender }) => + this.shouldRepositionSubFrameInlineMenuOnScroll(sender), destroyAutofillInlineMenuListeners: ({ message, sender }) => this.triggerDestroyInlineMenuListeners(sender.tab, message.subFrameData.frameId), collectPageDetailsResponse: ({ message, sender }) => this.storePageDetails(message, sender), @@ -2594,6 +2596,20 @@ export class OverlayBackground implements OverlayBackgroundInterface { this.repositionInlineMenu$.next(sender); } + /** + * Triggers on scroll of a frame within the tab. Will reposition the inline menu + * if the focused field is within a sub-frame and the inline menu is visible. + * + * @param sender - The sender of the message + */ + private shouldRepositionSubFrameInlineMenuOnScroll(sender: chrome.runtime.MessageSender) { + if (!this.isInlineMenuButtonVisible || sender.tab.id !== this.focusedFieldData?.tabId) { + return false; + } + + return this.focusedFieldData.frameId > 0; + } + /** * Handles determining if the inline menu should be repositioned or closed, and initiates * the process of calculating the new position of the inline menu. diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts index c74fa21937a..91ad63955c7 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts @@ -1699,6 +1699,7 @@ describe("AutofillOverlayContentService", () => { const repositionEvents = [EVENTS.SCROLL, EVENTS.RESIZE]; repositionEvents.forEach((repositionEvent) => { it(`sends a message trigger overlay reposition message to the background when a ${repositionEvent} event occurs`, async () => { + sendExtensionMessageSpy.mockResolvedValueOnce(true); globalThis.dispatchEvent(new Event(repositionEvent)); await flushPromises(); diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 760a585bd60..645795d9f27 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -1571,14 +1571,35 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ AUTOFILL_OVERLAY_HANDLE_REPOSITION, ); - const eventTargetDoesNotContainFocusedField = (element: Element) => - typeof element?.contains === "function" && !element.contains(this.mostRecentlyFocusedField); + const eventTargetContainsFocusedField = (eventTarget: Element | Document) => { + if (!eventTarget || !this.mostRecentlyFocusedField) { + return false; + } + + const activeElement = (eventTarget as Document).activeElement; + if (activeElement) { + return ( + activeElement === this.mostRecentlyFocusedField || + activeElement.contains(this.mostRecentlyFocusedField) + ); + } + + if (typeof eventTarget.contains !== "function") { + return false; + } + return ( + eventTarget === this.mostRecentlyFocusedField || + eventTarget.contains(this.mostRecentlyFocusedField) + ); + }; const scrollHandler = this.useEventHandlersMemo( - throttle((event) => { - if (eventTargetDoesNotContainFocusedField(event.target as Element)) { - return; + throttle(async (event) => { + if ( + eventTargetContainsFocusedField(event.target) || + (await this.shouldRepositionSubFrameInlineMenuOnScroll()) + ) { + repositionHandler(event); } - repositionHandler(event); }, 50), AUTOFILL_OVERLAY_HANDLE_SCROLL, ); @@ -1590,6 +1611,10 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ globalThis.addEventListener(EVENTS.RESIZE, repositionHandler); } + private shouldRepositionSubFrameInlineMenuOnScroll = async () => { + return await this.sendExtensionMessage("shouldRepositionSubFrameInlineMenuOnScroll"); + }; + /** * Removes the listeners that facilitate repositioning * the overlay elements on scroll or resize. From c780cbc77eb4dabeaeb32bd256b0c293f6083d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Fri, 25 Oct 2024 17:43:49 -0400 Subject: [PATCH 09/17] use `valueLabel` with `appCopyClick` directive (#11718) * add `valueLabel` to `appCopyClick` directives * move generated value description to the configuration --- .../src/credential-generator.component.html | 1 + .../src/credential-generator.component.ts | 8 ++++++++ .../src/password-generator.component.html | 1 + .../components/src/password-generator.component.ts | 8 ++++++++ .../src/username-generator.component.html | 1 + .../components/src/username-generator.component.ts | 8 ++++++++ libs/tools/generator/core/src/data/generators.ts | 6 ++++++ .../services/credential-generator.service.spec.ts | 2 ++ .../src/services/credential-generator.service.ts | 1 + .../types/credential-generator-configuration.ts | 14 ++++++++++---- 10 files changed, 46 insertions(+), 4 deletions(-) diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index 737e32fa1f9..f580b75f1ba 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -32,6 +32,7 @@ showToast [appA11yTitle]="credentialTypeCopyLabel$ | async" [appCopyClick]="value$ | async" + [valueLabel]="credentialTypeLabel$ | async" >
diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index e800ce4bd39..579d196a7a6 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -468,6 +468,14 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { map(({ generate }) => generate), ); + /** + * Emits the copy credential toast respective of the selected credential type + */ + protected credentialTypeLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ generatedValue }) => generatedValue), + ); + /** Emits hint key for the currently selected credential type */ protected credentialTypeHint$ = new ReplaySubject(1); diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index 96aa8f00b1c..6726df30855 100644 --- a/libs/tools/generator/components/src/password-generator.component.html +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -30,6 +30,7 @@ showToast [appA11yTitle]="credentialTypeCopyLabel$ | async" [appCopyClick]="value$ | async" + [valueLabel]="credentialTypeLabel$ | async" >
diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index 60c3f629538..8566edf4664 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -233,6 +233,14 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { map(({ generate }) => generate), ); + /** + * Emits the copy credential toast respective of the selected credential type + */ + protected credentialTypeLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ generatedValue }) => generatedValue), + ); + private toOptions(algorithms: AlgorithmInfo[]) { const options: Option[] = algorithms.map((algorithm) => ({ value: algorithm.id, diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index 18b29a6184b..36aaae57ce2 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -19,6 +19,7 @@ showToast [appA11yTitle]="credentialTypeCopyLabel$ | async" [appCopyClick]="value$ | async" + [valueLabel]="credentialTypeLabel$ | async" > {{ credentialTypeCopyLabel$ | async }} diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index 5187a313d10..6518ee51ed8 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -390,6 +390,14 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { map(({ generate }) => generate), ); + /** + * Emits the copy credential toast respective of the selected credential type + */ + protected credentialTypeLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ generatedValue }) => generatedValue), + ); + /** Emits hint key for the currently selected credential type */ protected credentialTypeHint$ = new ReplaySubject(1); diff --git a/libs/tools/generator/core/src/data/generators.ts b/libs/tools/generator/core/src/data/generators.ts index d86eb52a8fa..6090fe789cb 100644 --- a/libs/tools/generator/core/src/data/generators.ts +++ b/libs/tools/generator/core/src/data/generators.ts @@ -53,6 +53,7 @@ const PASSPHRASE = Object.freeze({ category: "password", nameKey: "passphrase", generateKey: "generatePassphrase", + generatedValueKey: "passphrase", copyKey: "copyPassphrase", onlyOnRequest: false, request: [], @@ -95,6 +96,7 @@ const PASSWORD = Object.freeze({ category: "password", nameKey: "password", generateKey: "generatePassword", + generatedValueKey: "password", copyKey: "copyPassword", onlyOnRequest: false, request: [], @@ -145,6 +147,7 @@ const USERNAME = Object.freeze({ category: "username", nameKey: "randomWord", generateKey: "generateUsername", + generatedValueKey: "username", copyKey: "copyUsername", onlyOnRequest: false, request: [], @@ -181,6 +184,7 @@ const CATCHALL = Object.freeze({ nameKey: "catchallEmail", descriptionKey: "catchallEmailDesc", generateKey: "generateEmail", + generatedValueKey: "email", copyKey: "copyEmail", onlyOnRequest: false, request: [], @@ -217,6 +221,7 @@ const SUBADDRESS = Object.freeze({ nameKey: "plusAddressedEmail", descriptionKey: "plusAddressedEmailDesc", generateKey: "generateEmail", + generatedValueKey: "email", copyKey: "copyEmail", onlyOnRequest: false, request: [], @@ -256,6 +261,7 @@ export function toCredentialGeneratorConfiguration Date: Sat, 26 Oct 2024 13:47:40 +0200 Subject: [PATCH 10/17] [PM-12607] Move key rotation to km ownership (#11709) * Move key rotation to km ownership * Fix build * Move userkey rotation data provider abstraction to km ownership * Move userkey rotation data provider abstraction to km ownership * Fix linting * Fix CODEOWNERS for key-management web * Remove incorrect export * Fix import error --- .github/CODEOWNERS | 2 +- .../organization-user-reset-password.service.ts | 3 +-- .../services/webauthn-login/webauthn-login-admin.service.ts | 3 ++- .../emergency-access/services/emergency-access.service.ts | 3 +-- apps/web/src/app/auth/settings/change-password.component.ts | 2 +- apps/web/src/app/auth/settings/settings.module.ts | 2 +- .../key-rotation/request/update-key.request.ts | 2 +- .../key-rotation/user-key-rotation-api.service.ts | 0 .../key-rotation/user-key-rotation.module.ts | 0 .../key-rotation/user-key-rotation.service.spec.ts | 0 .../key-rotation/user-key-rotation.service.ts | 4 ++-- .../migrate-legacy-encryption.component.html | 0 .../migrate-encryption/migrate-legacy-encryption.component.ts | 0 apps/web/src/app/oss-routing.module.ts | 2 +- libs/auth/src/common/abstractions/index.ts | 1 - .../src/tools/send/services/send.service.abstraction.ts | 2 +- libs/common/src/vault/abstractions/cipher.service.ts | 2 +- .../vault/abstractions/folder/folder.service.abstraction.ts | 2 +- .../user-key-rotation-data-provider.abstraction.ts | 0 libs/key-management/src/index.ts | 1 + 20 files changed, 15 insertions(+), 16 deletions(-) rename apps/web/src/app/{auth => key-management}/key-rotation/request/update-key.request.ts (88%) rename apps/web/src/app/{auth => key-management}/key-rotation/user-key-rotation-api.service.ts (100%) rename apps/web/src/app/{auth => key-management}/key-rotation/user-key-rotation.module.ts (100%) rename apps/web/src/app/{auth => key-management}/key-rotation/user-key-rotation.service.spec.ts (100%) rename apps/web/src/app/{auth => key-management}/key-rotation/user-key-rotation.service.ts (98%) rename apps/web/src/app/{auth => key-management}/migrate-encryption/migrate-legacy-encryption.component.html (100%) rename apps/web/src/app/{auth => key-management}/migrate-encryption/migrate-legacy-encryption.component.ts (100%) rename libs/{auth/src/common => key-management/src}/abstractions/user-key-rotation-data-provider.abstraction.ts (100%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 103401d1c97..5ba5885d725 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -106,7 +106,7 @@ apps/desktop/desktop_native @bitwarden/team-platform-dev ## Key management team files ## apps/desktop/src/key-management @bitwarden/team-key-management-dev -apps/web/src/key-management @bitwarden/team-key-management-dev +apps/web/src/app/key-management @bitwarden/team-key-management-dev apps/browser/src/key-management @bitwarden/team-key-management-dev apps/cli/src/key-management @bitwarden/team-key-management-dev libs/key-management @bitwarden/team-key-management-dev diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts index abfae811d54..88cb75b087e 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts @@ -5,7 +5,6 @@ import { OrganizationUserResetPasswordRequest, OrganizationUserResetPasswordWithIdRequest, } from "@bitwarden/admin-console/common"; -import { UserKeyRotationDataProvider } from "@bitwarden/auth/common"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { @@ -21,7 +20,7 @@ import { EncryptedString, EncString } from "@bitwarden/common/platform/models/do import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; -import { KeyService } from "@bitwarden/key-management"; +import { UserKeyRotationDataProvider, KeyService } from "@bitwarden/key-management"; @Injectable({ providedIn: "root", diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts index 09ea17829ce..2edbd0ada1a 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts @@ -1,7 +1,7 @@ import { Injectable, Optional } from "@angular/core"; import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap, tap } from "rxjs"; -import { PrfKeySet, UserKeyRotationDataProvider } from "@bitwarden/auth/common"; +import { PrfKeySet } from "@bitwarden/auth/common"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction"; import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request"; @@ -11,6 +11,7 @@ import { Verification } from "@bitwarden/common/auth/types/verification"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; +import { UserKeyRotationDataProvider } from "@bitwarden/key-management"; import { CredentialCreateOptionsView } from "../../views/credential-create-options.view"; import { PendingWebauthnLoginCredentialView } from "../../views/pending-webauthn-login-credential.view"; diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts index a4fd9eeff02..39eb6570df9 100644 --- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts +++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts @@ -1,6 +1,5 @@ import { Injectable } from "@angular/core"; -import { UserKeyRotationDataProvider } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; @@ -23,7 +22,7 @@ import { UserKey } from "@bitwarden/common/types/key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { KeyService } from "@bitwarden/key-management"; +import { UserKeyRotationDataProvider, KeyService } from "@bitwarden/key-management"; import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type"; import { EmergencyAccessType } from "../enums/emergency-access-type"; diff --git a/apps/web/src/app/auth/settings/change-password.component.ts b/apps/web/src/app/auth/settings/change-password.component.ts index ec1416ba0ae..3406c2d5b61 100644 --- a/apps/web/src/app/auth/settings/change-password.component.ts +++ b/apps/web/src/app/auth/settings/change-password.component.ts @@ -25,7 +25,7 @@ import { DialogService, ToastService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { KeyService } from "@bitwarden/key-management"; -import { UserKeyRotationService } from "../key-rotation/user-key-rotation.service"; +import { UserKeyRotationService } from "../../key-management/key-rotation/user-key-rotation.service"; @Component({ selector: "app-change-password", diff --git a/apps/web/src/app/auth/settings/settings.module.ts b/apps/web/src/app/auth/settings/settings.module.ts index 2d1f64d1ebb..437711f4aa6 100644 --- a/apps/web/src/app/auth/settings/settings.module.ts +++ b/apps/web/src/app/auth/settings/settings.module.ts @@ -2,9 +2,9 @@ import { NgModule } from "@angular/core"; import { PasswordCalloutComponent } from "@bitwarden/auth/angular"; +import { UserKeyRotationModule } from "../../key-management/key-rotation/user-key-rotation.module"; import { SharedModule } from "../../shared"; import { EmergencyAccessModule } from "../emergency-access"; -import { UserKeyRotationModule } from "../key-rotation/user-key-rotation.module"; import { ChangePasswordComponent } from "./change-password.component"; import { WebauthnLoginSettingsModule } from "./webauthn-login-settings"; diff --git a/apps/web/src/app/auth/key-rotation/request/update-key.request.ts b/apps/web/src/app/key-management/key-rotation/request/update-key.request.ts similarity index 88% rename from apps/web/src/app/auth/key-rotation/request/update-key.request.ts rename to apps/web/src/app/key-management/key-rotation/request/update-key.request.ts index 0988ed54a99..81b7d361579 100644 --- a/apps/web/src/app/auth/key-rotation/request/update-key.request.ts +++ b/apps/web/src/app/key-management/key-rotation/request/update-key.request.ts @@ -4,7 +4,7 @@ import { SendWithIdRequest } from "@bitwarden/common/src/tools/send/models/reque import { CipherWithIdRequest } from "@bitwarden/common/src/vault/models/request/cipher-with-id.request"; import { FolderWithIdRequest } from "@bitwarden/common/src/vault/models/request/folder-with-id.request"; -import { EmergencyAccessWithIdRequest } from "../../emergency-access/request/emergency-access-update.request"; +import { EmergencyAccessWithIdRequest } from "../../../auth/emergency-access/request/emergency-access-update.request"; export class UpdateKeyRequest { masterPasswordHash: string; diff --git a/apps/web/src/app/auth/key-rotation/user-key-rotation-api.service.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation-api.service.ts similarity index 100% rename from apps/web/src/app/auth/key-rotation/user-key-rotation-api.service.ts rename to apps/web/src/app/key-management/key-rotation/user-key-rotation-api.service.ts diff --git a/apps/web/src/app/auth/key-rotation/user-key-rotation.module.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.module.ts similarity index 100% rename from apps/web/src/app/auth/key-rotation/user-key-rotation.module.ts rename to apps/web/src/app/key-management/key-rotation/user-key-rotation.module.ts diff --git a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts similarity index 100% rename from apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts rename to apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts diff --git a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts similarity index 98% rename from apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts rename to apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts index 8116bcd0470..e4e5ab2caaa 100644 --- a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts @@ -18,8 +18,8 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv import { KeyService } from "@bitwarden/key-management"; import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service"; -import { WebauthnLoginAdminService } from "../core"; -import { EmergencyAccessService } from "../emergency-access"; +import { WebauthnLoginAdminService } from "../../auth/core"; +import { EmergencyAccessService } from "../../auth/emergency-access"; import { UpdateKeyRequest } from "./request/update-key.request"; import { UserKeyRotationApiService } from "./user-key-rotation-api.service"; diff --git a/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.component.html b/apps/web/src/app/key-management/migrate-encryption/migrate-legacy-encryption.component.html similarity index 100% rename from apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.component.html rename to apps/web/src/app/key-management/migrate-encryption/migrate-legacy-encryption.component.html diff --git a/apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.component.ts b/apps/web/src/app/key-management/migrate-encryption/migrate-legacy-encryption.component.ts similarity index 100% rename from apps/web/src/app/auth/migrate-encryption/migrate-legacy-encryption.component.ts rename to apps/web/src/app/key-management/migrate-encryption/migrate-legacy-encryption.component.ts diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 9f36df175fe..b3a8db20028 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -177,7 +177,7 @@ const routes: Routes = [ { path: "migrate-legacy-encryption", loadComponent: () => - import("./auth/migrate-encryption/migrate-legacy-encryption.component").then( + import("./key-management/migrate-encryption/migrate-legacy-encryption.component").then( (mod) => mod.MigrateFromLegacyEncryptionComponent, ), }, diff --git a/libs/auth/src/common/abstractions/index.ts b/libs/auth/src/common/abstractions/index.ts index 6b618992e9d..e686de52013 100644 --- a/libs/auth/src/common/abstractions/index.ts +++ b/libs/auth/src/common/abstractions/index.ts @@ -3,4 +3,3 @@ export * from "./login-email.service"; export * from "./login-strategy.service"; export * from "./user-decryption-options.service.abstraction"; export * from "./auth-request.service.abstraction"; -export * from "./user-key-rotation-data-provider.abstraction"; diff --git a/libs/common/src/tools/send/services/send.service.abstraction.ts b/libs/common/src/tools/send/services/send.service.abstraction.ts index 4fa927942c1..866a661b4a4 100644 --- a/libs/common/src/tools/send/services/send.service.abstraction.ts +++ b/libs/common/src/tools/send/services/send.service.abstraction.ts @@ -1,6 +1,6 @@ import { Observable } from "rxjs"; -import { UserKeyRotationDataProvider } from "@bitwarden/auth/common"; +import { UserKeyRotationDataProvider } from "@bitwarden/key-management"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index f0e19a21342..444c922fe31 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -1,7 +1,7 @@ import { Observable } from "rxjs"; -import { UserKeyRotationDataProvider } from "@bitwarden/auth/common"; import { LocalData } from "@bitwarden/common/vault/models/data/local.data"; +import { UserKeyRotationDataProvider } from "@bitwarden/key-management"; import { UriMatchStrategySetting } from "../../models/domain/domain-service"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; diff --git a/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts b/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts index 857915ddb80..df21b136f41 100644 --- a/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts +++ b/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts @@ -1,6 +1,6 @@ import { Observable } from "rxjs"; -import { UserKeyRotationDataProvider } from "@bitwarden/auth/common"; +import { UserKeyRotationDataProvider } from "@bitwarden/key-management"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { UserId } from "../../../types/guid"; diff --git a/libs/auth/src/common/abstractions/user-key-rotation-data-provider.abstraction.ts b/libs/key-management/src/abstractions/user-key-rotation-data-provider.abstraction.ts similarity index 100% rename from libs/auth/src/common/abstractions/user-key-rotation-data-provider.abstraction.ts rename to libs/key-management/src/abstractions/user-key-rotation-data-provider.abstraction.ts diff --git a/libs/key-management/src/index.ts b/libs/key-management/src/index.ts index f2bb5e30166..5ad96ddeba7 100644 --- a/libs/key-management/src/index.ts +++ b/libs/key-management/src/index.ts @@ -7,3 +7,4 @@ export * from "./biometrics/biometric.state"; export { KeyService } from "./abstractions/key.service"; export { DefaultKeyService } from "./key.service"; +export { UserKeyRotationDataProvider } from "./abstractions/user-key-rotation-data-provider.abstraction"; From d0ed9aaa5d3afbc2af5fc0872b6b1471f7c32f3b Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Mon, 28 Oct 2024 10:13:32 +1000 Subject: [PATCH 11/17] [PM-13779] Add vNext CollectionService without ActiveUserState (#11705) - add tests - install jest-extended for additional matchers - allow for generation of different crypto keys in tests --- .../abstractions/vnext-collection.service.ts | 41 +++ .../default-vnext-collection.service.spec.ts | 325 ++++++++++++++++++ .../default-vnext-collection.service.ts | 196 +++++++++++ .../services/vnext-collection.state.ts | 37 ++ libs/common/spec/matchers/index.ts | 5 + libs/common/spec/utils.ts | 11 +- package-lock.json | 22 ++ package.json | 1 + 8 files changed, 636 insertions(+), 2 deletions(-) create mode 100644 libs/admin-console/src/common/collections/abstractions/vnext-collection.service.ts create mode 100644 libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts create mode 100644 libs/admin-console/src/common/collections/services/default-vnext-collection.service.ts create mode 100644 libs/admin-console/src/common/collections/services/vnext-collection.state.ts diff --git a/libs/admin-console/src/common/collections/abstractions/vnext-collection.service.ts b/libs/admin-console/src/common/collections/abstractions/vnext-collection.service.ts new file mode 100644 index 00000000000..4b5828ccf3b --- /dev/null +++ b/libs/admin-console/src/common/collections/abstractions/vnext-collection.service.ts @@ -0,0 +1,41 @@ +import { Observable } from "rxjs"; + +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; + +import { CollectionData, Collection, CollectionView } from "../models"; + +export abstract class vNextCollectionService { + encryptedCollections$: (userId$: Observable) => Observable; + decryptedCollections$: (userId$: Observable) => Observable; + upsert: (collection: CollectionData | CollectionData[], userId: UserId) => Promise; + replace: (collections: { [id: string]: CollectionData }, userId: UserId) => Promise; + /** + * Clear decrypted state without affecting encrypted state. + * Used for locking the vault. + */ + clearDecryptedState: (userId: UserId) => Promise; + /** + * Clear decrypted and encrypted state. + * Used for logging out. + */ + clear: (userId: string) => Promise; + delete: (id: string | string[], userId: UserId) => Promise; + encrypt: (model: CollectionView) => Promise; + /** + * @deprecated This method will soon be made private, use `decryptedCollections$` instead. + */ + decryptMany: ( + collections: Collection[], + orgKeys?: Record, + ) => Promise; + /** + * Transforms the input CollectionViews into TreeNodes + */ + getAllNested: (collections: CollectionView[]) => TreeNode[]; + /** + * Transforms the input CollectionViews into TreeNodes and then returns the Treenode with the specified id + */ + getNested: (collections: CollectionView[], id: string) => TreeNode; +} diff --git a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts new file mode 100644 index 00000000000..4ca60cba77a --- /dev/null +++ b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts @@ -0,0 +1,325 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, of, ReplaySubject } from "rxjs"; + +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { ContainerService } from "@bitwarden/common/platform/services/container.service"; +import { + FakeStateProvider, + makeEncString, + makeSymmetricCryptoKey, + mockAccountServiceWith, +} from "@bitwarden/common/spec"; +import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { KeyService } from "@bitwarden/key-management"; + +import { CollectionData } from "../models"; + +import { DefaultvNextCollectionService } from "./default-vnext-collection.service"; +import { ENCRYPTED_COLLECTION_DATA_KEY } from "./vnext-collection.state"; + +describe("DefaultvNextCollectionService", () => { + let keyService: MockProxy; + let encryptService: MockProxy; + let i18nService: MockProxy; + let stateProvider: FakeStateProvider; + + let userId: UserId; + + let cryptoKeys: ReplaySubject | null>; + + let collectionService: DefaultvNextCollectionService; + + beforeEach(() => { + userId = Utils.newGuid() as UserId; + + keyService = mock(); + encryptService = mock(); + i18nService = mock(); + stateProvider = new FakeStateProvider(mockAccountServiceWith(userId)); + + cryptoKeys = new ReplaySubject(1); + keyService.orgKeys$.mockReturnValue(cryptoKeys); + + // Set up mock decryption + encryptService.decryptToUtf8 + .calledWith(expect.any(EncString), expect.any(SymmetricCryptoKey)) + .mockImplementation((encString, key) => + Promise.resolve(encString.data.replace("ENC_", "DEC_")), + ); + + (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); + + // Arrange i18nService so that sorting algorithm doesn't throw + i18nService.collator = null; + + collectionService = new DefaultvNextCollectionService( + keyService, + encryptService, + i18nService, + stateProvider, + ); + }); + + afterEach(() => { + delete (window as any).bitwardenContainerService; + }); + + describe("decryptedCollections$", () => { + it("emits decrypted collections from state", async () => { + // Arrange test data + const org1 = Utils.newGuid() as OrganizationId; + const orgKey1 = makeSymmetricCryptoKey(64, 1); + const collection1 = collectionDataFactory(org1); + + const org2 = Utils.newGuid() as OrganizationId; + const orgKey2 = makeSymmetricCryptoKey(64, 2); + const collection2 = collectionDataFactory(org2); + + // Arrange dependencies + await setEncryptedState([collection1, collection2]); + cryptoKeys.next({ + [org1]: orgKey1, + [org2]: orgKey2, + }); + + const result = await firstValueFrom(collectionService.decryptedCollections$(of(userId))); + + // Assert emitted values + expect(result.length).toBe(2); + expect(result).toIncludeAllPartialMembers([ + { + id: collection1.id, + name: "DEC_NAME_" + collection1.id, + }, + { + id: collection2.id, + name: "DEC_NAME_" + collection2.id, + }, + ]); + + // Assert that the correct org keys were used for each encrypted string + expect(encryptService.decryptToUtf8).toHaveBeenCalledWith( + expect.objectContaining(new EncString(collection1.name)), + orgKey1, + ); + expect(encryptService.decryptToUtf8).toHaveBeenCalledWith( + expect.objectContaining(new EncString(collection2.name)), + orgKey2, + ); + }); + + it("handles null collection state", async () => { + // Arrange dependencies + await setEncryptedState(null); + cryptoKeys.next({}); + + const encryptedCollections = await firstValueFrom( + collectionService.encryptedCollections$(of(userId)), + ); + + expect(encryptedCollections.length).toBe(0); + }); + }); + + describe("encryptedCollections$", () => { + it("emits encrypted collections from state", async () => { + // Arrange test data + const collection1 = collectionDataFactory(); + const collection2 = collectionDataFactory(); + + // Arrange dependencies + await setEncryptedState([collection1, collection2]); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + + expect(result.length).toBe(2); + expect(result).toIncludeAllPartialMembers([ + { + id: collection1.id, + name: makeEncString("ENC_NAME_" + collection1.id), + }, + { + id: collection2.id, + name: makeEncString("ENC_NAME_" + collection2.id), + }, + ]); + }); + + it("handles null collection state", async () => { + await setEncryptedState(null); + + const decryptedCollections = await firstValueFrom( + collectionService.encryptedCollections$(of(userId)), + ); + expect(decryptedCollections.length).toBe(0); + }); + }); + + describe("upsert", () => { + it("upserts to existing collections", async () => { + const collection1 = collectionDataFactory(); + const collection2 = collectionDataFactory(); + + await setEncryptedState([collection1, collection2]); + + const updatedCollection1 = Object.assign(new CollectionData({} as any), collection1, { + name: makeEncString("UPDATED_ENC_NAME_" + collection1.id).encryptedString, + }); + const newCollection3 = collectionDataFactory(); + + await collectionService.upsert([updatedCollection1, newCollection3], userId); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + expect(result.length).toBe(3); + expect(result).toIncludeAllPartialMembers([ + { + id: collection1.id, + name: makeEncString("UPDATED_ENC_NAME_" + collection1.id), + }, + { + id: collection2.id, + name: makeEncString("ENC_NAME_" + collection2.id), + }, + { + id: newCollection3.id, + name: makeEncString("ENC_NAME_" + newCollection3.id), + }, + ]); + }); + + it("upserts to a null state", async () => { + const collection1 = collectionDataFactory(); + + await setEncryptedState(null); + + await collectionService.upsert(collection1, userId); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + expect(result.length).toBe(1); + expect(result).toIncludeAllPartialMembers([ + { + id: collection1.id, + name: makeEncString("ENC_NAME_" + collection1.id), + }, + ]); + }); + }); + + describe("replace", () => { + it("replaces all collections", async () => { + await setEncryptedState([collectionDataFactory(), collectionDataFactory()]); + + const newCollection3 = collectionDataFactory(); + await collectionService.replace( + { + [newCollection3.id]: newCollection3, + }, + userId, + ); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + expect(result.length).toBe(1); + expect(result).toIncludeAllPartialMembers([ + { + id: newCollection3.id, + name: makeEncString("ENC_NAME_" + newCollection3.id), + }, + ]); + }); + }); + + it("clearDecryptedState", async () => { + await setEncryptedState([collectionDataFactory(), collectionDataFactory()]); + + await collectionService.clearDecryptedState(userId); + + // Encrypted state remains + const encryptedState = await firstValueFrom( + collectionService.encryptedCollections$(of(userId)), + ); + expect(encryptedState.length).toEqual(2); + + // Decrypted state is cleared + const decryptedState = await firstValueFrom( + collectionService.decryptedCollections$(of(userId)), + ); + expect(decryptedState.length).toEqual(0); + }); + + it("clear", async () => { + await setEncryptedState([collectionDataFactory(), collectionDataFactory()]); + cryptoKeys.next({}); + + await collectionService.clear(userId); + + // Encrypted state is cleared + const encryptedState = await firstValueFrom( + collectionService.encryptedCollections$(of(userId)), + ); + expect(encryptedState.length).toEqual(0); + + // Decrypted state is cleared + const decryptedState = await firstValueFrom( + collectionService.decryptedCollections$(of(userId)), + ); + expect(decryptedState.length).toEqual(0); + }); + + describe("delete", () => { + it("deletes a collection", async () => { + const collection1 = collectionDataFactory(); + const collection2 = collectionDataFactory(); + await setEncryptedState([collection1, collection2]); + + await collectionService.delete(collection1.id, userId); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + expect(result.length).toEqual(1); + expect(result[0]).toMatchObject({ id: collection2.id }); + }); + + it("deletes several collections", async () => { + const collection1 = collectionDataFactory(); + const collection2 = collectionDataFactory(); + const collection3 = collectionDataFactory(); + await setEncryptedState([collection1, collection2, collection3]); + + await collectionService.delete([collection1.id, collection3.id], userId); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + expect(result.length).toEqual(1); + expect(result[0]).toMatchObject({ id: collection2.id }); + }); + + it("handles null collections", async () => { + const collection1 = collectionDataFactory(); + await setEncryptedState(null); + + await collectionService.delete(collection1.id, userId); + + const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + expect(result.length).toEqual(0); + }); + }); + + const setEncryptedState = (collectionData: CollectionData[] | null) => + stateProvider.setUserState( + ENCRYPTED_COLLECTION_DATA_KEY, + collectionData == null ? null : Object.fromEntries(collectionData.map((c) => [c.id, c])), + userId, + ); +}); + +const collectionDataFactory = (orgId?: OrganizationId) => { + const collection = new CollectionData({} as any); + collection.id = Utils.newGuid() as CollectionId; + collection.organizationId = orgId ?? (Utils.newGuid() as OrganizationId); + collection.name = makeEncString("ENC_NAME_" + collection.id).encryptedString; + + return collection; +}; diff --git a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.ts b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.ts new file mode 100644 index 00000000000..8ca1ab7fcf0 --- /dev/null +++ b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.ts @@ -0,0 +1,196 @@ +import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs"; + +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { StateProvider, DerivedState } from "@bitwarden/common/platform/state"; +import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; +import { KeyService } from "@bitwarden/key-management"; + +import { vNextCollectionService } from "../abstractions/vnext-collection.service"; +import { Collection, CollectionData, CollectionView } from "../models"; + +import { + DECRYPTED_COLLECTION_DATA_KEY, + ENCRYPTED_COLLECTION_DATA_KEY, +} from "./vnext-collection.state"; + +const NestingDelimiter = "/"; + +export class DefaultvNextCollectionService implements vNextCollectionService { + constructor( + private keyService: KeyService, + private encryptService: EncryptService, + private i18nService: I18nService, + protected stateProvider: StateProvider, + ) {} + + encryptedCollections$(userId$: Observable) { + return userId$.pipe( + switchMap((userId) => this.encryptedState(userId).state$), + map((collections) => { + if (collections == null) { + return []; + } + + return Object.values(collections).map((c) => new Collection(c)); + }), + ); + } + + decryptedCollections$(userId$: Observable) { + return userId$.pipe( + switchMap((userId) => this.decryptedState(userId).state$), + map((collections) => collections ?? []), + ); + } + + async upsert(toUpdate: CollectionData | CollectionData[], userId: UserId): Promise { + if (toUpdate == null) { + return; + } + await this.encryptedState(userId).update((collections) => { + if (collections == null) { + collections = {}; + } + if (Array.isArray(toUpdate)) { + toUpdate.forEach((c) => { + collections[c.id] = c; + }); + } else { + collections[toUpdate.id] = toUpdate; + } + return collections; + }); + } + + async replace(collections: Record, userId: UserId): Promise { + await this.encryptedState(userId).update(() => collections); + } + + async clearDecryptedState(userId: UserId): Promise { + if (userId == null) { + throw new Error("User ID is required."); + } + + await this.decryptedState(userId).forceValue(null); + } + + async clear(userId: UserId): Promise { + await this.encryptedState(userId).update(() => null); + // This will propagate from the encrypted state update, but by doing it explicitly + // the promise doesn't resolve until the update is complete. + await this.decryptedState(userId).forceValue(null); + } + + async delete(id: CollectionId | CollectionId[], userId: UserId): Promise { + await this.encryptedState(userId).update((collections) => { + if (collections == null) { + collections = {}; + } + if (typeof id === "string") { + delete collections[id]; + } else { + (id as CollectionId[]).forEach((i) => { + delete collections[i]; + }); + } + return collections; + }); + } + + async encrypt(model: CollectionView): Promise { + if (model.organizationId == null) { + throw new Error("Collection has no organization id."); + } + const key = await this.keyService.getOrgKey(model.organizationId); + if (key == null) { + throw new Error("No key for this collection's organization."); + } + const collection = new Collection(); + collection.id = model.id; + collection.organizationId = model.organizationId; + collection.readOnly = model.readOnly; + collection.externalId = model.externalId; + collection.name = await this.encryptService.encrypt(model.name, key); + return collection; + } + + // TODO: this should be private and orgKeys should be required. + // See https://bitwarden.atlassian.net/browse/PM-12375 + async decryptMany( + collections: Collection[], + orgKeys?: Record, + ): Promise { + if (collections == null || collections.length === 0) { + return []; + } + const decCollections: CollectionView[] = []; + + orgKeys ??= await firstValueFrom(this.keyService.activeUserOrgKeys$); + + const promises: Promise[] = []; + collections.forEach((collection) => { + promises.push( + collection + .decrypt(orgKeys[collection.organizationId as OrganizationId]) + .then((c) => decCollections.push(c)), + ); + }); + await Promise.all(promises); + return decCollections.sort(Utils.getSortFunction(this.i18nService, "name")); + } + + getAllNested(collections: CollectionView[]): TreeNode[] { + const nodes: TreeNode[] = []; + collections.forEach((c) => { + const collectionCopy = new CollectionView(); + collectionCopy.id = c.id; + collectionCopy.organizationId = c.organizationId; + const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; + ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter); + }); + return nodes; + } + + /** + * @deprecated August 30 2022: Moved to new Vault Filter Service + * Remove when Desktop and Browser are updated + */ + getNested(collections: CollectionView[], id: string): TreeNode { + const nestedCollections = this.getAllNested(collections); + return ServiceUtils.getTreeNodeObjectFromList( + nestedCollections, + id, + ) as TreeNode; + } + + /** + * @returns a SingleUserState for encrypted collection data. + */ + private encryptedState(userId: UserId) { + return this.stateProvider.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY); + } + + /** + * @returns a SingleUserState for decrypted collection data. + */ + private decryptedState(userId: UserId): DerivedState { + const encryptedCollectionsWithKeys = this.encryptedState(userId).combinedState$.pipe( + switchMap(([userId, collectionData]) => + combineLatest([of(collectionData), this.keyService.orgKeys$(userId)]), + ), + ); + + return this.stateProvider.getDerived( + encryptedCollectionsWithKeys, + DECRYPTED_COLLECTION_DATA_KEY, + { + collectionService: this, + }, + ); + } +} diff --git a/libs/admin-console/src/common/collections/services/vnext-collection.state.ts b/libs/admin-console/src/common/collections/services/vnext-collection.state.ts new file mode 100644 index 00000000000..533308f3cc7 --- /dev/null +++ b/libs/admin-console/src/common/collections/services/vnext-collection.state.ts @@ -0,0 +1,37 @@ +import { Jsonify } from "type-fest"; + +import { + COLLECTION_DATA, + DeriveDefinition, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; +import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; + +import { vNextCollectionService } from "../abstractions/vnext-collection.service"; +import { Collection, CollectionData, CollectionView } from "../models"; + +export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record( + COLLECTION_DATA, + "collections", + { + deserializer: (jsonData: Jsonify) => CollectionData.fromJSON(jsonData), + clearOn: ["logout"], + }, +); + +export const DECRYPTED_COLLECTION_DATA_KEY = new DeriveDefinition< + [Record, Record], + CollectionView[], + { collectionService: vNextCollectionService } +>(COLLECTION_DATA, "decryptedCollections", { + deserializer: (obj) => obj.map((collection) => CollectionView.fromJSON(collection)), + derive: async ([collections, orgKeys], { collectionService }) => { + if (collections == null) { + return []; + } + + const data = Object.values(collections).map((c) => new Collection(c)); + return await collectionService.decryptMany(data, orgKeys); + }, +}); diff --git a/libs/common/spec/matchers/index.ts b/libs/common/spec/matchers/index.ts index 235f54d7754..44440be5b54 100644 --- a/libs/common/spec/matchers/index.ts +++ b/libs/common/spec/matchers/index.ts @@ -1,3 +1,5 @@ +import * as matchers from "jest-extended"; + import { toBeFulfilled, toBeResolved, toBeRejected } from "./promise-fulfilled"; import { toAlmostEqual } from "./to-almost-equal"; import { toEqualBuffer } from "./to-equal-buffer"; @@ -6,6 +8,9 @@ export * from "./to-equal-buffer"; export * from "./to-almost-equal"; export * from "./promise-fulfilled"; +// add all jest-extended matchers +expect.extend(matchers); + export function addCustomMatchers() { expect.extend({ toEqualBuffer: toEqualBuffer, diff --git a/libs/common/spec/utils.ts b/libs/common/spec/utils.ts index d3722329370..1cead2aa624 100644 --- a/libs/common/spec/utils.ts +++ b/libs/common/spec/utils.ts @@ -46,8 +46,15 @@ export function makeStaticByteArray(length: number, start = 0) { return arr; } -export function makeSymmetricCryptoKey(length: 32 | 64 = 64) { - return new SymmetricCryptoKey(makeStaticByteArray(length)) as T; +/** + * Creates a symmetric crypto key for use in tests. This is deterministic, i.e. it will produce identical keys + * for identical argument values. Provide a unique value to the `seed` parameter to create different keys. + */ +export function makeSymmetricCryptoKey( + length: 32 | 64 = 64, + seed = 0, +) { + return new SymmetricCryptoKey(makeStaticByteArray(length, seed)) as T; } /** diff --git a/package-lock.json b/package-lock.json index 344cf7835e1..f60ec732132 100644 --- a/package-lock.json +++ b/package-lock.json @@ -154,6 +154,7 @@ "html-webpack-injector": "1.1.4", "html-webpack-plugin": "5.6.0", "husky": "9.1.4", + "jest-extended": "^4.0.2", "jest-junit": "16.0.0", "jest-mock-extended": "3.0.7", "jest-preset-angular": "14.1.1", @@ -23930,6 +23931,27 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-extended": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-4.0.2.tgz", + "integrity": "sha512-FH7aaPgtGYHc9mRjriS0ZEHYM5/W69tLrFTIdzm+yJgeoCmmrSB/luSfMSqWP9O29QWHPEmJ4qmU6EwsZideog==", + "dev": true, + "dependencies": { + "jest-diff": "^29.0.0", + "jest-get-type": "^29.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "jest": ">=27.2.5" + }, + "peerDependenciesMeta": { + "jest": { + "optional": true + } + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", diff --git a/package.json b/package.json index 75e91f64936..bf9c8b76735 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "html-webpack-injector": "1.1.4", "html-webpack-plugin": "5.6.0", "husky": "9.1.4", + "jest-extended": "^4.0.2", "jest-junit": "16.0.0", "jest-mock-extended": "3.0.7", "jest-preset-angular": "14.1.1", From 903c215867d37813fc3dbf70e5bdb04eef995dd1 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Mon, 28 Oct 2024 13:02:57 +1000 Subject: [PATCH 12/17] Fix vNextCollectiuonService tests (#11739) --- .../services/default-vnext-collection.service.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts index 4ca60cba77a..54c4470d414 100644 --- a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts +++ b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts @@ -47,7 +47,7 @@ describe("DefaultvNextCollectionService", () => { // Set up mock decryption encryptService.decryptToUtf8 - .calledWith(expect.any(EncString), expect.any(SymmetricCryptoKey)) + .calledWith(expect.any(EncString), expect.any(SymmetricCryptoKey), expect.any(String)) .mockImplementation((encString, key) => Promise.resolve(encString.data.replace("ENC_", "DEC_")), ); @@ -106,10 +106,12 @@ describe("DefaultvNextCollectionService", () => { expect(encryptService.decryptToUtf8).toHaveBeenCalledWith( expect.objectContaining(new EncString(collection1.name)), orgKey1, + expect.any(String), ); expect(encryptService.decryptToUtf8).toHaveBeenCalledWith( expect.objectContaining(new EncString(collection2.name)), orgKey2, + expect.any(String), ); }); From e3f903556457bb491309337792fb142fc42b9d3d Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 28 Oct 2024 02:17:15 -0700 Subject: [PATCH 13/17] [PM-14022] - change send link to disabled (#11721) * change send link to disabled * change send link to disabled --- .../components/send-details/send-details.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html index 93db4df3187..06b0f1a55df 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html @@ -23,7 +23,7 @@ {{ "sendLink" | i18n }} - + - - - + + +