From 3b535802dbb99c179ca8a19666d32ff290f7fd8e Mon Sep 17 00:00:00 2001 From: Jared Date: Tue, 10 Feb 2026 15:45:45 -0500 Subject: [PATCH 01/42] =?UTF-8?q?[PM-26020]=20Implement=20dynamic=20cipher?= =?UTF-8?q?=20creation=20permissions=20in=20vault=20header=20and=20new?= =?UTF-8?q?=E2=80=A6=20(#18579)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement dynamic cipher creation permissions in vault header and new cipher menu components * Enhance new cipher menu button behavior and accessibility. Implement dynamic button label based on creation permissions, allowing direct collection creation when applicable. Update button trigger logic to improve user experience. * Update apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts Co-authored-by: SmithThe4th * Add canCreateCipher getter for improved readability --------- Co-authored-by: SmithThe4th --- .../vault-header/vault-header.component.html | 2 +- .../vault-header/vault-header.component.ts | 4 ++ .../new-cipher-menu.component.html | 7 +-- .../new-cipher-menu.component.ts | 53 +++++++++++++++++-- 4 files changed, 58 insertions(+), 8 deletions(-) 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 af4eb182eec..d2f5cc38013 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 @@ -76,7 +76,7 @@
- {{ "new" | i18n }} + {{ getButtonLabel() | i18n }} @for (item of cipherMenuItems$ | async; track item.type) { diff --git a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts index 0a755a9cdb4..1a592809691 100644 --- a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts +++ b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.ts @@ -1,6 +1,7 @@ import { CommonModule } from "@angular/common"; import { Component, input, output } from "@angular/core"; -import { map, shareReplay } from "rxjs"; +import { toObservable } from "@angular/core/rxjs-interop"; +import { combineLatest, map, shareReplay } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -38,10 +39,18 @@ export class NewCipherMenuComponent { /** * Returns an observable that emits the cipher menu items, filtered by the restricted types. */ - cipherMenuItems$ = this.restrictedItemTypesService.restricted$.pipe( - map((restrictedTypes) => { + cipherMenuItems$ = combineLatest([ + this.restrictedItemTypesService.restricted$, + toObservable(this.canCreateCipher), + toObservable(this.canCreateSshKey), + ]).pipe( + map(([restrictedTypes, canCreateCipher, canCreateSshKey]) => { + // If user cannot create ciphers at all, return empty array + if (!canCreateCipher) { + return []; + } return CIPHER_MENU_ITEMS.filter((item) => { - if (!this.canCreateSshKey() && item.type === CipherType.SshKey) { + if (!canCreateSshKey && item.type === CipherType.SshKey) { return false; } return !restrictedTypes.some((restrictedType) => restrictedType.cipherType === item.type); @@ -49,4 +58,40 @@ export class NewCipherMenuComponent { }), shareReplay({ bufferSize: 1, refCount: true }), ); + + /** + * Returns the appropriate button label based on what can be created. + * If only collections can be created (no ciphers or folders), show "New Collection". + * Otherwise, show "New". + */ + protected getButtonLabel(): string { + const canCreateCipher = this.canCreateCipher(); + const canCreateFolder = this.canCreateFolder(); + const canCreateCollection = this.canCreateCollection(); + + // If only collections can be created, be specific + if (!canCreateCipher && !canCreateFolder && canCreateCollection) { + return "newCollection"; + } + + return "new"; + } + + /** + * Returns true if only collections can be created (no other options). + * When this is true, the button should directly create a collection instead of showing a dropdown. + */ + protected isOnlyCollectionCreation(): boolean { + return !this.canCreateCipher() && !this.canCreateFolder() && this.canCreateCollection(); + } + + /** + * Handles the button click. If only collections can be created, directly emit the collection event. + * Otherwise, the menu trigger will handle opening the dropdown. + */ + protected handleButtonClick(): void { + if (this.isOnlyCollectionCreation()) { + this.collectionAdded.emit(); + } + } } From 1aef83b6e3b5665d10b752446484beefe9642163 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Tue, 10 Feb 2026 16:20:39 -0500 Subject: [PATCH 02/42] [PM-28262] Bulk re-invite UI improvements (#18754) * implement dilogs and logic * clean up, add tests * add feature flag key * product requested changes * more product changes * edit error message --- .../bulk/bulk-progress-dialog.component.html | 22 ++ .../bulk/bulk-progress-dialog.component.ts | 46 +++++ ...ulk-reinvite-failure-dialog.component.html | 70 +++++++ .../bulk-reinvite-failure-dialog.component.ts | 62 ++++++ .../members/deprecated_members.component.ts | 10 +- .../members/members.component.html | 37 ++-- .../members/members.component.spec.ts | 2 +- .../members/members.component.ts | 24 ++- .../organizations/members/members.module.ts | 7 +- .../member-actions.service.spec.ts | 195 ++++++++++++++++-- .../member-actions/member-actions.service.ts | 96 ++++++--- .../member-dialog-manager.service.ts | 36 +++- apps/web/src/locales/en/messages.json | 56 +++++ .../manage/deprecated_members.component.ts | 5 +- .../providers/manage/members.component.ts | 5 +- libs/common/src/enums/feature-flag.enum.ts | 2 + 16 files changed, 596 insertions(+), 79 deletions(-) create mode 100644 apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.html create mode 100644 apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.ts create mode 100644 apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.html create mode 100644 apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.ts diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.html b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.html new file mode 100644 index 00000000000..2fbcc8afd86 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.html @@ -0,0 +1,22 @@ + +
+
+
+ +
+
+
+

+ {{ "bulkReinviteProgressTitle" | i18n: progressCount() : allCount }} +

+ + {{ "bulkReinviteProgressSubtitle" | i18n }} + +
+
+
diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.ts new file mode 100644 index 00000000000..66582fb4434 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-progress-dialog.component.ts @@ -0,0 +1,46 @@ +import { DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + Inject, + Signal, +} from "@angular/core"; + +import { DIALOG_DATA, DialogService } from "@bitwarden/components"; + +export interface BulkProgressDialogParams { + progress: Signal; + allCount: number; +} + +@Component({ + templateUrl: "bulk-progress-dialog.component.html", + selector: "member-bulk-progress-dialog", + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, +}) +export class BulkProgressDialogComponent { + protected allCount: string; + protected readonly progressCount: Signal; + protected readonly progressPercentage: Signal; + private readonly progressEffect = effect(() => { + if (this.progressPercentage() >= 100) { + this.dialogRef.close(); + } + }); + + constructor( + public dialogRef: DialogRef, + @Inject(DIALOG_DATA) data: BulkProgressDialogParams, + ) { + this.progressCount = computed(() => data.progress().toLocaleString()); + this.allCount = data.allCount.toLocaleString(); + this.progressPercentage = computed(() => (data.progress() / data.allCount) * 100); + } + + static open(dialogService: DialogService, config: DialogConfig) { + return dialogService.open(BulkProgressDialogComponent, config); + } +} diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.html b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.html new file mode 100644 index 00000000000..0f216be6e5f --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.html @@ -0,0 +1,70 @@ + + @let failCount = dataSource().data.length; +
+ @if (failCount > 1) { + {{ "bulkReinviteFailuresTitle" | i18n: failCount }} + } @else { + {{ "bulkReinviteFailureTitle" | i18n }} + } +
+ +
+ {{ "bulkReinviteFailureDescription" | i18n: failCount : totalCount }} + + + {{ "contactSupportShort" | i18n | lowercase }} + + + +
+ + + + {{ "name" | i18n }} + + + + + @let rows = $any(rows$ | async); + @for (u of rows; track u.id) { + + +
+ +
+
+ +
+ @if (u.name) { +
+ {{ u.email }} +
+ } +
+
+ + + } +
+
+
+
+ + + + + + +
diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.ts new file mode 100644 index 00000000000..5cb11708fd0 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-reinvite-failure-dialog.component.ts @@ -0,0 +1,62 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { ChangeDetectionStrategy, Component, Inject, signal, WritableSignal } from "@angular/core"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { DialogService } from "@bitwarden/components"; +import { MembersTableDataSource } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source"; + +import { OrganizationUserView } from "../../../core"; +import { + BulkActionResult, + MemberActionsService, +} from "../../services/member-actions/member-actions.service"; + +export interface BulkReinviteFailureDialogParams { + result: BulkActionResult; + users: OrganizationUserView[]; + organization: Organization; +} + +@Component({ + templateUrl: "bulk-reinvite-failure-dialog.component.html", + selector: "member-bulk-reinvite-failure-dialog", + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, +}) +export class BulkReinviteFailureDialogComponent { + private organization: Organization; + protected totalCount: string; + protected readonly dataSource: WritableSignal; + + constructor( + public dialogRef: DialogRef, + private memberActionsService: MemberActionsService, + @Inject(DIALOG_DATA) data: BulkReinviteFailureDialogParams, + environmentService: EnvironmentService, + ) { + this.organization = data.organization; + this.totalCount = (data.users.length ?? 0).toLocaleString(); + this.dataSource = signal(new MembersTableDataSource(environmentService)); + this.dataSource().data = data.result.failed.map((failedUser) => { + const user = data.users.find((u) => u.id === failedUser.id); + if (user == null) { + throw new Error("Member not found"); + } + return user; + }); + } + + async resendInvitations() { + await this.memberActionsService.bulkReinvite(this.organization, this.dataSource().data); + this.dialogRef.close(); + } + + async cancel() { + this.dialogRef.close(); + } + + static open(dialogService: DialogService, config: DialogConfig) { + return dialogService.open(BulkReinviteFailureDialogComponent, config); + } +} diff --git a/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts index 197c5d3efb5..dae9bafbcfe 100644 --- a/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts @@ -444,10 +444,7 @@ export class MembersComponent extends BaseMembersComponent } try { - const result = await this.memberActionsService.bulkReinvite( - organization, - filteredUsers.map((user) => user.id as UserId), - ); + const result = await this.memberActionsService.bulkReinvite(organization, filteredUsers); if (!result.successful) { throw new Error(); @@ -472,7 +469,10 @@ export class MembersComponent extends BaseMembersComponent } else { this.toastService.showToast({ variant: "success", - message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()), + message: + invitedCount === 1 + ? this.i18nService.t("reinviteSuccessToast") + : this.i18nService.t("bulkReinviteSentToast", invitedCount.toString()), }); } } else { diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.html b/apps/web/src/app/admin-console/organizations/members/members.component.html index 0f074d4481d..75ef503366b 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.html +++ b/apps/web/src/app/admin-console/organizations/members/members.component.html @@ -113,25 +113,24 @@ {{ "policies" | i18n }} @if (showUserManagementControls()) { - -
- - -
- +
+ + +
} diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts b/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts index 246c3d8a1c0..1cd90989b12 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts @@ -519,7 +519,7 @@ describe("vNextMembersComponent", () => { await component.bulkReinvite(mockOrg); - expect(mockMemberActionsService.bulkReinvite).toHaveBeenCalledWith(mockOrg, [invitedUser.id]); + expect(mockMemberActionsService.bulkReinvite).toHaveBeenCalledWith(mockOrg, [invitedUser]); expect(mockMemberDialogManager.openBulkStatusDialog).toHaveBeenCalled(); }); diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index 36c207219a0..6139c5f07a5 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -33,6 +33,8 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -101,6 +103,7 @@ export class vNextMembersComponent { private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction); private environmentService = inject(EnvironmentService); private memberExportService = inject(MemberExportService); + private configService = inject(ConfigService); private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); @@ -145,6 +148,10 @@ export class vNextMembersComponent { () => this.organization()?.canManageUsers ?? false, ); + protected readonly bulkReinviteUIEnabled = toSignal( + this.configService.getFeatureFlag$(FeatureFlag.BulkReinviteUI), + ); + protected billingMetadata$: Observable; protected resetPasswordPolicyEnabled$: Observable; @@ -399,7 +406,7 @@ export class vNextMembersComponent { // In cloud environments, limit invited users and uncheck the excess let filteredUsers: OrganizationUserView[]; - if (this.dataSource().isIncreasedBulkLimitEnabled()) { + if (this.dataSource().isIncreasedBulkLimitEnabled() && !this.bulkReinviteUIEnabled()) { filteredUsers = this.dataSource().limitAndUncheckExcess( allInvitedUsers, CloudBulkReinviteLimit, @@ -417,10 +424,7 @@ export class vNextMembersComponent { return; } - const result = await this.memberActionsService.bulkReinvite( - organization, - filteredUsers.map((user) => user.id as UserId), - ); + const result = await this.memberActionsService.bulkReinvite(organization, filteredUsers); if (!result.successful) { this.validationService.showError(result.failed); @@ -431,7 +435,8 @@ export class vNextMembersComponent { const selectedCount = originalInvitedCount; const invitedCount = filteredUsers.length; - if (selectedCount > CloudBulkReinviteLimit) { + // Only show limited toast if feature flag is disabled and limit was applied + if (!this.bulkReinviteUIEnabled() && selectedCount > CloudBulkReinviteLimit) { const excludedCount = selectedCount - CloudBulkReinviteLimit; this.toastService.showToast({ variant: "success", @@ -445,7 +450,10 @@ export class vNextMembersComponent { } else { this.toastService.showToast({ variant: "success", - message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()), + message: + invitedCount === 1 + ? this.i18nService.t("reinviteSuccessToast") + : this.i18nService.t("bulkReinviteSentToast", invitedCount.toString()), }); } } else { @@ -457,6 +465,8 @@ export class vNextMembersComponent { this.i18nService.t("bulkReinviteMessage"), ); } + + this.dataSource().uncheckAllUsers(); } async bulkConfirm(organization: Organization) { diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts index 9fd477b1e29..54e2d1b6373 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts @@ -3,7 +3,7 @@ import { NgModule } from "@angular/core"; import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component"; import { PasswordCalloutComponent } from "@bitwarden/auth/angular"; -import { ScrollLayoutDirective } from "@bitwarden/components"; +import { IconModule, ScrollLayoutDirective } from "@bitwarden/components"; import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service"; import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; @@ -13,6 +13,8 @@ import { SharedOrganizationModule } from "../shared"; import { BulkConfirmDialogComponent } from "./components/bulk/bulk-confirm-dialog.component"; import { BulkDeleteDialogComponent } from "./components/bulk/bulk-delete-dialog.component"; import { BulkEnableSecretsManagerDialogComponent } from "./components/bulk/bulk-enable-sm-dialog.component"; +import { BulkProgressDialogComponent } from "./components/bulk/bulk-progress-dialog.component"; +import { BulkReinviteFailureDialogComponent } from "./components/bulk/bulk-reinvite-failure-dialog.component"; import { BulkRemoveDialogComponent } from "./components/bulk/bulk-remove-dialog.component"; import { BulkRestoreRevokeComponent } from "./components/bulk/bulk-restore-revoke.component"; import { BulkStatusComponent } from "./components/bulk/bulk-status.component"; @@ -39,6 +41,7 @@ import { PasswordStrengthV2Component, ScrollLayoutDirective, OrganizationFreeTrialWarningComponent, + IconModule, ], declarations: [ BulkConfirmDialogComponent, @@ -46,6 +49,8 @@ import { BulkRemoveDialogComponent, BulkRestoreRevokeComponent, BulkStatusComponent, + BulkProgressDialogComponent, + BulkReinviteFailureDialogComponent, MembersComponent, vNextMembersComponent, BulkDeleteDialogComponent, diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts index 5924c2f7814..688c7ed77ce 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts @@ -18,6 +18,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; @@ -25,6 +26,7 @@ import { newGuid } from "@bitwarden/guid"; import { KeyService } from "@bitwarden/key-management"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; +import { MemberDialogManagerService } from "../member-dialog-manager/member-dialog-manager.service"; import { REQUESTS_PER_BATCH, MemberActionsService } from "./member-actions.service"; @@ -34,6 +36,7 @@ describe("MemberActionsService", () => { let organizationUserService: MockProxy; let configService: MockProxy; let organizationMetadataService: MockProxy; + let memberDialogManager: MockProxy; const organizationId = newGuid() as OrganizationId; const userIdToManage = newGuid(); @@ -46,6 +49,7 @@ describe("MemberActionsService", () => { organizationUserService = mock(); configService = mock(); organizationMetadataService = mock(); + memberDialogManager = mock(); mockOrganization = { id: organizationId, @@ -82,6 +86,8 @@ describe("MemberActionsService", () => { useValue: mock(), }, { provide: UserNamePipe, useValue: mock() }, + { provide: MemberDialogManagerService, useValue: memberDialogManager }, + { provide: I18nService, useValue: mock() }, ], }); @@ -318,8 +324,13 @@ describe("MemberActionsService", () => { }); describe("bulkReinvite", () => { + beforeEach(() => { + configService.getFeatureFlag$.mockReturnValue(of(false)); + }); + it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => { const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); const mockResponse = new ListResponse( { data: userIdsBatch.map((id) => ({ @@ -333,10 +344,10 @@ describe("MemberActionsService", () => { organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const result = await service.bulkReinvite(mockOrganization, users); expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); + expect(result.successful).toHaveLength(REQUESTS_PER_BATCH); expect(result.failed).toHaveLength(0); expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(1); expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( @@ -348,6 +359,7 @@ describe("MemberActionsService", () => { it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => { const totalUsers = REQUESTS_PER_BATCH + 100; const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); const mockResponse1 = new ListResponse( { @@ -375,10 +387,10 @@ describe("MemberActionsService", () => { .mockResolvedValueOnce(mockResponse1) .mockResolvedValueOnce(mockResponse2); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const result = await service.bulkReinvite(mockOrganization, users); expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(totalUsers); + expect(result.successful).toHaveLength(totalUsers); expect(result.failed).toHaveLength(0); expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2); expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( @@ -396,6 +408,7 @@ describe("MemberActionsService", () => { it("should aggregate results across multiple successful batches", async () => { const totalUsers = REQUESTS_PER_BATCH + 50; const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); const mockResponse1 = new ListResponse( { @@ -423,18 +436,19 @@ describe("MemberActionsService", () => { .mockResolvedValueOnce(mockResponse1) .mockResolvedValueOnce(mockResponse2); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const result = await service.bulkReinvite(mockOrganization, users); expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(totalUsers); - expect(result.successful?.response.slice(0, REQUESTS_PER_BATCH)).toEqual(mockResponse1.data); - expect(result.successful?.response.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data); + expect(result.successful).toHaveLength(totalUsers); + expect(result.successful!.slice(0, REQUESTS_PER_BATCH)).toEqual(mockResponse1.data); + expect(result.successful!.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data); expect(result.failed).toHaveLength(0); }); it("should handle mixed individual errors across multiple batches", async () => { const totalUsers = REQUESTS_PER_BATCH + 4; const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); const mockResponse1 = new ListResponse( { @@ -464,7 +478,7 @@ describe("MemberActionsService", () => { .mockResolvedValueOnce(mockResponse1) .mockResolvedValueOnce(mockResponse2); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const result = await service.bulkReinvite(mockOrganization, users); // Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch // Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values @@ -474,7 +488,7 @@ describe("MemberActionsService", () => { const expectedSuccesses = totalUsers - expectedTotalFailures; expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(expectedSuccesses); + expect(result.successful).toHaveLength(expectedSuccesses); expect(result.failed).toHaveLength(expectedTotalFailures); expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true); expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true); @@ -484,13 +498,14 @@ describe("MemberActionsService", () => { it("should aggregate all failures when all batches fail", async () => { const totalUsers = REQUESTS_PER_BATCH + 100; const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); const errorMessage = "All batches failed"; organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( new Error(errorMessage), ); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const result = await service.bulkReinvite(mockOrganization, users); expect(result.successful).toBeUndefined(); expect(result.failed).toHaveLength(totalUsers); @@ -501,6 +516,7 @@ describe("MemberActionsService", () => { it("should handle empty data in batch response", async () => { const totalUsers = REQUESTS_PER_BATCH + 50; const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); const mockResponse1 = new ListResponse( { @@ -525,16 +541,17 @@ describe("MemberActionsService", () => { .mockResolvedValueOnce(mockResponse1) .mockResolvedValueOnce(mockResponse2); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const result = await service.bulkReinvite(mockOrganization, users); expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); + expect(result.successful).toHaveLength(REQUESTS_PER_BATCH); expect(result.failed).toHaveLength(0); }); it("should process batches sequentially in order", async () => { const totalUsers = REQUESTS_PER_BATCH * 2; const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); const callOrder: number[] = []; organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation( @@ -555,11 +572,161 @@ describe("MemberActionsService", () => { }, ); - await service.bulkReinvite(mockOrganization, userIdsBatch); + await service.bulkReinvite(mockOrganization, users); expect(callOrder).toEqual([1, 2]); expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2); }); + + describe("with BulkReinviteUI feature flag enabled", () => { + let mockDialogService: MockProxy; + let mockI18nService: MockProxy; + + beforeEach(() => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + mockDialogService = TestBed.inject(DialogService) as MockProxy; + mockI18nService = TestBed.inject(I18nService) as MockProxy; + mockI18nService.t.mockImplementation((key: string) => key); + }); + + it("should open progress dialog when user count exceeds REQUESTS_PER_BATCH", async () => { + const totalUsers = REQUESTS_PER_BATCH + 100; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); + + mockDialogService.openSimpleDialog.mockResolvedValue(true); + + const mockDialogRef = { closed: of(undefined) }; + memberDialogManager.openBulkProgressDialog.mockReturnValue(mockDialogRef as any); + + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + const mockResponse2 = new ListResponse( + { + data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + await service.bulkReinvite(mockOrganization, users); + + expect(memberDialogManager.openBulkReinviteFailureDialog).not.toHaveBeenCalled(); + expect(memberDialogManager.openBulkProgressDialog).toHaveBeenCalledWith( + expect.anything(), + totalUsers, + ); + }); + + it("should not open progress dialog when user count is or below REQUESTS_PER_BATCH", async () => { + const totalUsers = REQUESTS_PER_BATCH; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); + + const mockResponse = new ListResponse( + { + data: userIdsBatch.map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); + + await service.bulkReinvite(mockOrganization, users); + + expect(memberDialogManager.openBulkReinviteFailureDialog).not.toHaveBeenCalled(); + expect(memberDialogManager.openBulkProgressDialog).not.toHaveBeenCalled(); + }); + + it("should open failure dialog when there are failures", async () => { + const totalUsers = 10; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); + + const mockResponse = new ListResponse( + { + data: userIdsBatch.map((id) => ({ + id, + error: "error", + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); + + const result = await service.bulkReinvite(mockOrganization, users); + + expect(memberDialogManager.openBulkReinviteFailureDialog).toHaveBeenCalledWith( + mockOrganization, + users, + result, + ); + expect(result.failed.length).toBeGreaterThan(0); + }); + + it("should process batches when exceeding REQUESTS_PER_BATCH", async () => { + const totalUsers = REQUESTS_PER_BATCH + 100; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const users = userIdsBatch.map((id) => ({ id }) as OrganizationUserView); + + const mockDialogRef = { closed: of(undefined) }; + memberDialogManager.openBulkProgressDialog.mockReturnValue(mockDialogRef as any); + + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + const mockResponse2 = new ListResponse( + { + data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + await service.bulkReinvite(mockOrganization, users); + + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( + 2, + ); + }); + }); }); describe("allowResetPassword", () => { diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts index 3b0db124a6b..e5f8c0c6673 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts @@ -1,5 +1,5 @@ -import { inject, Injectable, signal } from "@angular/core"; -import { lastValueFrom, firstValueFrom, switchMap } from "rxjs"; +import { inject, Injectable, signal, WritableSignal } from "@angular/core"; +import { lastValueFrom, firstValueFrom, switchMap, take } from "rxjs"; import { OrganizationUserApiService, @@ -23,11 +23,11 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { Utils } from "@bitwarden/common/platform/misc/utils"; import { DialogService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; -import { UserId } from "@bitwarden/user-core"; import { ProviderUser } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; import { UserConfirmComponent } from "../../../manage/user-confirm.component"; +import { MemberDialogManagerService } from "../member-dialog-manager/member-dialog-manager.service"; export const REQUESTS_PER_BATCH = 500; @@ -36,9 +36,13 @@ export interface MemberActionResult { error?: string; } -export interface BulkActionResult { - successful?: ListResponse; - failed: { id: string; error: string }[]; +export class BulkActionResult { + constructor() { + this.failed = []; + } + + successful?: OrganizationUserBulkResponse[]; + failed: { id: string; error: string }[] = []; } @Injectable() @@ -53,17 +57,28 @@ export class MemberActionsService { private logService = inject(LogService); private orgManagementPrefs = inject(OrganizationManagementPreferencesService); private userNamePipe = inject(UserNamePipe); + private memberDialogManager = inject(MemberDialogManagerService); readonly isProcessing = signal(false); - private startProcessing(): void { + private startProcessing(length?: number): void { this.isProcessing.set(true); + if (length != null && length > REQUESTS_PER_BATCH) { + this.memberDialogManager + .openBulkProgressDialog(this.progressCount, length) + .closed.pipe(take(1)) + .subscribe(() => { + this.progressCount.set(0); + }); + } } private endProcessing(): void { this.isProcessing.set(false); } + private readonly progressCount: WritableSignal = signal(0); + async inviteUser( organization: Organization, email: string, @@ -186,19 +201,42 @@ export class MemberActionsService { } } - async bulkReinvite(organization: Organization, userIds: UserId[]): Promise { - this.startProcessing(); + async bulkReinvite( + organization: Organization, + users: OrganizationUserView[], + ): Promise { + let result = new BulkActionResult(); + const bulkReinviteUIEnabled = await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.BulkReinviteUI), + ); + + if (bulkReinviteUIEnabled) { + this.startProcessing(users.length); + } else { + this.startProcessing(); + } + try { - return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) => - this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch), - ); + result = await this.processBatchedOperation(users, REQUESTS_PER_BATCH, (userBatch) => { + const userIds = userBatch.map((u) => u.id); + return this.organizationUserApiService.postManyOrganizationUserReinvite( + organization.id, + userIds, + ); + }); + + if (bulkReinviteUIEnabled && result.failed.length > 0) { + this.memberDialogManager.openBulkReinviteFailureDialog(organization, users, result); + } } catch (error) { - return { - failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })), - }; + result.failed = users.map((user) => ({ + id: user.id, + error: (error as Error).message ?? String(error), + })); } finally { this.endProcessing(); } + return result; } allowResetPassword( @@ -235,21 +273,23 @@ export class MemberActionsService { /** * Processes user IDs in sequential batches and aggregates results. - * @param userIds - Array of user IDs to process + * @param users - Array of users to process * @param batchSize - Number of IDs to process per batch - * @param processBatch - Async function that processes a single batch and returns the result + * @param processBatch - Async function that processes a single batch from the provided param `users` and returns the result. * @returns Aggregated bulk action result */ private async processBatchedOperation( - userIds: UserId[], + users: OrganizationUserView[], batchSize: number, - processBatch: (batch: string[]) => Promise>, + processBatch: ( + batch: OrganizationUserView[], + ) => Promise>, ): Promise { const allSuccessful: OrganizationUserBulkResponse[] = []; const allFailed: { id: string; error: string }[] = []; - for (let i = 0; i < userIds.length; i += batchSize) { - const batch = userIds.slice(i, i + batchSize); + for (let i = 0; i < users.length; i += batchSize) { + const batch = users.slice(i, i + batchSize); try { const result = await processBatch(batch); @@ -265,18 +305,18 @@ export class MemberActionsService { } } catch (error) { allFailed.push( - ...batch.map((id) => ({ id, error: (error as Error).message ?? String(error) })), + ...batch.map((user) => ({ + id: user.id, + error: (error as Error).message ?? String(error), + })), ); } + + this.progressCount.update((value) => value + batch.length); } - const successful = - allSuccessful.length > 0 - ? new ListResponse(allSuccessful, OrganizationUserBulkResponse) - : undefined; - return { - successful, + successful: allSuccessful.length > 0 ? allSuccessful : undefined, failed: allFailed, }; } diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts index c6ef536af2b..18106031fd0 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@angular/core"; +import { Injectable, WritableSignal } from "@angular/core"; import { firstValueFrom, lastValueFrom } from "rxjs"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; @@ -7,7 +7,7 @@ import { ProductTierType } from "@bitwarden/common/billing/enums"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; -import { DialogService, ToastService } from "@bitwarden/components"; +import { CenterPositionStrategy, DialogService, ToastService } from "@bitwarden/components"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; import { openEntityEventsDialog } from "../../../manage/entity-events.component"; @@ -18,6 +18,8 @@ import { import { BulkConfirmDialogComponent } from "../../components/bulk/bulk-confirm-dialog.component"; import { BulkDeleteDialogComponent } from "../../components/bulk/bulk-delete-dialog.component"; import { BulkEnableSecretsManagerDialogComponent } from "../../components/bulk/bulk-enable-sm-dialog.component"; +import { BulkProgressDialogComponent } from "../../components/bulk/bulk-progress-dialog.component"; +import { BulkReinviteFailureDialogComponent } from "../../components/bulk/bulk-reinvite-failure-dialog.component"; import { BulkRemoveDialogComponent } from "../../components/bulk/bulk-remove-dialog.component"; import { BulkRestoreRevokeComponent } from "../../components/bulk/bulk-restore-revoke.component"; import { BulkStatusComponent } from "../../components/bulk/bulk-status.component"; @@ -27,6 +29,7 @@ import { openUserAddEditDialog, } from "../../components/member-dialog"; import { DeleteManagedMemberWarningService } from "../delete-managed-member/delete-managed-member-warning.service"; +import { BulkActionResult } from "../member-actions/member-actions.service"; @Injectable() export class MemberDialogManagerService { @@ -319,4 +322,33 @@ export class MemberDialogManagerService { type: "warning", }); } + + openBulkProgressDialog(progress: WritableSignal, allCount: number) { + return this.dialogService.open(BulkProgressDialogComponent, { + disableClose: true, + positionStrategy: new CenterPositionStrategy(), + data: { + progress, + allCount, + }, + }); + } + + openBulkReinviteFailureDialog( + organization: Organization, + users: OrganizationUserView[], + result: BulkActionResult, + ) { + return this.dialogService.open( + BulkReinviteFailureDialogComponent, + { + positionStrategy: new CenterPositionStrategy(), + data: { + organization, + users, + result, + }, + }, + ); + } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index fc2f463d9e6..1f4e1c98e32 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -6637,6 +6637,18 @@ } } }, + "reinviteSuccessToast":{ + "message": "1 invitation sent" + }, + "bulkReinviteSentToast": { + "message": "$COUNT$ invitations sent", + "placeholders": { + "count": { + "content": "$1", + "example": "12" + } + } + }, "bulkReinviteLimitedSuccessToast": { "message": "$LIMIT$ of $SELECTEDCOUNT$ users re-invited. $EXCLUDEDCOUNT$ were not invited due to the $LIMIT$ invite limit.", "placeholders": { @@ -6654,6 +6666,50 @@ } } }, + "bulkReinviteProgressTitle": { + "message": "$COUNT$ of $TOTAL$ invitations sent...", + "placeholders": { + "count": { + "content": "$1", + "example": "1,000" + }, + "total": { + "content": "$2", + "example": "2,000" + } + } + }, + "bulkReinviteProgressSubtitle": { + "message": "Keep this page open until all are sent." + }, + "bulkReinviteFailuresTitle": { + "message": "$COUNT$ invitations didn't send", + "placeholders": { + "count": { + "content": "$1", + "example": "1,000" + } + } + }, + "bulkReinviteFailureTitle": { + "message": "1 invitation didn't send" + }, + "bulkReinviteFailureDescription":{ + "message": "An error occurred while sending invitations to $COUNT$ of $TOTAL$ members. Try sending again, and if the problem continues,", + "placeholders": { + "count": { + "content": "$1", + "example": "1,000" + }, + "total": { + "content": "$2", + "example": "2,000" + } + } + }, + "bulkResendInvitations": { + "message": "Try sending again" + }, "bulkRemovedMessage": { "message": "Removed successfully" }, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts index e581bf458d2..1b1ae25c027 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts @@ -215,7 +215,10 @@ export class MembersComponent extends BaseMembersComponent { } else { this.toastService.showToast({ variant: "success", - message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()), + message: + invitedCount === 1 + ? this.i18nService.t("reinviteSuccessToast") + : this.i18nService.t("bulkReinviteSentToast", invitedCount.toString()), }); } } else { diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts index 3efeee17100..c63bda449c5 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts @@ -228,7 +228,10 @@ export class vNextMembersComponent { } else { this.toastService.showToast({ variant: "success", - message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()), + message: + invitedCount === 1 + ? this.i18nService.t("reinviteSuccessToast") + : this.i18nService.t("bulkReinviteSentToast", invitedCount.toString()), }); } } else { diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 9941e7671f4..7722138b88f 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -15,6 +15,7 @@ export enum FeatureFlag { BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration", DefaultUserCollectionRestore = "pm-30883-my-items-restored-users", MembersComponentRefactor = "pm-29503-refactor-members-inheritance", + BulkReinviteUI = "pm-28416-bulk-reinvite-ux-improvements", /* Auth */ PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin", @@ -109,6 +110,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE, [FeatureFlag.DefaultUserCollectionRestore]: FALSE, [FeatureFlag.MembersComponentRefactor]: FALSE, + [FeatureFlag.BulkReinviteUI]: FALSE, /* Autofill */ [FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic]: FALSE, From 4fe29c71ce03391c8944a3fa479e41083348795c Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:31:55 -0600 Subject: [PATCH 03/42] allow archiving organization ciphers in the cli (#18793) --- apps/cli/src/vault/archive.command.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/cli/src/vault/archive.command.ts b/apps/cli/src/vault/archive.command.ts index 5ced2282c6d..0f634f78fb3 100644 --- a/apps/cli/src/vault/archive.command.ts +++ b/apps/cli/src/vault/archive.command.ts @@ -99,9 +99,6 @@ export class ArchiveCommand { errorMessage: "Item is in the trash, the item must be restored before archiving.", }; } - case cipher.organizationId != null: { - return { canArchive: false, errorMessage: "Cannot archive items in an organization." }; - } default: return { canArchive: true }; } From cc03df495013ef4fe79192e358237d63266d0c6c Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:39:53 -0800 Subject: [PATCH 04/42] [PM-17735] - remove v2 suffix from browser vault components (#18108) * remove v2 suffix * fix test * fix path * fix test * Fix missed -v2 import path * fix test --------- Co-authored-by: Shane --- apps/browser/src/popup/app-routing.module.ts | 44 +++++++------- .../add-edit/add-edit.component.html} | 0 .../add-edit/add-edit.component.spec.ts} | 12 ++-- .../add-edit/add-edit.component.ts} | 6 +- .../assign-collections.component.html | 0 .../assign-collections.component.ts | 0 .../attachments/attachments.component.html} | 0 .../attachments.component.spec.ts} | 14 ++--- .../attachments/attachments.component.ts} | 6 +- .../open-attachments.component.html | 0 .../open-attachments.component.spec.ts | 0 .../open-attachments.component.ts | 0 ...utofill-confirmation-dialog.component.html | 0 ...fill-confirmation-dialog.component.spec.ts | 0 .../autofill-confirmation-dialog.component.ts | 0 .../autofill-vault-list-items.component.html | 0 .../autofill-vault-list-items.component.ts | 0 .../blocked-injection-banner.component.html | 0 .../blocked-injection-banner.component.ts | 0 .../components/{vault-v2 => vault}/index.ts | 0 .../intro-carousel.component.html | 0 .../intro-carousel.component.ts | 0 .../item-copy-actions.component.html | 0 .../item-copy-actions.component.spec.ts | 0 .../item-copy-actions.component.ts | 0 .../item-more-options.component.html | 0 .../item-more-options.component.spec.ts | 0 .../item-more-options.component.ts | 2 +- .../new-item-dropdown.component.html} | 0 .../new-item-dropdown.component.spec.ts} | 14 ++--- .../new-item-dropdown.component.ts} | 6 +- .../vault-generator-dialog.component.html | 0 .../vault-generator-dialog.component.spec.ts | 0 .../vault-generator-dialog.component.ts | 0 .../vault-header/vault-header.component.html} | 2 +- .../vault-header.component.spec.ts} | 16 ++--- .../vault-header/vault-header.component.ts} | 12 ++-- .../vault-list-filters.component.html | 0 .../vault-list-filters.component.ts | 0 .../vault-list-items-container.component.html | 0 .../vault-list-items-container.component.ts | 0 .../vault-password-history.component.html} | 0 .../vault-password-history.component.spec.ts} | 10 ++-- .../vault-password-history.component.ts} | 6 +- .../vault-search/vault-search.component.html} | 0 .../vault-search.component.spec.ts} | 12 ++-- .../vault-search/vault-search.component.ts} | 6 +- .../vault.component.html} | 2 +- .../vault.component.spec.ts} | 58 +++++++++---------- .../vault.component.ts} | 14 ++--- .../view/view.component.html} | 0 .../view/view.component.spec.ts} | 14 ++--- .../view/view.component.ts} | 17 +++--- .../guards/clear-vault-state.guard.spec.ts | 8 +-- .../popup/guards/clear-vault-state.guard.ts | 6 +- .../browser-cipher-form-generation.service.ts | 2 +- ...mponent.html => appearance.component.html} | 0 ...t.spec.ts => appearance.component.spec.ts} | 14 ++--- ...2.component.ts => appearance.component.ts} | 4 +- .../vault/popup/settings/archive.component.ts | 2 +- ....component.html => folders.component.html} | 0 ...nent.spec.ts => folders.component.spec.ts} | 14 ++--- ...s-v2.component.ts => folders.component.ts} | 4 +- ...> more-from-bitwarden-page.component.html} | 0 ... => more-from-bitwarden-page.component.ts} | 4 +- ...ent.html => vault-settings.component.html} | 0 ...ec.ts => vault-settings.component.spec.ts} | 18 +++--- ...mponent.ts => vault-settings.component.ts} | 4 +- 68 files changed, 176 insertions(+), 177 deletions(-) rename apps/browser/src/vault/popup/components/{vault-v2/add-edit/add-edit-v2.component.html => vault/add-edit/add-edit.component.html} (100%) rename apps/browser/src/vault/popup/components/{vault-v2/add-edit/add-edit-v2.component.spec.ts => vault/add-edit/add-edit.component.spec.ts} (98%) rename apps/browser/src/vault/popup/components/{vault-v2/add-edit/add-edit-v2.component.ts => vault/add-edit/add-edit.component.ts} (99%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/assign-collections/assign-collections.component.html (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/assign-collections/assign-collections.component.ts (100%) rename apps/browser/src/vault/popup/components/{vault-v2/attachments/attachments-v2.component.html => vault/attachments/attachments.component.html} (100%) rename apps/browser/src/vault/popup/components/{vault-v2/attachments/attachments-v2.component.spec.ts => vault/attachments/attachments.component.spec.ts} (92%) rename apps/browser/src/vault/popup/components/{vault-v2/attachments/attachments-v2.component.ts => vault/attachments/attachments.component.ts} (94%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/attachments/open-attachments/open-attachments.component.html (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/attachments/open-attachments/open-attachments.component.spec.ts (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/attachments/open-attachments/open-attachments.component.ts (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/autofill-vault-list-items/autofill-vault-list-items.component.html (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/autofill-vault-list-items/autofill-vault-list-items.component.ts (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/blocked-injection-banner/blocked-injection-banner.component.html (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/blocked-injection-banner/blocked-injection-banner.component.ts (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/index.ts (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/intro-carousel/intro-carousel.component.html (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/intro-carousel/intro-carousel.component.ts (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/item-copy-action/item-copy-actions.component.html (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/item-copy-action/item-copy-actions.component.spec.ts (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/item-copy-action/item-copy-actions.component.ts (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/item-more-options/item-more-options.component.html (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/item-more-options/item-more-options.component.spec.ts (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/item-more-options/item-more-options.component.ts (99%) rename apps/browser/src/vault/popup/components/{vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html => vault/new-item-dropdown/new-item-dropdown.component.html} (100%) rename apps/browser/src/vault/popup/components/{vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts => vault/new-item-dropdown/new-item-dropdown.component.spec.ts} (93%) rename apps/browser/src/vault/popup/components/{vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts => vault/new-item-dropdown/new-item-dropdown.component.ts} (94%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/vault-generator-dialog/vault-generator-dialog.component.html (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/vault-generator-dialog/vault-generator-dialog.component.spec.ts (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/vault-generator-dialog/vault-generator-dialog.component.ts (100%) rename apps/browser/src/vault/popup/components/{vault-v2/vault-header/vault-header-v2.component.html => vault/vault-header/vault-header.component.html} (95%) rename apps/browser/src/vault/popup/components/{vault-v2/vault-header/vault-header-v2.component.spec.ts => vault/vault-header/vault-header.component.spec.ts} (92%) rename apps/browser/src/vault/popup/components/{vault-v2/vault-header/vault-header-v2.component.ts => vault/vault-header/vault-header.component.ts} (88%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/vault-list-filters/vault-list-filters.component.html (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/vault-list-filters/vault-list-filters.component.ts (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/vault-list-items-container/vault-list-items-container.component.html (100%) rename apps/browser/src/vault/popup/components/{vault-v2 => vault}/vault-list-items-container/vault-list-items-container.component.ts (100%) rename apps/browser/src/vault/popup/components/{vault-v2/vault-password-history-v2/vault-password-history-v2.component.html => vault/vault-password-history/vault-password-history.component.html} (100%) rename apps/browser/src/vault/popup/components/{vault-v2/vault-password-history-v2/vault-password-history-v2.component.spec.ts => vault/vault-password-history/vault-password-history.component.spec.ts} (90%) rename apps/browser/src/vault/popup/components/{vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts => vault/vault-password-history/vault-password-history.component.ts} (94%) rename apps/browser/src/vault/popup/components/{vault-v2/vault-search/vault-v2-search.component.html => vault/vault-search/vault-search.component.html} (100%) rename apps/browser/src/vault/popup/components/{vault-v2/vault-search/vault-v2-search.component.spec.ts => vault/vault-search/vault-search.component.spec.ts} (89%) rename apps/browser/src/vault/popup/components/{vault-v2/vault-search/vault-v2-search.component.ts => vault/vault-search/vault-search.component.ts} (95%) rename apps/browser/src/vault/popup/components/{vault-v2/vault-v2.component.html => vault/vault.component.html} (99%) rename apps/browser/src/vault/popup/components/{vault-v2/vault-v2.component.spec.ts => vault/vault.component.spec.ts} (94%) rename apps/browser/src/vault/popup/components/{vault-v2/vault-v2.component.ts => vault/vault.component.ts} (97%) rename apps/browser/src/vault/popup/components/{vault-v2/view-v2/view-v2.component.html => vault/view/view.component.html} (100%) rename apps/browser/src/vault/popup/components/{vault-v2/view-v2/view-v2.component.spec.ts => vault/view/view.component.spec.ts} (98%) rename apps/browser/src/vault/popup/components/{vault-v2/view-v2/view-v2.component.ts => vault/view/view.component.ts} (96%) rename apps/browser/src/vault/popup/settings/{appearance-v2.component.html => appearance.component.html} (100%) rename apps/browser/src/vault/popup/settings/{appearance-v2.component.spec.ts => appearance.component.spec.ts} (95%) rename apps/browser/src/vault/popup/settings/{appearance-v2.component.ts => appearance.component.ts} (98%) rename apps/browser/src/vault/popup/settings/{folders-v2.component.html => folders.component.html} (100%) rename apps/browser/src/vault/popup/settings/{folders-v2.component.spec.ts => folders.component.spec.ts} (93%) rename apps/browser/src/vault/popup/settings/{folders-v2.component.ts => folders.component.ts} (96%) rename apps/browser/src/vault/popup/settings/{more-from-bitwarden-page-v2.component.html => more-from-bitwarden-page.component.html} (100%) rename apps/browser/src/vault/popup/settings/{more-from-bitwarden-page-v2.component.ts => more-from-bitwarden-page.component.ts} (97%) rename apps/browser/src/vault/popup/settings/{vault-settings-v2.component.html => vault-settings.component.html} (100%) rename apps/browser/src/vault/popup/settings/{vault-settings-v2.component.spec.ts => vault-settings.component.spec.ts} (93%) rename apps/browser/src/vault/popup/settings/{vault-settings-v2.component.ts => vault-settings.component.ts} (97%) diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 7fb466449f2..4e14d1171fd 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -78,13 +78,13 @@ import { ExportBrowserV2Component } from "../tools/popup/settings/export/export- import { ImportBrowserV2Component } from "../tools/popup/settings/import/import-browser-v2.component"; import { SettingsV2Component } from "../tools/popup/settings/settings-v2.component"; import { AtRiskPasswordsComponent } from "../vault/popup/components/at-risk-passwords/at-risk-passwords.component"; -import { AddEditV2Component } from "../vault/popup/components/vault-v2/add-edit/add-edit-v2.component"; -import { AssignCollections } from "../vault/popup/components/vault-v2/assign-collections/assign-collections.component"; -import { AttachmentsV2Component } from "../vault/popup/components/vault-v2/attachments/attachments-v2.component"; -import { IntroCarouselComponent } from "../vault/popup/components/vault-v2/intro-carousel/intro-carousel.component"; -import { PasswordHistoryV2Component } from "../vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component"; -import { VaultV2Component } from "../vault/popup/components/vault-v2/vault-v2.component"; -import { ViewV2Component } from "../vault/popup/components/vault-v2/view-v2/view-v2.component"; +import { AddEditComponent } from "../vault/popup/components/vault/add-edit/add-edit.component"; +import { AssignCollections } from "../vault/popup/components/vault/assign-collections/assign-collections.component"; +import { AttachmentsComponent } from "../vault/popup/components/vault/attachments/attachments.component"; +import { IntroCarouselComponent } from "../vault/popup/components/vault/intro-carousel/intro-carousel.component"; +import { PasswordHistoryComponent } from "../vault/popup/components/vault/vault-password-history/vault-password-history.component"; +import { VaultComponent } from "../vault/popup/components/vault/vault.component"; +import { ViewComponent } from "../vault/popup/components/vault/view/view.component"; import { atRiskPasswordAuthGuard, canAccessAtRiskPasswords, @@ -93,13 +93,13 @@ import { import { clearVaultStateGuard } from "../vault/popup/guards/clear-vault-state.guard"; import { IntroCarouselGuard } from "../vault/popup/guards/intro-carousel.guard"; import { AdminSettingsComponent } from "../vault/popup/settings/admin-settings.component"; -import { AppearanceV2Component } from "../vault/popup/settings/appearance-v2.component"; +import { AppearanceComponent } from "../vault/popup/settings/appearance.component"; import { ArchiveComponent } from "../vault/popup/settings/archive.component"; import { DownloadBitwardenComponent } from "../vault/popup/settings/download-bitwarden.component"; -import { FoldersV2Component } from "../vault/popup/settings/folders-v2.component"; -import { MoreFromBitwardenPageV2Component } from "../vault/popup/settings/more-from-bitwarden-page-v2.component"; +import { FoldersComponent } from "../vault/popup/settings/folders.component"; +import { MoreFromBitwardenPageComponent } from "../vault/popup/settings/more-from-bitwarden-page.component"; import { TrashComponent } from "../vault/popup/settings/trash.component"; -import { VaultSettingsV2Component } from "../vault/popup/settings/vault-settings-v2.component"; +import { VaultSettingsComponent } from "../vault/popup/settings/vault-settings.component"; import { RouteElevation } from "./app-routing.animations"; import { @@ -214,7 +214,7 @@ const routes: Routes = [ }, { path: "view-cipher", - component: ViewV2Component, + component: ViewComponent, canActivate: [authGuard], data: { // Above "trash" @@ -223,20 +223,20 @@ const routes: Routes = [ }, { path: "cipher-password-history", - component: PasswordHistoryV2Component, + component: PasswordHistoryComponent, canActivate: [authGuard], data: { elevation: 4 } satisfies RouteDataProperties, }, { path: "add-cipher", - component: AddEditV2Component, + component: AddEditComponent, canActivate: [authGuard, debounceNavigationGuard()], data: { elevation: 1, resetRouterCacheOnTabChange: true } satisfies RouteDataProperties, runGuardsAndResolvers: "always", }, { path: "edit-cipher", - component: AddEditV2Component, + component: AddEditComponent, canActivate: [authGuard, debounceNavigationGuard()], data: { // Above "trash" @@ -247,7 +247,7 @@ const routes: Routes = [ }, { path: "attachments", - component: AttachmentsV2Component, + component: AttachmentsComponent, canActivate: [authGuard, filePickerPopoutGuard()], data: { elevation: 4 } satisfies RouteDataProperties, }, @@ -301,13 +301,13 @@ const routes: Routes = [ }, { path: "vault-settings", - component: VaultSettingsV2Component, + component: VaultSettingsComponent, canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "folders", - component: FoldersV2Component, + component: FoldersComponent, canActivate: [authGuard], data: { elevation: 2 } satisfies RouteDataProperties, }, @@ -331,7 +331,7 @@ const routes: Routes = [ }, { path: "appearance", - component: AppearanceV2Component, + component: AppearanceComponent, canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, @@ -343,7 +343,7 @@ const routes: Routes = [ }, { path: "clone-cipher", - component: AddEditV2Component, + component: AddEditComponent, canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, @@ -635,7 +635,7 @@ const routes: Routes = [ }, { path: "more-from-bitwarden", - component: MoreFromBitwardenPageV2Component, + component: MoreFromBitwardenPageComponent, canActivate: [authGuard], data: { elevation: 2 } satisfies RouteDataProperties, }, @@ -696,7 +696,7 @@ const routes: Routes = [ }, { path: "vault", - component: VaultV2Component, + component: VaultComponent, canActivate: [authGuard], canDeactivate: [clearVaultStateGuard], data: { elevation: 0 } satisfies RouteDataProperties, diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html b/apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html rename to apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.spec.ts similarity index 98% rename from apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.spec.ts index 8ea23e7e2b9..bc785996104 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.spec.ts @@ -40,16 +40,16 @@ import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-uti import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; import { PopupCloseWarningService } from "../../../../../popup/services/popup-close-warning.service"; -import { AddEditV2Component } from "./add-edit-v2.component"; +import { AddEditComponent } from "./add-edit.component"; // 'qrcode-parser' is used by `BrowserTotpCaptureService` but is an es6 module that jest can't compile. // Mock the entire module here to prevent jest from throwing an error. I wasn't able to find a way to mock the // `BrowserTotpCaptureService` where jest would not load the file in the first place. jest.mock("qrcode-parser", () => {}); -describe("AddEditV2Component", () => { - let component: AddEditV2Component; - let fixture: ComponentFixture; +describe("AddEditComponent", () => { + let component: AddEditComponent; + let fixture: ComponentFixture; let addEditCipherInfo$: BehaviorSubject; let cipherServiceMock: MockProxy; @@ -85,7 +85,7 @@ describe("AddEditV2Component", () => { }); await TestBed.configureTestingModule({ - imports: [AddEditV2Component], + imports: [AddEditComponent], providers: [ provideNoopAnimations(), { provide: PlatformUtilsService, useValue: mock() }, @@ -143,7 +143,7 @@ describe("AddEditV2Component", () => { }) .compileComponents(); - fixture = TestBed.createComponent(AddEditV2Component); + fixture = TestBed.createComponent(AddEditComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.ts similarity index 99% rename from apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts rename to apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.ts index 895a5fe0cce..d8f4ccc3d0c 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.ts @@ -157,8 +157,8 @@ export type AddEditQueryParams = Partial>; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - selector: "app-add-edit-v2", - templateUrl: "add-edit-v2.component.html", + selector: "app-add-edit", + templateUrl: "add-edit.component.html", providers: [ { provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService }, { provide: TotpCaptureService, useClass: BrowserTotpCaptureService }, @@ -182,7 +182,7 @@ export type AddEditQueryParams = Partial>; BadgeModule, ], }) -export class AddEditV2Component implements OnInit, OnDestroy { +export class AddEditComponent implements OnInit, OnDestroy { readonly cipherFormComponent = viewChild(CipherFormComponent); headerText: string; config: CipherFormConfig; diff --git a/apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.html b/apps/browser/src/vault/popup/components/vault/assign-collections/assign-collections.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.html rename to apps/browser/src/vault/popup/components/vault/assign-collections/assign-collections.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.ts b/apps/browser/src/vault/popup/components/vault/assign-collections/assign-collections.component.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.ts rename to apps/browser/src/vault/popup/components/vault/assign-collections/assign-collections.component.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.html b/apps/browser/src/vault/popup/components/vault/attachments/attachments.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.html rename to apps/browser/src/vault/popup/components/vault/attachments/attachments.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault/attachments/attachments.component.spec.ts similarity index 92% rename from apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/attachments/attachments.component.spec.ts index d8f1d34ef9a..377b4fa27cc 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/attachments/attachments.component.spec.ts @@ -23,7 +23,7 @@ import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component"; import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; -import { AttachmentsV2Component } from "./attachments-v2.component"; +import { AttachmentsComponent } from "./attachments.component"; @Component({ selector: "popup-header", @@ -44,9 +44,9 @@ class MockPopupFooterComponent { readonly pageTitle = input(); } -describe("AttachmentsV2Component", () => { - let component: AttachmentsV2Component; - let fixture: ComponentFixture; +describe("AttachmentsComponent", () => { + let component: AttachmentsComponent; + let fixture: ComponentFixture; const queryParams = new BehaviorSubject<{ cipherId: string }>({ cipherId: "5555-444-3333" }); let cipherAttachment: CipherAttachmentsComponent; const navigate = jest.fn(); @@ -60,7 +60,7 @@ describe("AttachmentsV2Component", () => { navigate.mockClear(); await TestBed.configureTestingModule({ - imports: [AttachmentsV2Component], + imports: [AttachmentsComponent], providers: [ { provide: LogService, useValue: mock() }, { provide: ConfigService, useValue: mock() }, @@ -83,7 +83,7 @@ describe("AttachmentsV2Component", () => { { provide: OrganizationService, useValue: mock() }, ], }) - .overrideComponent(AttachmentsV2Component, { + .overrideComponent(AttachmentsComponent, { remove: { imports: [PopupHeaderComponent, PopupFooterComponent], }, @@ -95,7 +95,7 @@ describe("AttachmentsV2Component", () => { }); beforeEach(() => { - fixture = TestBed.createComponent(AttachmentsV2Component); + fixture = TestBed.createComponent(AttachmentsComponent); component = fixture.componentInstance; fixture.detectChanges(); diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts b/apps/browser/src/vault/popup/components/vault/attachments/attachments.component.ts similarity index 94% rename from apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts rename to apps/browser/src/vault/popup/components/vault/attachments/attachments.component.ts index 29282d293de..63196edab30 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/attachments/attachments.component.ts @@ -20,8 +20,8 @@ import { PopupRouterCacheService } from "../../../../../platform/popup/view-cach // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - selector: "app-attachments-v2", - templateUrl: "./attachments-v2.component.html", + selector: "app-attachments", + templateUrl: "./attachments.component.html", imports: [ CommonModule, ButtonModule, @@ -33,7 +33,7 @@ import { PopupRouterCacheService } from "../../../../../platform/popup/view-cach PopOutComponent, ], }) -export class AttachmentsV2Component { +export class AttachmentsComponent { /** The `id` tied to the underlying HTMLFormElement */ attachmentFormId = CipherAttachmentsComponent.attachmentFormID; diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.html b/apps/browser/src/vault/popup/components/vault/attachments/open-attachments/open-attachments.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.html rename to apps/browser/src/vault/popup/components/vault/attachments/open-attachments/open-attachments.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts b/apps/browser/src/vault/popup/components/vault/attachments/open-attachments/open-attachments.component.spec.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/attachments/open-attachments/open-attachments.component.spec.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts b/apps/browser/src/vault/popup/components/vault/attachments/open-attachments/open-attachments.component.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts rename to apps/browser/src/vault/popup/components/vault/attachments/open-attachments/open-attachments.component.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html b/apps/browser/src/vault/popup/components/vault/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html rename to apps/browser/src/vault/popup/components/vault/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts b/apps/browser/src/vault/popup/components/vault/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts b/apps/browser/src/vault/popup/components/vault/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts rename to apps/browser/src/vault/popup/components/vault/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html b/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html rename to apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts b/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts rename to apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/blocked-injection-banner/blocked-injection-banner.component.html b/apps/browser/src/vault/popup/components/vault/blocked-injection-banner/blocked-injection-banner.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/blocked-injection-banner/blocked-injection-banner.component.html rename to apps/browser/src/vault/popup/components/vault/blocked-injection-banner/blocked-injection-banner.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/blocked-injection-banner/blocked-injection-banner.component.ts b/apps/browser/src/vault/popup/components/vault/blocked-injection-banner/blocked-injection-banner.component.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/blocked-injection-banner/blocked-injection-banner.component.ts rename to apps/browser/src/vault/popup/components/vault/blocked-injection-banner/blocked-injection-banner.component.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/index.ts b/apps/browser/src/vault/popup/components/vault/index.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/index.ts rename to apps/browser/src/vault/popup/components/vault/index.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html b/apps/browser/src/vault/popup/components/vault/intro-carousel/intro-carousel.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html rename to apps/browser/src/vault/popup/components/vault/intro-carousel/intro-carousel.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts b/apps/browser/src/vault/popup/components/vault/intro-carousel/intro-carousel.component.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts rename to apps/browser/src/vault/popup/components/vault/intro-carousel/intro-carousel.component.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html b/apps/browser/src/vault/popup/components/vault/item-copy-action/item-copy-actions.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html rename to apps/browser/src/vault/popup/components/vault/item-copy-action/item-copy-actions.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.spec.ts b/apps/browser/src/vault/popup/components/vault/item-copy-action/item-copy-actions.component.spec.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/item-copy-action/item-copy-actions.component.spec.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts b/apps/browser/src/vault/popup/components/vault/item-copy-action/item-copy-actions.component.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts rename to apps/browser/src/vault/popup/components/vault/item-copy-action/item-copy-actions.component.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html rename to apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.spec.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.spec.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts similarity index 99% rename from apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts rename to apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts index d7de51ad20f..8ed2699254e 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts @@ -35,7 +35,7 @@ import { PasswordRepromptService } from "@bitwarden/vault"; import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.service"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; -import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; +import { AddEditQueryParams } from "../add-edit/add-edit.component"; import { AutofillConfirmationDialogComponent, AutofillConfirmationDialogResult, diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html b/apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html rename to apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.spec.ts similarity index 93% rename from apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.spec.ts index 48e87e2d192..a20724e3160 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.spec.ts @@ -21,13 +21,13 @@ import { ButtonModule, DialogService, MenuModule, NoItemsModule } from "@bitward import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; -import { NewItemDropdownV2Component, NewItemInitialValues } from "./new-item-dropdown-v2.component"; +import { NewItemDropdownComponent, NewItemInitialValues } from "./new-item-dropdown.component"; -describe("NewItemDropdownV2Component", () => { - let component: NewItemDropdownV2Component; - let fixture: ComponentFixture; +describe("NewItemDropdownComponent", () => { + let component: NewItemDropdownComponent; + let fixture: ComponentFixture; let dialogServiceMock: jest.Mocked; - let browserApiMock: jest.Mocked; + const browserApiMock: jest.Mocked = mock(); let restrictedItemTypesServiceMock: jest.Mocked; const mockTab = { url: "https://example.com" }; @@ -62,7 +62,7 @@ describe("NewItemDropdownV2Component", () => { ButtonModule, MenuModule, NoItemsModule, - NewItemDropdownV2Component, + NewItemDropdownComponent, ], providers: [ { provide: I18nService, useValue: { t: (key: string) => key } }, @@ -80,7 +80,7 @@ describe("NewItemDropdownV2Component", () => { }); beforeEach(() => { - fixture = TestBed.createComponent(NewItemDropdownV2Component); + fixture = TestBed.createComponent(NewItemDropdownComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts b/apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.ts similarity index 94% rename from apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts rename to apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.ts index 004980db181..aa43743960b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/new-item-dropdown/new-item-dropdown.component.ts @@ -15,7 +15,7 @@ import { AddEditFolderDialogComponent } from "@bitwarden/vault"; import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; -import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; +import { AddEditQueryParams } from "../add-edit/add-edit.component"; export interface NewItemInitialValues { folderId?: string; @@ -27,10 +27,10 @@ export interface NewItemInitialValues { // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-new-item-dropdown", - templateUrl: "new-item-dropdown-v2.component.html", + templateUrl: "new-item-dropdown.component.html", imports: [NoItemsModule, JslibModule, CommonModule, ButtonModule, RouterLink, MenuModule], }) -export class NewItemDropdownV2Component implements OnInit { +export class NewItemDropdownComponent implements OnInit { cipherType = CipherType; private tab?: chrome.tabs.Tab; /** diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html b/apps/browser/src/vault/popup/components/vault/vault-generator-dialog/vault-generator-dialog.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html rename to apps/browser/src/vault/popup/components/vault/vault-generator-dialog/vault-generator-dialog.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts b/apps/browser/src/vault/popup/components/vault/vault-generator-dialog/vault-generator-dialog.component.spec.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/vault-generator-dialog/vault-generator-dialog.component.spec.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts b/apps/browser/src/vault/popup/components/vault/vault-generator-dialog/vault-generator-dialog.component.ts similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts rename to apps/browser/src/vault/popup/components/vault/vault-generator-dialog/vault-generator-dialog.component.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html b/apps/browser/src/vault/popup/components/vault/vault-header/vault-header.component.html similarity index 95% rename from apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html rename to apps/browser/src/vault/popup/components/vault/vault-header/vault-header.component.html index 1ab162b56fb..09b4cb2b461 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-header/vault-header.component.html @@ -1,6 +1,6 @@
- +
- + diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault/vault.component.spec.ts similarity index 94% rename from apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/vault.component.spec.ts index a956b2fe68b..55cb18ba637 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/vault.component.spec.ts @@ -50,10 +50,10 @@ import { AtRiskPasswordCalloutComponent } from "../at-risk-callout/at-risk-passw import { AutofillVaultListItemsComponent } from "./autofill-vault-list-items/autofill-vault-list-items.component"; import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component"; -import { NewItemDropdownV2Component } from "./new-item-dropdown/new-item-dropdown-v2.component"; -import { VaultHeaderV2Component } from "./vault-header/vault-header-v2.component"; +import { NewItemDropdownComponent } from "./new-item-dropdown/new-item-dropdown.component"; +import { VaultHeaderComponent } from "./vault-header/vault-header.component"; import { VaultListItemsContainerComponent } from "./vault-list-items-container/vault-list-items-container.component"; -import { VaultV2Component } from "./vault-v2.component"; +import { VaultComponent, VaultComponent } from "./vault.component"; @Component({ selector: "popup-header", @@ -66,12 +66,12 @@ export class PopupHeaderStubComponent { } @Component({ - selector: "app-vault-header-v2", + selector: "app-vault-header", standalone: true, template: "", changeDetection: ChangeDetectionStrategy.OnPush, }) -export class VaultHeaderV2StubComponent {} +export class VaultHeaderStubComponent {} @Component({ selector: "app-current-account", @@ -158,8 +158,8 @@ const autoConfirmDialogSpy = jest jest.spyOn(BrowserApi, "isPopupOpen").mockResolvedValue(false); jest.spyOn(BrowserPopupUtils, "openCurrentPagePopout").mockResolvedValue(); -describe("VaultV2Component", () => { - let component: VaultV2Component; +describe("VaultComponent", () => { + let component: VaultComponent; interface FakeAccount { id: string; @@ -242,7 +242,7 @@ describe("VaultV2Component", () => { beforeEach(async () => { jest.clearAllMocks(); await TestBed.configureTestingModule({ - imports: [VaultV2Component, RouterTestingModule], + imports: [VaultComponent, RouterTestingModule], providers: [ provideNoopAnimations(), { provide: VaultPopupItemsService, useValue: itemsSvc }, @@ -298,13 +298,13 @@ describe("VaultV2Component", () => { schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); - TestBed.overrideComponent(VaultV2Component, { + TestBed.overrideComponent(VaultComponent, { remove: { imports: [ PopupHeaderComponent, - VaultHeaderV2Component, + VaultHeaderComponent, CurrentAccountComponent, - NewItemDropdownV2Component, + NewItemDropdownComponent, PopOutComponent, BlockedInjectionBanner, AtRiskPasswordCalloutComponent, @@ -318,7 +318,7 @@ describe("VaultV2Component", () => { add: { imports: [ PopupHeaderStubComponent, - VaultHeaderV2StubComponent, + VaultHeaderStubComponent, CurrentAccountStubComponent, NewItemDropdownStubComponent, PopOutStubComponent, @@ -331,7 +331,7 @@ describe("VaultV2Component", () => { }, }); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); component = fixture.componentInstance; }); @@ -393,7 +393,7 @@ describe("VaultV2Component", () => { }); it("passes popup-page scroll region element to scroll position service", fakeAsync(() => { - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); const component = fixture.componentInstance; const readySubject$ = component["readySubject"] as unknown as BehaviorSubject; @@ -491,7 +491,7 @@ describe("VaultV2Component", () => { of(type === NudgeType.PremiumUpgrade), ); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); const component = fixture.componentInstance; void component.ngOnInit(); @@ -524,7 +524,7 @@ describe("VaultV2Component", () => { return of(type === NudgeType.EmptyVaultNudge); }); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); fixture.detectChanges(); tick(); @@ -541,7 +541,7 @@ describe("VaultV2Component", () => { return of(type === NudgeType.HasVaultItems); }); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); fixture.detectChanges(); tick(); @@ -559,7 +559,7 @@ describe("VaultV2Component", () => { return of(type === NudgeType.PremiumUpgrade); }); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); fixture.detectChanges(); tick(); @@ -575,7 +575,7 @@ describe("VaultV2Component", () => { return of(type === NudgeType.PremiumUpgrade); }); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); fixture.detectChanges(); tick(); @@ -591,7 +591,7 @@ describe("VaultV2Component", () => { return of(type === NudgeType.PremiumUpgrade); }); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); fixture.detectChanges(); tick(); @@ -602,7 +602,7 @@ describe("VaultV2Component", () => { it("does not render app-autofill-vault-list-items or favorites item container when hasSearchText$ is true", () => { itemsSvc.hasSearchText$.next(true); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); component = fixture.componentInstance; const readySubject$ = component["readySubject"]; @@ -628,7 +628,7 @@ describe("VaultV2Component", () => { itemsSvc.hasSearchText$.next(false); loadingSvc.loading$.next(false); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); component = fixture.componentInstance; const readySubject$ = component["readySubject"]; @@ -655,7 +655,7 @@ describe("VaultV2Component", () => { filtersSvc.numberOfAppliedFilters$.next(0); loadingSvc.loading$.next(false); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); component = fixture.componentInstance; const readySubject$ = component["readySubject"]; @@ -679,7 +679,7 @@ describe("VaultV2Component", () => { itemsSvc.hasSearchText$.next(true); loadingSvc.loading$.next(false); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); component = fixture.componentInstance; const readySubject$ = component["readySubject"]; @@ -704,7 +704,7 @@ describe("VaultV2Component", () => { filtersSvc.numberOfAppliedFilters$.next(1); loadingSvc.loading$.next(false); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); component = fixture.componentInstance; const readySubject$ = component["readySubject"]; @@ -735,7 +735,7 @@ describe("VaultV2Component", () => { }), ); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); const component = fixture.componentInstance; void component.ngOnInit(); @@ -754,7 +754,7 @@ describe("VaultV2Component", () => { }), ); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); const component = fixture.componentInstance; void component.ngOnInit(); @@ -773,7 +773,7 @@ describe("VaultV2Component", () => { }), ); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); const component = fixture.componentInstance; void component.ngOnInit(); @@ -792,7 +792,7 @@ describe("VaultV2Component", () => { }), ); - const fixture = TestBed.createComponent(VaultV2Component); + const fixture = TestBed.createComponent(VaultComponent); const component = fixture.componentInstance; void component.ngOnInit(); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault/vault.component.ts similarity index 97% rename from apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts rename to apps/browser/src/vault/popup/components/vault/vault.component.ts index a5a74eb8ab8..281abc5f180 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault.component.ts @@ -71,10 +71,10 @@ import { VaultLoadingSkeletonComponent } from "../vault-loading-skeleton/vault-l import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component"; import { - NewItemDropdownV2Component, + NewItemDropdownComponent, NewItemInitialValues, -} from "./new-item-dropdown/new-item-dropdown-v2.component"; -import { VaultHeaderV2Component } from "./vault-header/vault-header-v2.component"; +} from "./new-item-dropdown/new-item-dropdown.component"; +import { VaultHeaderComponent } from "./vault-header/vault-header.component"; import { AutofillVaultListItemsComponent, VaultListItemsContainerComponent } from "."; @@ -90,7 +90,7 @@ type VaultState = UnionOfValues; // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-vault", - templateUrl: "vault-v2.component.html", + templateUrl: "vault.component.html", imports: [ BlockedInjectionBanner, PopupPageComponent, @@ -103,9 +103,9 @@ type VaultState = UnionOfValues; AutofillVaultListItemsComponent, VaultListItemsContainerComponent, ButtonModule, - NewItemDropdownV2Component, + NewItemDropdownComponent, ScrollingModule, - VaultHeaderV2Component, + VaultHeaderComponent, AtRiskPasswordCalloutComponent, SpotlightComponent, RouterModule, @@ -116,7 +116,7 @@ type VaultState = UnionOfValues; ], providers: [{ provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService }], }) -export class VaultV2Component implements OnInit, OnDestroy { +export class VaultComponent implements OnInit, OnDestroy { NudgeType = NudgeType; cipherType = CipherType; private activeUserId$ = this.accountService.activeAccount$.pipe(getUserId); diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html b/apps/browser/src/vault/popup/components/vault/view/view.component.html similarity index 100% rename from apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html rename to apps/browser/src/vault/popup/components/vault/view/view.component.html diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault/view/view.component.spec.ts similarity index 98% rename from apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts rename to apps/browser/src/vault/popup/components/vault/view/view.component.spec.ts index 7e57cd69ba1..5c94af0205d 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/view/view.component.spec.ts @@ -45,19 +45,19 @@ import { import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; +import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; import { VaultPopupScrollPositionService } from "../../../services/vault-popup-scroll-position.service"; -import { VaultPopupAutofillService } from "./../../../services/vault-popup-autofill.service"; -import { ViewV2Component } from "./view-v2.component"; +import { ViewComponent } from "./view.component"; // 'qrcode-parser' is used by `BrowserTotpCaptureService` but is an es6 module that jest can't compile. // Mock the entire module here to prevent jest from throwing an error. I wasn't able to find a way to mock the // `BrowserTotpCaptureService` where jest would not load the file in the first place. jest.mock("qrcode-parser", () => {}); -describe("ViewV2Component", () => { - let component: ViewV2Component; - let fixture: ComponentFixture; +describe("ViewComponent", () => { + let component: ViewComponent; + let fixture: ComponentFixture; const params$ = new Subject(); const mockNavigate = jest.fn(); const collect = jest.fn().mockResolvedValue(null); @@ -124,7 +124,7 @@ describe("ViewV2Component", () => { cipherArchiveService.unarchiveWithServer.mockResolvedValue({ id: "122-333-444" } as CipherData); await TestBed.configureTestingModule({ - imports: [ViewV2Component], + imports: [ViewComponent], providers: [ { provide: Router, useValue: { navigate: mockNavigate } }, { provide: CipherService, useValue: mockCipherService }, @@ -231,7 +231,7 @@ describe("ViewV2Component", () => { }) .compileComponents(); - fixture = TestBed.createComponent(ViewV2Component); + fixture = TestBed.createComponent(ViewComponent); component = fixture.componentInstance; fixture.detectChanges(); (component as any).showFooter$ = of(true); diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault/view/view.component.ts similarity index 96% rename from apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts rename to apps/browser/src/vault/popup/components/vault/view/view.component.ts index f57b3e2d7f1..d63cd5920a1 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/view/view.component.ts @@ -56,17 +56,16 @@ import { sendExtensionMessage } from "../../../../../autofill/utils/index"; import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component"; +import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component"; +import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component"; import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.service"; import { BrowserViewPasswordHistoryService } from "../../../services/browser-view-password-history.service"; +import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; import { VaultPopupScrollPositionService } from "../../../services/vault-popup-scroll-position.service"; import { closeViewVaultItemPopout, VaultPopoutType } from "../../../utils/vault-popout-window"; -import { ROUTES_AFTER_EDIT_DELETION } from "../add-edit/add-edit-v2.component"; - -import { PopupFooterComponent } from "./../../../../../platform/popup/layout/popup-footer.component"; -import { PopupHeaderComponent } from "./../../../../../platform/popup/layout/popup-header.component"; -import { PopupPageComponent } from "./../../../../../platform/popup/layout/popup-page.component"; -import { VaultPopupAutofillService } from "./../../../services/vault-popup-autofill.service"; +import { ROUTES_AFTER_EDIT_DELETION } from "../add-edit/add-edit.component"; /** * The types of actions that can be triggered when loading the view vault item popout via the @@ -83,8 +82,8 @@ type LoadAction = // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - selector: "app-view-v2", - templateUrl: "view-v2.component.html", + selector: "app-view", + templateUrl: "view.component.html", imports: [ CommonModule, SearchModule, @@ -107,7 +106,7 @@ type LoadAction = { provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService }, ], }) -export class ViewV2Component { +export class ViewComponent { private activeUserId: UserId; headerText: string; diff --git a/apps/browser/src/vault/popup/guards/clear-vault-state.guard.spec.ts b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.spec.ts index 7ead8576b37..633a3b3295e 100644 --- a/apps/browser/src/vault/popup/guards/clear-vault-state.guard.spec.ts +++ b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.spec.ts @@ -1,7 +1,7 @@ import { TestBed } from "@angular/core/testing"; import { RouterStateSnapshot } from "@angular/router"; -import { VaultV2Component } from "../components/vault-v2/vault-v2.component"; +import { VaultComponent } from "../components/vault/vault.component"; import { VaultPopupItemsService } from "../services/vault-popup-items.service"; import { VaultPopupListFiltersService } from "../services/vault-popup-list-filters.service"; @@ -42,7 +42,7 @@ describe("clearVaultStateGuard", () => { const nextState = { url } as RouterStateSnapshot; const result = TestBed.runInInjectionContext(() => - clearVaultStateGuard({} as VaultV2Component, null, null, nextState), + clearVaultStateGuard({} as VaultComponent, null, null, nextState), ); expect(result).toBe(true); @@ -56,7 +56,7 @@ describe("clearVaultStateGuard", () => { const nextState = { url } as RouterStateSnapshot; const result = TestBed.runInInjectionContext(() => - clearVaultStateGuard({} as VaultV2Component, null, null, nextState), + clearVaultStateGuard({} as VaultComponent, null, null, nextState), ); expect(result).toBe(true); @@ -67,7 +67,7 @@ describe("clearVaultStateGuard", () => { it("should not clear vault state when not changing states", () => { const result = TestBed.runInInjectionContext(() => - clearVaultStateGuard({} as VaultV2Component, null, null, null), + clearVaultStateGuard({} as VaultComponent, null, null, null), ); expect(result).toBe(true); diff --git a/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts index 2a87db6e903..5258c7cd741 100644 --- a/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts +++ b/apps/browser/src/vault/popup/guards/clear-vault-state.guard.ts @@ -1,7 +1,7 @@ import { inject } from "@angular/core"; import { CanDeactivateFn } from "@angular/router"; -import { VaultV2Component } from "../components/vault-v2/vault-v2.component"; +import { VaultComponent } from "../components/vault/vault.component"; import { VaultPopupItemsService } from "../services/vault-popup-items.service"; import { VaultPopupListFiltersService } from "../services/vault-popup-list-filters.service"; @@ -10,8 +10,8 @@ import { VaultPopupListFiltersService } from "../services/vault-popup-list-filte * This ensures the search and filter state is reset when navigating between different tabs, * except viewing or editing a cipher. */ -export const clearVaultStateGuard: CanDeactivateFn = ( - component: VaultV2Component, +export const clearVaultStateGuard: CanDeactivateFn = ( + component: VaultComponent, currentRoute, currentState, nextState, diff --git a/apps/browser/src/vault/popup/services/browser-cipher-form-generation.service.ts b/apps/browser/src/vault/popup/services/browser-cipher-form-generation.service.ts index ecba9aa1413..9e67953c251 100644 --- a/apps/browser/src/vault/popup/services/browser-cipher-form-generation.service.ts +++ b/apps/browser/src/vault/popup/services/browser-cipher-form-generation.service.ts @@ -7,7 +7,7 @@ import { firstValueFrom } from "rxjs"; import { DialogService } from "@bitwarden/components"; import { CipherFormGenerationService } from "@bitwarden/vault"; -import { VaultGeneratorDialogComponent } from "../components/vault-v2/vault-generator-dialog/vault-generator-dialog.component"; +import { VaultGeneratorDialogComponent } from "../components/vault/vault-generator-dialog/vault-generator-dialog.component"; @Injectable() export class BrowserCipherFormGenerationService implements CipherFormGenerationService { diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.html b/apps/browser/src/vault/popup/settings/appearance.component.html similarity index 100% rename from apps/browser/src/vault/popup/settings/appearance-v2.component.html rename to apps/browser/src/vault/popup/settings/appearance.component.html diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts b/apps/browser/src/vault/popup/settings/appearance.component.spec.ts similarity index 95% rename from apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts rename to apps/browser/src/vault/popup/settings/appearance.component.spec.ts index 9e1beab5787..41e89ec30e8 100644 --- a/apps/browser/src/vault/popup/settings/appearance-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/settings/appearance.component.spec.ts @@ -20,7 +20,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co import { PopupSizeService } from "../../../platform/popup/layout/popup-size.service"; import { VaultPopupCopyButtonsService } from "../services/vault-popup-copy-buttons.service"; -import { AppearanceV2Component } from "./appearance-v2.component"; +import { AppearanceComponent } from "./appearance.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -49,9 +49,9 @@ class MockPopupPageComponent { @Input() loading: boolean; } -describe("AppearanceV2Component", () => { - let component: AppearanceV2Component; - let fixture: ComponentFixture; +describe("AppearanceComponent", () => { + let component: AppearanceComponent; + let fixture: ComponentFixture; const showFavicons$ = new BehaviorSubject(true); const enableBadgeCounter$ = new BehaviorSubject(true); @@ -80,7 +80,7 @@ describe("AppearanceV2Component", () => { setEnableRoutingAnimation.mockClear(); await TestBed.configureTestingModule({ - imports: [AppearanceV2Component], + imports: [AppearanceComponent], providers: [ { provide: ConfigService, useValue: mock() }, { provide: PlatformUtilsService, useValue: mock() }, @@ -120,7 +120,7 @@ describe("AppearanceV2Component", () => { }, ], }) - .overrideComponent(AppearanceV2Component, { + .overrideComponent(AppearanceComponent, { remove: { imports: [PopupHeaderComponent, PopupPageComponent], }, @@ -130,7 +130,7 @@ describe("AppearanceV2Component", () => { }) .compileComponents(); - fixture = TestBed.createComponent(AppearanceV2Component); + fixture = TestBed.createComponent(AppearanceComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts b/apps/browser/src/vault/popup/settings/appearance.component.ts similarity index 98% rename from apps/browser/src/vault/popup/settings/appearance-v2.component.ts rename to apps/browser/src/vault/popup/settings/appearance.component.ts index e02ccf25f3e..bff51335192 100644 --- a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/appearance.component.ts @@ -36,7 +36,7 @@ import { VaultPopupCopyButtonsService } from "../services/vault-popup-copy-butto // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - templateUrl: "./appearance-v2.component.html", + templateUrl: "./appearance.component.html", imports: [ CommonModule, JslibModule, @@ -52,7 +52,7 @@ import { VaultPopupCopyButtonsService } from "../services/vault-popup-copy-butto PermitCipherDetailsPopoverComponent, ], }) -export class AppearanceV2Component implements OnInit { +export class AppearanceComponent implements OnInit { private compactModeService = inject(PopupCompactModeService); private copyButtonsService = inject(VaultPopupCopyButtonsService); private popupSizeService = inject(PopupSizeService); diff --git a/apps/browser/src/vault/popup/settings/archive.component.ts b/apps/browser/src/vault/popup/settings/archive.component.ts index a34609bd8f8..336d9be6d16 100644 --- a/apps/browser/src/vault/popup/settings/archive.component.ts +++ b/apps/browser/src/vault/popup/settings/archive.component.ts @@ -42,7 +42,7 @@ import { import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; -import { ROUTES_AFTER_EDIT_DELETION } from "../components/vault-v2/add-edit/add-edit-v2.component"; +import { ROUTES_AFTER_EDIT_DELETION } from "../components/vault/add-edit/add-edit.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.html b/apps/browser/src/vault/popup/settings/folders.component.html similarity index 100% rename from apps/browser/src/vault/popup/settings/folders-v2.component.html rename to apps/browser/src/vault/popup/settings/folders.component.html diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts b/apps/browser/src/vault/popup/settings/folders.component.spec.ts similarity index 93% rename from apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts rename to apps/browser/src/vault/popup/settings/folders.component.spec.ts index 3cb5503ed89..678e6d3f10e 100644 --- a/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/settings/folders.component.spec.ts @@ -19,7 +19,7 @@ import { AddEditFolderDialogComponent } from "@bitwarden/vault"; import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; -import { FoldersV2Component } from "./folders-v2.component"; +import { FoldersComponent } from "./folders.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -48,9 +48,9 @@ class MockPopupFooterComponent { @Input() pageTitle: string = ""; } -describe("FoldersV2Component", () => { - let component: FoldersV2Component; - let fixture: ComponentFixture; +describe("FoldersComponent", () => { + let component: FoldersComponent; + let fixture: ComponentFixture; const folderViews$ = new BehaviorSubject([]); const open = jest.spyOn(AddEditFolderDialogComponent, "open"); const mockDialogService = { open: jest.fn() }; @@ -59,7 +59,7 @@ describe("FoldersV2Component", () => { open.mockClear(); await TestBed.configureTestingModule({ - imports: [FoldersV2Component], + imports: [FoldersComponent], providers: [ { provide: PlatformUtilsService, useValue: mock() }, { provide: ConfigService, useValue: mock() }, @@ -69,7 +69,7 @@ describe("FoldersV2Component", () => { { provide: AccountService, useValue: mockAccountServiceWith("UserId" as UserId) }, ], }) - .overrideComponent(FoldersV2Component, { + .overrideComponent(FoldersComponent, { remove: { imports: [PopupHeaderComponent, PopupFooterComponent], }, @@ -80,7 +80,7 @@ describe("FoldersV2Component", () => { .overrideProvider(DialogService, { useValue: mockDialogService }) .compileComponents(); - fixture = TestBed.createComponent(FoldersV2Component); + fixture = TestBed.createComponent(FoldersComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.ts b/apps/browser/src/vault/popup/settings/folders.component.ts similarity index 96% rename from apps/browser/src/vault/popup/settings/folders-v2.component.ts rename to apps/browser/src/vault/popup/settings/folders.component.ts index 20a816e7297..b70c17bd6a5 100644 --- a/apps/browser/src/vault/popup/settings/folders-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/folders.component.ts @@ -25,7 +25,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - templateUrl: "./folders-v2.component.html", + templateUrl: "./folders.component.html", imports: [ CommonModule, JslibModule, @@ -39,7 +39,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co AsyncActionsModule, ], }) -export class FoldersV2Component { +export class FoldersComponent { folders$: Observable; NoFoldersIcon = NoFolders; diff --git a/apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.html b/apps/browser/src/vault/popup/settings/more-from-bitwarden-page.component.html similarity index 100% rename from apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.html rename to apps/browser/src/vault/popup/settings/more-from-bitwarden-page.component.html diff --git a/apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.ts b/apps/browser/src/vault/popup/settings/more-from-bitwarden-page.component.ts similarity index 97% rename from apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.ts rename to apps/browser/src/vault/popup/settings/more-from-bitwarden-page.component.ts index 0b896547008..01537cbd62e 100644 --- a/apps/browser/src/vault/popup/settings/more-from-bitwarden-page-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/more-from-bitwarden-page.component.ts @@ -19,7 +19,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - templateUrl: "more-from-bitwarden-page-v2.component.html", + templateUrl: "more-from-bitwarden-page.component.html", imports: [ CommonModule, JslibModule, @@ -30,7 +30,7 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co ItemModule, ], }) -export class MoreFromBitwardenPageV2Component { +export class MoreFromBitwardenPageComponent { protected familySponsorshipAvailable$: Observable; protected isFreeFamilyPolicyEnabled$: Observable; protected hasSingleEnterpriseOrg$: Observable; diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html b/apps/browser/src/vault/popup/settings/vault-settings.component.html similarity index 100% rename from apps/browser/src/vault/popup/settings/vault-settings-v2.component.html rename to apps/browser/src/vault/popup/settings/vault-settings.component.html diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.spec.ts b/apps/browser/src/vault/popup/settings/vault-settings.component.spec.ts similarity index 93% rename from apps/browser/src/vault/popup/settings/vault-settings-v2.component.spec.ts rename to apps/browser/src/vault/popup/settings/vault-settings.component.spec.ts index 554570de7f9..a948b811fd4 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/settings/vault-settings.component.spec.ts @@ -19,7 +19,7 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; -import { VaultSettingsV2Component } from "./vault-settings-v2.component"; +import { VaultSettingsComponent } from "./vault-settings.component"; @Component({ selector: "popup-header", @@ -47,9 +47,9 @@ class MockPopOutComponent { readonly show = input(true); } -describe("VaultSettingsV2Component", () => { - let component: VaultSettingsV2Component; - let fixture: ComponentFixture; +describe("VaultSettingsComponent", () => { + let component: VaultSettingsComponent; + let fixture: ComponentFixture; let router: Router; let mockCipherArchiveService: jest.Mocked; @@ -90,11 +90,11 @@ describe("VaultSettingsV2Component", () => { mockCipherArchiveService.hasArchiveFlagEnabled$ = mockHasArchiveFlagEnabled$.asObservable(); await TestBed.configureTestingModule({ - imports: [VaultSettingsV2Component], + imports: [VaultSettingsComponent], providers: [ provideRouter([ - { path: "archive", component: VaultSettingsV2Component }, - { path: "premium", component: VaultSettingsV2Component }, + { path: "archive", component: VaultSettingsComponent }, + { path: "premium", component: VaultSettingsComponent }, ]), { provide: SyncService, useValue: mock() }, { provide: ToastService, useValue: mock() }, @@ -117,7 +117,7 @@ describe("VaultSettingsV2Component", () => { }, ], }) - .overrideComponent(VaultSettingsV2Component, { + .overrideComponent(VaultSettingsComponent, { remove: { imports: [PopupHeaderComponent, PopupPageComponent, PopOutComponent], }, @@ -127,7 +127,7 @@ describe("VaultSettingsV2Component", () => { }) .compileComponents(); - fixture = TestBed.createComponent(VaultSettingsV2Component); + fixture = TestBed.createComponent(VaultSettingsComponent); component = fixture.componentInstance; router = TestBed.inject(Router); jest.spyOn(router, "navigate"); diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts b/apps/browser/src/vault/popup/settings/vault-settings.component.ts similarity index 97% rename from apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts rename to apps/browser/src/vault/popup/settings/vault-settings.component.ts index c35345bd8ab..f79cef56155 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/vault-settings.component.ts @@ -23,7 +23,7 @@ import { BrowserPremiumUpgradePromptService } from "../services/browser-premium- // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - templateUrl: "vault-settings-v2.component.html", + templateUrl: "vault-settings.component.html", imports: [ CommonModule, JslibModule, @@ -39,7 +39,7 @@ import { BrowserPremiumUpgradePromptService } from "../services/browser-premium- { provide: PremiumUpgradePromptService, useClass: BrowserPremiumUpgradePromptService }, ], }) -export class VaultSettingsV2Component implements OnInit, OnDestroy { +export class VaultSettingsComponent implements OnInit, OnDestroy { private readonly premiumBadgeComponent = viewChild(PremiumBadgeComponent); lastSync = "--"; From 0f5163453e098e00cc2955922eba0dd86bdcf430 Mon Sep 17 00:00:00 2001 From: Brad <44413459+lastbestdev@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:08:20 -0800 Subject: [PATCH 05/42] [PM-30540] Access Intelligence action button updates (mark critical, assign tasks) (#18730) Updates the buttons available in the Access Intelligence "Applications" tab. - The "Mark as critical" button appears when at least 1 row is selected in the table, and if all selected applications are already marked critical, changes to a "Mark as not critical" button. This functionality allows Admins to either bulk mark critical applications, or bulk unmark critical applications. - "Assign tasks" has been moved into this tab view, and now is only enabled when there are critical ciphers found without assigned password change tasks. A tooltip appears when hovering on the disabled state, informing the Admin that all tasks have already been assigned. --- apps/web/src/locales/en/messages.json | 39 ++++++ .../risk-insights-orchestrator.service.ts | 13 +- .../view/risk-insights-data.service.ts | 4 +- .../access-intelligence.module.ts | 2 +- .../password-change-metric.component.ts | 58 ++------- .../applications.component.html | 71 ++++++++--- .../applications.component.spec.ts | 8 ++ .../applications.component.ts | 114 ++++++++++++++++-- .../critical-applications.component.ts | 2 +- .../shared/security-tasks.service.spec.ts | 7 +- .../shared/security-tasks.service.ts | 55 ++++++++- 11 files changed, 282 insertions(+), 91 deletions(-) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 1f4e1c98e32..763ce6634fe 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -268,6 +268,42 @@ } } }, + "numCriticalApplicationsMarkedSuccess": { + "message": "$COUNT$ applications marked critical", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "numApplicationsUnmarkedCriticalSuccess": { + "message": "$COUNT$ applications marked not critical", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "markAppCountAsCritical": { + "message": "Mark $COUNT$ as critical", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "markAppCountAsNotCritical": { + "message": "Mark $COUNT$ as not critical", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "applicationsMarkedAsCriticalFail": { "message": "Failed to mark applications as critical" }, @@ -10148,6 +10184,9 @@ "assignTasks": { "message": "Assign tasks" }, + "allTasksAssigned": { + "message": "All tasks have been assigned" + }, "assignSecurityTasksToMembers": { "message": "Send notifications to change passwords" }, diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts index 88af8081a8b..886ae4c5008 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts @@ -228,7 +228,7 @@ export class RiskInsightsOrchestratorService { * @param criticalApplication Application name of the critical application to remove * @returns */ - removeCriticalApplication$(criticalApplication: string): Observable { + removeCriticalApplications$(applicationsToUnmark: Set): Observable { this.logService.info( "[RiskInsightsOrchestratorService] Removing critical applications from report", ); @@ -245,11 +245,10 @@ export class RiskInsightsOrchestratorService { throwError(() => Error("Tried to update critical applications without a report")); } - // Create a set for quick lookup of the new critical apps const existingApplicationData = report!.applicationData || []; - const updatedApplicationData = this._removeCriticalApplication( + const updatedApplicationData = this._removeCriticalApplications( existingApplicationData, - criticalApplication, + applicationsToUnmark, ); // Updated summary data after changing critical apps @@ -917,12 +916,12 @@ export class RiskInsightsOrchestratorService { } // Toggles the isCritical flag on applications via criticalApplicationName - private _removeCriticalApplication( + private _removeCriticalApplications( applicationData: OrganizationReportApplication[], - criticalApplication: string, + applicationsToUnmark: Set, ): OrganizationReportApplication[] { const updatedApplicationData = applicationData.map((application) => { - if (application.applicationName == criticalApplication) { + if (applicationsToUnmark.has(application.applicationName)) { return { ...application, isCritical: false } as OrganizationReportApplication; } return application; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts index d426a6b09c1..8cf799250f2 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/risk-insights-data.service.ts @@ -263,8 +263,8 @@ export class RiskInsightsDataService { return this.orchestrator.saveCriticalApplications$(selectedUrls); } - removeCriticalApplication(hostname: string) { - return this.orchestrator.removeCriticalApplication$(hostname); + removeCriticalApplications(selectedUrls: Set) { + return this.orchestrator.removeCriticalApplications$(selectedUrls); } saveApplicationReviewStatus(selectedCriticalApps: OrganizationReportApplication[]) { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts index 5592e4cc546..555d6aa62e0 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts @@ -59,7 +59,7 @@ import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks. safeProvider({ provide: AccessIntelligenceSecurityTasksService, useClass: AccessIntelligenceSecurityTasksService, - deps: [DefaultAdminTaskService, SecurityTasksApiService], + deps: [DefaultAdminTaskService, SecurityTasksApiService, RiskInsightsDataService], }), safeProvider({ provide: PasswordHealthService, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts index df47adb4635..0487ae726e3 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts @@ -20,7 +20,7 @@ import { } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { SecurityTask, SecurityTaskStatus } from "@bitwarden/common/vault/tasks"; import { ButtonModule, @@ -57,10 +57,14 @@ export class PasswordChangeMetricComponent implements OnInit { // Signal states private readonly _tasks: Signal = signal([]); - private readonly _atRiskCipherIds: Signal = signal([]); private readonly _hasCriticalApplications: Signal = signal(false); - private readonly _reportGeneratedAt: Signal = signal( - undefined, + private readonly _unassignedCipherIds = toSignal( + this.securityTasksService.unassignedCriticalCipherIds$, + { initialValue: [] }, + ); + private readonly _atRiskCipherIds = toSignal( + this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$, + { initialValue: [] }, ); // Computed properties @@ -74,41 +78,11 @@ export class PasswordChangeMetricComponent implements OnInit { return total > 0 ? Math.round((this.completedTasksCount() / total) * 100) : 0; }); - readonly unassignedCipherIds = computed(() => { - const atRiskIds = this._atRiskCipherIds(); - const tasks = this._tasks(); + readonly unassignedCipherIds = computed(() => this._unassignedCipherIds().length); - if (tasks.length === 0) { - return atRiskIds.length; - } - - const inProgressTasks = tasks.filter((task) => task.status === SecurityTaskStatus.Pending); - const inProgressTaskIds = new Set(inProgressTasks.map((task) => task.cipherId)); - - const reportGeneratedAt = this._reportGeneratedAt(); - const completedTasksAfterReportGeneration = reportGeneratedAt - ? tasks.filter( - (task) => - task.status === SecurityTaskStatus.Completed && - new Date(task.revisionDate) >= reportGeneratedAt, - ) - : []; - const completedTaskIds = new Set( - completedTasksAfterReportGeneration.map((task) => task.cipherId), - ); - - // find cipher ids from last report that do not have a corresponding in progress task (awaiting password reset) OR completed task - const unassignedIds = atRiskIds.filter( - (id) => !inProgressTaskIds.has(id) && !completedTaskIds.has(id), - ); - - return unassignedIds.length; - }); - - readonly atRiskPasswordCount = computed(() => { + readonly atRiskPasswordCount = computed(() => { const atRiskIds = this._atRiskCipherIds(); const atRiskIdsSet = new Set(atRiskIds); - return atRiskIdsSet.size; }); @@ -119,7 +93,7 @@ export class PasswordChangeMetricComponent implements OnInit { if (this.tasksCount() === 0) { return PasswordChangeView.NO_TASKS_ASSIGNED; } - if (this.unassignedCipherIds() > 0) { + if (this._unassignedCipherIds().length > 0) { return PasswordChangeView.NEW_TASKS_AVAILABLE; } return PasswordChangeView.PROGRESS; @@ -133,10 +107,6 @@ export class PasswordChangeMetricComponent implements OnInit { private toastService: ToastService, ) { this._tasks = toSignal(this.securityTasksService.tasks$, { initialValue: [] }); - this._atRiskCipherIds = toSignal( - this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$, - { initialValue: [] }, - ); this._hasCriticalApplications = toSignal( this.riskInsightsDataService.criticalReportResults$.pipe( map((report) => { @@ -145,10 +115,6 @@ export class PasswordChangeMetricComponent implements OnInit { ), { initialValue: false }, ); - this._reportGeneratedAt = toSignal( - this.riskInsightsDataService.enrichedReportData$.pipe(map((report) => report?.creationDate)), - { initialValue: undefined }, - ); effect(() => { const isShowingProgress = this.currentView() === PasswordChangeView.PROGRESS; @@ -164,7 +130,7 @@ export class PasswordChangeMetricComponent implements OnInit { try { await this.securityTasksService.requestPasswordChangeForCriticalApplications( this.organizationId(), - this._atRiskCipherIds(), + this._unassignedCipherIds(), ); this.toastService.showToast({ message: this.i18nService.t("notifiedMembers"), diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html index efe07d50683..27864fa2f87 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html @@ -21,27 +21,58 @@ > - +
+ @if (selectedUrls().size > 0) { + @if (allSelectedAppsAreCritical()) { + + } @else { + + } + } - + + + +
{ let mockLogService: MockProxy; let mockToastService: MockProxy; let mockDataService: MockProxy; + let mockSecurityTasksService: MockProxy; const reportStatus$ = new BehaviorSubject(ReportStatus.Complete); const enrichedReportData$ = new BehaviorSubject(null); @@ -47,6 +49,7 @@ describe("ApplicationsComponent", () => { appAtRiskMembers: null, atRiskAppDetails: null, }); + const unassignedCriticalCipherIds$ = new BehaviorSubject([]); beforeEach(async () => { mockI18nService = mock(); @@ -54,6 +57,7 @@ describe("ApplicationsComponent", () => { mockLogService = mock(); mockToastService = mock(); mockDataService = mock(); + mockSecurityTasksService = mock(); mockI18nService.t.mockImplementation((key: string) => key); @@ -65,6 +69,9 @@ describe("ApplicationsComponent", () => { get: () => criticalReportResults$, }); Object.defineProperty(mockDataService, "drawerDetails$", { get: () => drawerDetails$ }); + Object.defineProperty(mockSecurityTasksService, "unassignedCriticalCipherIds$", { + get: () => unassignedCriticalCipherIds$, + }); await TestBed.configureTestingModule({ imports: [ApplicationsComponent, ReactiveFormsModule], @@ -78,6 +85,7 @@ describe("ApplicationsComponent", () => { provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: (): string | null => null } } }, }, + { provide: AccessIntelligenceSecurityTasksService, useValue: mockSecurityTasksService }, ], }).compileComponents(); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts index b5fae36bb2e..0020106ba7d 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts @@ -7,10 +7,10 @@ import { signal, computed, } from "@angular/core"; -import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop"; +import { takeUntilDestroyed, toObservable, toSignal } from "@angular/core/rxjs-interop"; import { FormControl, ReactiveFormsModule } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { combineLatest, debounceTime, startWith } from "rxjs"; +import { combineLatest, debounceTime, EMPTY, map, startWith, switchMap } from "rxjs"; import { Security } from "@bitwarden/assets/svg"; import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; @@ -22,6 +22,7 @@ import { import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { ButtonModule, IconButtonModule, @@ -30,8 +31,10 @@ import { SearchModule, TableDataSource, ToastService, + TooltipDirective, TypographyModule, ChipSelectComponent, + IconComponent, } from "@bitwarden/components"; import { ExportHelper } from "@bitwarden/vault-export-core"; import { exportToCSV } from "@bitwarden/web-vault/app/dirt/reports/report-utils"; @@ -42,6 +45,7 @@ import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pip import { AppTableRowScrollableM11Component } from "../shared/app-table-row-scrollable-m11.component"; import { ApplicationTableDataSource } from "../shared/app-table-row-scrollable.component"; import { ReportLoadingComponent } from "../shared/report-loading.component"; +import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service"; export const ApplicationFilterOption = { All: "all", @@ -70,6 +74,8 @@ export type ApplicationFilterOption = ButtonModule, ReactiveFormsModule, ChipSelectComponent, + IconComponent, + TooltipDirective, ], }) export class ApplicationsComponent implements OnInit { @@ -86,13 +92,14 @@ export class ApplicationsComponent implements OnInit { // Template driven properties protected readonly selectedUrls = signal(new Set()); - protected readonly markingAsCritical = signal(false); + protected readonly updatingCriticalApps = signal(false); protected readonly applicationSummary = signal(createNewSummaryData()); protected readonly criticalApplicationsCount = signal(0); protected readonly totalApplicationsCount = signal(0); protected readonly nonCriticalApplicationsCount = computed(() => { return this.totalApplicationsCount() - this.criticalApplicationsCount(); }); + protected readonly organizationId = signal(undefined); // filter related properties protected readonly selectedFilter = signal(ApplicationFilterOption.All); @@ -112,14 +119,46 @@ export class ApplicationsComponent implements OnInit { ]); protected readonly emptyTableExplanation = signal(""); + readonly allSelectedAppsAreCritical = computed(() => { + if (!this.dataSource.filteredData || this.selectedUrls().size == 0) { + return false; + } + + return this.dataSource.filteredData + .filter((row) => this.selectedUrls().has(row.applicationName)) + .every((row) => row.isMarkedAsCritical); + }); + + protected readonly unassignedCipherIds = toSignal( + this.securityTasksService.unassignedCriticalCipherIds$, + { initialValue: [] }, + ); + + readonly enableRequestPasswordChange = computed(() => this.unassignedCipherIds().length > 0); + constructor( protected i18nService: I18nService, protected activatedRoute: ActivatedRoute, protected toastService: ToastService, protected dataService: RiskInsightsDataService, + protected securityTasksService: AccessIntelligenceSecurityTasksService, ) {} async ngOnInit() { + this.activatedRoute.paramMap + .pipe( + takeUntilDestroyed(this.destroyRef), + map((params) => params.get("organizationId")), + switchMap(async (orgId) => { + if (orgId) { + this.organizationId.set(orgId as OrganizationId); + } else { + return EMPTY; + } + }), + ) + .subscribe(); + this.dataService.enrichedReportData$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ next: (report) => { if (report != null) { @@ -193,12 +232,8 @@ export class ApplicationsComponent implements OnInit { this.selectedFilter.set(value); } - isMarkedAsCriticalItem(applicationName: string) { - return this.selectedUrls().has(applicationName); - } - markAppsAsCritical = async () => { - this.markingAsCritical.set(true); + this.updatingCriticalApps.set(true); const count = this.selectedUrls().size; this.dataService @@ -209,10 +244,10 @@ export class ApplicationsComponent implements OnInit { this.toastService.showToast({ variant: "success", title: "", - message: this.i18nService.t("criticalApplicationsMarkedSuccess", count.toString()), + message: this.i18nService.t("numCriticalApplicationsMarkedSuccess", count), }); this.selectedUrls.set(new Set()); - this.markingAsCritical.set(false); + this.updatingCriticalApps.set(false); }, error: () => { this.toastService.showToast({ @@ -224,6 +259,65 @@ export class ApplicationsComponent implements OnInit { }); }; + unmarkAppsAsCritical = async () => { + this.updatingCriticalApps.set(true); + const appsToUnmark = this.selectedUrls(); + + this.dataService + .removeCriticalApplications(appsToUnmark) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.toastService.showToast({ + message: this.i18nService.t( + "numApplicationsUnmarkedCriticalSuccess", + appsToUnmark.size, + ), + variant: "success", + }); + this.selectedUrls.set(new Set()); + this.updatingCriticalApps.set(false); + }, + error: () => { + this.toastService.showToast({ + message: this.i18nService.t("unexpectedError"), + variant: "error", + title: this.i18nService.t("error"), + }); + }, + }); + }; + + async requestPasswordChange() { + const orgId = this.organizationId(); + if (!orgId) { + this.toastService.showToast({ + message: this.i18nService.t("unexpectedError"), + variant: "error", + title: this.i18nService.t("error"), + }); + return; + } + + try { + await this.securityTasksService.requestPasswordChangeForCriticalApplications( + orgId, + this.unassignedCipherIds(), + ); + this.toastService.showToast({ + message: this.i18nService.t("notifiedMembers"), + variant: "success", + title: this.i18nService.t("success"), + }); + } catch { + this.toastService.showToast({ + message: this.i18nService.t("unexpectedError"), + variant: "error", + title: this.i18nService.t("error"), + }); + } + } + showAppAtRiskMembers = async (applicationName: string) => { await this.dataService.setDrawerForAppAtRiskMembers(applicationName); }; diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts index 3033bf139c3..7180d50fe05 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.ts @@ -131,7 +131,7 @@ export class CriticalApplicationsComponent implements OnInit { removeCriticalApplication = async (hostname: string) => { this.dataService - .removeCriticalApplication(hostname) + .removeCriticalApplications(new Set([hostname])) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: () => { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts index f6fb41cdbb0..4ee784337de 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts @@ -1,7 +1,10 @@ import { TestBed } from "@angular/core/testing"; import { mock } from "jest-mock-extended"; -import { SecurityTasksApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { + RiskInsightsDataService, + SecurityTasksApiService, +} from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; import { SecurityTaskType } from "@bitwarden/common/vault/tasks"; @@ -13,12 +16,14 @@ describe("AccessIntelligenceSecurityTasksService", () => { let service: AccessIntelligenceSecurityTasksService; const defaultAdminTaskServiceMock = mock(); const securityTasksApiServiceMock = mock(); + const riskInsightsDataServiceMock = mock(); beforeEach(() => { TestBed.configureTestingModule({}); service = new AccessIntelligenceSecurityTasksService( defaultAdminTaskServiceMock, securityTasksApiServiceMock, + riskInsightsDataServiceMock, ); }); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts index 688ab039ca9..65a31896341 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts @@ -1,8 +1,10 @@ -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, combineLatest, Observable } from "rxjs"; +import { map, shareReplay } from "rxjs/operators"; +import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { SecurityTasksApiService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; -import { SecurityTask, SecurityTaskType } from "@bitwarden/common/vault/tasks"; +import { SecurityTask, SecurityTaskStatus, SecurityTaskType } from "@bitwarden/common/vault/tasks"; import { CreateTasksRequest } from "../../../vault/services/abstractions/admin-task.abstraction"; import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service"; @@ -14,10 +16,57 @@ export class AccessIntelligenceSecurityTasksService { private _tasksSubject$ = new BehaviorSubject([]); tasks$ = this._tasksSubject$.asObservable(); + /** + * Observable stream of unassigned critical cipher IDs. + * Returns cipher IDs from critical applications that don't have an associated task + * (either pending or completed after the report was generated). + */ + readonly unassignedCriticalCipherIds$: Observable; + constructor( private adminTaskService: DefaultAdminTaskService, private securityTasksApiService: SecurityTasksApiService, - ) {} + private riskInsightsDataService: RiskInsightsDataService, + ) { + this.unassignedCriticalCipherIds$ = combineLatest([ + this.tasks$, + this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$, + this.riskInsightsDataService.enrichedReportData$, + ]).pipe( + map(([tasks, atRiskCipherIds, reportData]) => { + // If no tasks exist, return all at-risk cipher IDs + if (tasks.length === 0) { + return atRiskCipherIds; + } + + // Get in-progress tasks (awaiting password reset) + const inProgressTasks = tasks.filter((task) => task.status === SecurityTaskStatus.Pending); + const inProgressTaskIds = new Set(inProgressTasks.map((task) => task.cipherId)); + + // Get completed tasks after report generation + const reportGeneratedAt = reportData?.creationDate; + const completedTasksAfterReportGeneration = reportGeneratedAt + ? tasks.filter( + (task) => + task.status === SecurityTaskStatus.Completed && + new Date(task.revisionDate) >= reportGeneratedAt, + ) + : []; + const completedTaskIds = new Set( + completedTasksAfterReportGeneration.map((task) => task.cipherId), + ); + + // Filter out cipher IDs that have a corresponding in-progress or completed task + return atRiskCipherIds.filter( + (id) => !inProgressTaskIds.has(id) && !completedTaskIds.has(id), + ); + }), + shareReplay({ + bufferSize: 1, + refCount: true, + }), + ); + } /** * Gets security task metrics for the given organization From 8f6cf67f8d2e1ac1e219cd0f7d175232ae0e3c69 Mon Sep 17 00:00:00 2001 From: Jackson Engstrom Date: Tue, 10 Feb 2026 15:32:36 -0800 Subject: [PATCH 06/42] [PM-29116] UI Text cut off on default size extension for Download Bitwarden (#18789) * updates settings buttons to wrap instead of truncate * adds new download copy --- apps/browser/src/_locales/en/messages.json | 4 + .../popup/settings/settings-v2.component.html | 75 +++++++++---------- 2 files changed, 41 insertions(+), 38 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index c6d9d325e00..940bb22c95c 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -6127,6 +6127,10 @@ "emailPlaceholder": { "message": "user@bitwarden.com , user@acme.com" }, + + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, "emailProtected": { "message": "Email protected" }, diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.html b/apps/browser/src/tools/popup/settings/settings-v2.component.html index 19f2445b61d..5aa962d5cc3 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.html +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.html @@ -1,19 +1,21 @@ - - {{ "unlockFeaturesWithPremium" | i18n }} - - - + @if (!(hasPremium$ | async)) { + + {{ "unlockFeaturesWithPremium" | i18n }} + + + + } @@ -23,14 +25,14 @@ - + {{ "accountSecurity" | i18n }} - +

{{ "autofill" | i18n }}

@@ -44,7 +46,7 @@
- + {{ "notifications" | i18n }} @@ -55,6 +57,7 @@ bit-item-content routerLink="/vault-settings" (click)="dismissBadge(NudgeType.EmptyVaultNudge)" + [truncate]="false" >
@@ -63,20 +66,18 @@ Currently can be only 1 item for notification. Will make this dynamic when more nudges are added --> - 1 + @if (showVaultBadge$ | async) { + 1 + }
- + {{ "appearance" | i18n }} @@ -85,7 +86,7 @@ @if (showAdminSettingsLink$ | async) { - +

{{ "admin" | i18n }}

@@ -101,30 +102,28 @@ } -
+ {{ "about" | i18n }} - +
-

{{ "downloadBitwardenOnAllDevices" | i18n }}

- 1 - +

{{ "downloadBitwardenApps" | i18n }}

+ @if (showDownloadBitwardenNudge$ | async) { + 1 + + }
- + {{ "moreFromBitwarden" | i18n }} From 7ccf1263a0da002841115d3854a51cd0d37ced0c Mon Sep 17 00:00:00 2001 From: Leslie Tilton <23057410+Banrion@users.noreply.github.com> Date: Tue, 10 Feb 2026 19:01:52 -0600 Subject: [PATCH 07/42] [PM-31939] Access Intelligence Documentation: Report Data Model Evolution (#18879) * Add report-data-model-evolution document * Change memberRefs to one record with flag for at risk or not * Update model evolution doc * Remove implementation section in favor of jira tracking * Remove todo comment * Add table of contents --- .../report-data-model-evolution.md | 807 ++++++++++++++++++ 1 file changed, 807 insertions(+) create mode 100644 bitwarden_license/bit-common/src/dirt/docs/access-intelligence/architecture/report-data-model-evolution.md diff --git a/bitwarden_license/bit-common/src/dirt/docs/access-intelligence/architecture/report-data-model-evolution.md b/bitwarden_license/bit-common/src/dirt/docs/access-intelligence/architecture/report-data-model-evolution.md new file mode 100644 index 00000000000..2e45d7fa9d8 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/docs/access-intelligence/architecture/report-data-model-evolution.md @@ -0,0 +1,807 @@ +# Report Data Model Evolution + +> **Purpose**: Document the old report data model (what's stored today), the updated model +> from PR #17356 (merged, follows BW architecture), and the target model with the member +> registry optimization. This is a reference for understanding why the report was 450MB+ +> and how the member registry solves it. + +--- + +## Table of Contents + +1. [Current Storage Model](#1-current-storage-model-still-in-use--plain-interfaces-no-architecture) +2. [Proposed View Models — Following BW Architecture](#2-proposed-view-models--following-bw-architecture-what-should-be-implemented-next) +3. [Target Model — With Member Registry](#3-target-model--with-member-registry-what-were-building) +4. [Storage Structure Comparison](#4-storage-structure-comparison) +5. [Encryption Approaches (Current vs Future Options)](#5-encryption-approaches-current-vs-future-options) + +--- + +## 1. Current Storage Model (Still In Use) — Plain interfaces, no architecture + +**Status:** This is what's stored in the database today. These are simple TypeScript interfaces/types with no domain/data/view/api layers. No encryption support in the types themselves. Services do all the filtering and transformation. + +**Note:** While PR #17356 introduced architecture patterns, the actual storage structure still uses these plain types directly. The proposed view models (Section 2) describe the architecture we should migrate to next. + +### ApplicationHealthReportDetail (the old report row) + +**Source:** `models/report-models.ts:78-88` (current implementation, still in use) + +**Current structure (with arrays):** + +```typescript +// This is the main report model — one record per application (grouped by URI hostname) +// Used directly by services and UI components +export type ApplicationHealthReportDetail = { + applicationName: string; // hostname (e.g. "google.com") + passwordCount: number; // total ciphers for this app + atRiskPasswordCount: number; // ciphers with weak/reused/exposed passwords + cipherIds: CipherId[]; // IDs of all ciphers in this app - ARRAY + atRiskCipherIds: CipherId[]; // IDs of at-risk ciphers - ARRAY (subset of cipherIds) + memberCount: number; // count of unique members (redundant, = memberDetails.length) + atRiskMemberCount: number; // count of at-risk members (redundant, = atRiskMemberDetails.length) + memberDetails: MemberDetails[]; // ⚠️ FULL member objects repeated per app - ARRAY + atRiskMemberDetails: MemberDetails[]; // ⚠️ FULL member objects for at-risk only (subset of memberDetails) - ARRAY + // Members are deduplicated within a single app but NOT across apps. +}; +``` + +**Proposed structure (with Records for consistency):** + +```typescript +export type ApplicationHealthReportDetail = { + applicationName: string; + passwordCount: number; + atRiskPasswordCount: number; + cipherRefs: Record; // true = at-risk, false = not at-risk (combines cipherIds + atRiskCipherIds) + memberCount: number; // could be removed (= Object.keys(memberRefs).length) + atRiskMemberCount: number; // could be removed (= count of true values in memberRefs) + memberRefs: Record; // true = at-risk, false = not at-risk (combines memberDetails + atRiskMemberDetails) +}; +``` + +**Benefits of Record pattern for ciphers:** + +- ✅ Combines `cipherIds` and `atRiskCipherIds` into single structure +- ✅ No duplicate IDs (prevents data inconsistency) +- ✅ O(1) lookup to check if cipher is at-risk +- ✅ Consistent with `memberRefs` pattern +- ✅ Saves space (~50 bytes per duplicate cipher ID in large orgs) + +### MemberDetails (the old member model) + +**Source:** `models/report-models.ts:16-21` (current implementation, still in use) + +```typescript +// Repeated in EVERY ApplicationHealthReportDetail that a member has access to +// For a large org with 5,000 members accessing 200 apps → duplicated across apps +export type MemberDetails = { + userGuid: string; // Organization user ID (UUID) + userName: string | null; // Display name + email: string; // Email address + cipherId: string; // ⚠️ Meaningless after deduplication (first cipher processed) +}; +``` + +### RiskInsightsData (the storage container) + +**Source:** `models/report-models.ts:121-128` (current implementation, still in use) + +**Current structure (with arrays):** + +**Rename to:** RiskInsights + +```typescript +// The top-level container that is stored in the database +// Each field is encrypted separately as an EncString +export interface RiskInsightsData { + id: OrganizationReportId; // Report ID (generated by API) + creationDate: Date; // When report was generated + contentEncryptionKey: EncString; // Key used to encrypt report data + reportData: ApplicationHealthReportDetail[]; // ⚠️ Main payload - can be 700MB+ + summaryData: OrganizationReportSummary; // Pre-computed aggregates (~1KB) + applicationData: OrganizationReportApplication[]; // Per-app settings (~10KB) - ARRAY with O(n) lookup +} +``` + +**Proposed structure (with Records for O(1) lookup):** + +```typescript +export interface RiskInsights { + id: OrganizationReportId; + creationDate: Date; + contentEncryptionKey: EncString; + reportData: ApplicationHealthReportDetail[]; // Array is still needed here for iteration + summaryData: OrganizationReportSummary; + applicationData: Record; // Record for O(1) lookup +} +``` + +**Current encryption:** Each of `reportData`, `summaryData`, and `applicationData` is JSON.stringify'd and encrypted as a separate EncString. For large orgs, `reportData` is compressed before encryption to avoid WASM size limits. + +### OrganizationReportApplication (per-app user settings) + +**Source:** `models/report-models.ts:64-72` (current implementation, still in use) + +**Current (Array):** Stored as array with O(n) lookup (inefficient) + +**Rename to:** RiskInsightsApplication (If separate model is needed) + +```typescript +// User-defined settings per application (critical flag, review date) +// Stored in the report, carried over between report generations +export type OrganizationReportApplication = { + applicationName: string; // hostname (e.g. "google.com") + isCritical: boolean; // user-defined critical flag + reviewedDate: Date | null; // null = new/unreviewed application +}; +``` + +**Proposed (Record):** Should be stored as Record for O(1) lookup + +```typescript +// Key = applicationName (hostname) +type ApplicationDataRecord = Record; + +// Example: +applicationData: { + "google.com": { isCritical: true, reviewedDate: new Date("2026-01-15") }, + "github.com": { isCritical: false, reviewedDate: null }, // new/unreviewed + "slack.com": { isCritical: true, reviewedDate: new Date("2026-02-01") } +} +``` + +**Problem with current array structure:** + +```typescript +// Current inefficient O(n) lookup pattern found in code: +getCriticalApplications(): RiskInsightsReportView[] { + return this.report.filter((app) => { + const appMeta = this.applications.find((a) => a.hostname === app.applicationName); // O(n)! + return appMeta?.isCritical === true; + }); +} +``` + +**With Record (O(1) lookup):** + +```typescript +getCriticalApplications(): RiskInsightsReportView[] { + return this.report.filter((app) => { + return this.applicationData[app.applicationName]?.isCritical === true; // O(1)! + }); +} +``` + +### OrganizationReportSummary (pre-computed aggregates) + +**Source:** `models/report-models.ts:49-58` (current implementation, still in use) + +**Rename to:** RiskInsightsSummary + +```typescript +// Pre-computed aggregates for summary cards and filtering +// Recomputed when critical application markings change +export type OrganizationReportSummary = { + totalMemberCount: number; // All members in org + totalApplicationCount: number; // All applications in report + totalAtRiskMemberCount: number; // Members with at-risk access + totalAtRiskApplicationCount: number; // Applications with at-risk ciphers + totalCriticalApplicationCount: number; // Applications marked critical + totalCriticalMemberCount: number; // Members with access to critical apps + totalCriticalAtRiskMemberCount: number; // Members with at-risk access to critical apps + totalCriticalAtRiskApplicationCount: number; // Critical apps with at-risk ciphers +}; +``` + +**Note:** When a user marks/unmarks an application as critical, the summary is recomputed. This is why `atRiskMemberDetails[]` is stored separately per application - it allows efficient recalculation of critical app summaries without reprocessing all cipher health data. + +### Why This Was 450MB+ + +The core problem: **`MemberDetails` objects were fully duplicated per application**. + +Example for a large org: + +- 5,000 org members +- 200 applications in the report +- Each member might have access to 50+ applications +- Each `MemberDetails` object ~200 bytes + +**Worst case**: 5,000 members × 50 apps × 200 bytes = ~50MB just for member data +in `memberDetails[]` arrays. With `atRiskMemberDetails[]` duplicated alongside, +plus cipher health data, this easily reached 450MB+. + +This caused: + +1. **WASM encryption panics** — the encrypted blob exceeded SDK size limits +2. **Database storage limits** — even compressed, the JSON was too large for DB fields +3. **Memory pressure** — holding this in a `BehaviorSubject` blocked the UI +4. **Slow report generation** — building all these duplicated member arrays was O(n²) + +--- + +## 2. Proposed View Models — Following BW Architecture (What Should Be Implemented Next) + +**Status:** PR #17356 laid groundwork for architecture patterns, but storage still uses plain types from Section 1. This section describes the view models that SHOULD be implemented to follow Bitwarden's 4-layer pattern: `Api → Data → Domain → View` + +**Important:** These models are NOT currently in use. They represent the target architecture we should migrate to, with query methods replacing facade/orchestrator filtering logic. + +### What's Stored (Current Implementation) + +The current implementation stores the **exact types from Section 1** above: + +- `ApplicationHealthReportDetail` - report rows (700MB+ for large orgs) - using ARRAYS +- `OrganizationReportApplication` - per-app settings (~10KB) - using ARRAY +- `OrganizationReportSummary` - aggregates (~1KB) + +These are stored in `RiskInsightsData` and encrypted as separate EncStrings: + +```typescript +// What gets stored in the database today (using arrays): +RiskInsightsData { + reportData: ApplicationHealthReportDetail[] // ← JSON.stringify → EncString + // Contains duplicate member objects across apps + // Contains duplicate cipher IDs (cipherIds + atRiskCipherIds) + summaryData: OrganizationReportSummary // ← JSON.stringify → EncString + applicationData: OrganizationReportApplication[] // ← JSON.stringify → EncString (array with O(n) lookup) + contentEncryptionKey: EncString + id: OrganizationReportId + creationDate: Date +} +``` + +**Encryption approach:** Each field is JSON.stringify'd, optionally compressed (for `reportData` only, to avoid WASM limits), then encrypted with the `contentEncryptionKey`. + +**Problems with current structure:** + +- Member objects duplicated across applications (576MB for 10K org) +- Cipher and member IDs duplicated in separate arrays (~70MB wasted) +- ApplicationData requires O(n) find operations for every lookup + +### Proposed View Models (For Query Logic) + +The new architecture will introduce domain/view models with query methods. These are **NOT stored** - they're runtime transformations of the stored data. + +#### RiskInsightsView (proposed - replaces facade logic) + +```typescript +class RiskInsightsView { + report: ApplicationHealthReportDetail[]; // Decrypted from storage + applications: OrganizationReportApplication[]; // Decrypted from storage + summary: OrganizationReportSummary; // Decrypted from storage + memberRegistry: MemberRegistry; // ← NEW: Built at load time + createdDate: Date; + + // Query methods (replace current facade/orchestrator filtering): + getAtRiskMembers(): MemberRegistryEntry[]; + getCriticalApplications(): ApplicationHealthReportDetail[]; + getApplicationByHostname(hostname: string): ApplicationHealthReportDetail | undefined; + getNewApplications(): ApplicationHealthReportDetail[]; // reviewedDate === null + getSummary(): OrganizationReportSummary; +} +``` + +**Note:** The view model will have query methods, but the underlying storage structure (Section 1) remains the same until we implement the member registry optimization (Section 3). + +--- + +## 3. Target Model — With Member Registry (What We're Building) + +**Key optimization:** Replace duplicated `MemberDetails[]` arrays with lightweight member ID references that point into a shared `MemberRegistry`. This reduces a 10K org report from ~786MB to ~173MB (78% reduction). + +**Storage changes:** + +- Store members ONCE in a registry (not per application) +- Store only member IDs (userGuids) in application records +- Remove meaningless `cipherId` field from member data +- Combine `memberDetails` and `atRiskMemberDetails` into single array with flag (OR keep separate arrays with IDs only) + +### MemberRegistry (new — deduplicated member lookup) + +```typescript +// Single source of truth for member data in a report +// Stored once, referenced by index from every application that member appears in +class MemberRegistry { + // Map from org user ID → full member entry + private entries: Map; + + get(id: OrganizationUserId): MemberRegistryEntry | undefined; + getAll(): MemberRegistryEntry[]; + size(): number; +} + +interface MemberRegistryEntry { + id: OrganizationUserId; + userName: string; + email: string; + // Any other member metadata needed by the UI +} +``` + +### Member References (new — Record with at-risk flag) + +Instead of duplicating full member objects per application, each application stores member IDs as a `Record`, where: + +- **Key** = member ID (userGuid) +- **Value** = `true` if at-risk, `false` if not at-risk + +This provides: + +- **O(1) lookup** for checking membership and at-risk status +- **Automatic deduplication** (can't have duplicate keys) +- **Single source** for both member list and at-risk status +- **No duplicate IDs** (previously stored in both memberDetails and atRiskMemberDetails) + +```typescript +// Stored as a Record where value indicates at-risk status +type MemberRefs = Record; + +// Example: +memberRefs: { + "abc-123": true, // at-risk member + "def-456": false, // not at-risk + "ghi-789": true // at-risk member +} +``` + +### Updated RiskInsightsReportView (with registry references) + +```typescript +class RiskInsightsReportView { + applicationName: string; + passwordCount: number; + atRiskPasswordCount: number; + weakPasswordCount: number; + reusedPasswordCount: number; + exposedPasswordCount: number; + + // OLD: memberDetails: MemberDetails[] + atRiskMemberDetails: MemberDetails[] (duplicated arrays) + // NEW: Single Record with at-risk flag + memberRefs: Record; // { "abc": true, "def": false, ... } + + // OLD: cipherIds: CipherId[] + atRiskCipherIds: CipherId[] (duplicated arrays) + // NEW: Single Record with at-risk flag + cipherRefs: Record; // { "cipher-1": true, "cipher-2": false, ... } + + // The registry is held by the parent RiskInsightsView + // View model methods resolve refs → full entries on demand: + + getAllMembers(registry: MemberRegistry): MemberRegistryEntry[] { + return Object.keys(this.memberRefs) + .map((id) => registry.get(id as OrganizationUserId)) + .filter(Boolean); + } + + getAtRiskMembers(registry: MemberRegistry): MemberRegistryEntry[] { + return Object.entries(this.memberRefs) + .filter(([_, isAtRisk]) => isAtRisk) + .map(([id]) => registry.get(id as OrganizationUserId)) + .filter(Boolean); + } + + isAtRisk(): boolean { + return this.atRiskPasswordCount > 0; + } + + hasMember(memberId: OrganizationUserId): boolean { + return memberId in this.memberRefs; // O(1) lookup + } + + isMemberAtRisk(memberId: OrganizationUserId): boolean { + return this.memberRefs[memberId] === true; // O(1) lookup + } +} +``` + +### Updated RiskInsightsView (parent, holds registry) + +```typescript +class RiskInsightsView { + report: RiskInsightsReportView[]; + applications: Record; + summary: RiskInsightsSummaryView; + memberRegistry: MemberRegistry; // ← shared, deduplicated + createdDate: Date; + + // Smart query methods — these replace facade/orchestrator filtering logic: + + getAtRiskMembers(): MemberRegistryEntry[] { + // Deduplicate across all at-risk apps + const ids = new Set(); + for (const app of this.report) { + if (app.isAtRisk()) { + // memberRefs is a Record, iterate entries and filter for at-risk (value === true) + Object.entries(app.memberRefs).forEach(([id, isAtRisk]) => { + if (isAtRisk) ids.add(id as OrganizationUserId); + }); + } + } + return [...ids].map((id) => this.memberRegistry.get(id)).filter(Boolean); + } + + getCriticalApplications(): RiskInsightsReportView[] { + // OLD (O(n)): this.applications.find((a) => a.hostname === app.applicationName) + // NEW (O(1)): this.applicationData[app.applicationName] + return this.report.filter((app) => { + return this.applicationData[app.applicationName]?.isCritical === true; + }); + } + + getApplicationByHostname(hostname: string): RiskInsightsReportView | undefined { + return this.report.find((app) => app.applicationName === hostname); + } + + getNewApplications(): RiskInsightsReportView[] { + // OLD (O(n)): this.applications.find((a) => a.hostname === app.applicationName) + // NEW (O(1)): this.applicationData[app.applicationName] + return this.report.filter((app) => { + return this.applicationData[app.applicationName]?.reviewedDate === null; + }); + } + + getSummary(): RiskInsightsSummaryView { + return this.summary; + } +} +``` + +### Size Impact: Current vs Target + +#### Current (700MB+ for large orgs) + +**10K member org:** + +- `memberDetails`: 400 apps × 5,000 members × 180 bytes = **360MB** +- `atRiskMemberDetails`: 400 apps × 3,000 members × 180 bytes = **216MB** +- Cipher IDs + metadata: **~15MB** +- **Total unencrypted: ~591MB** +- **After encryption + Base64: ~786MB** + +#### Target (With Registry + Record Pattern) + +**10K member org:** + +- **MemberRegistry**: 10,000 members × 140 bytes (no cipherId) = **1.4MB** (stored once) +- **memberRefs**: 400 apps × 5,000 refs × 50 bytes (Record entry: `"id": false/true`) = **100MB** + - No separate atRiskMemberRefs needed - at-risk status is the boolean value +- **cipherRefs**: 400 apps × 100 ciphers × 50 bytes (Record entry: `"id": false/true`) = **2MB** + - No separate atRiskCipherIds array needed - at-risk status is the boolean value +- **applicationData** (as Record): 400 apps × 100 bytes = **0.04MB** (negligible) +- Metadata (counts, applicationName): **~10MB** +- **Total unencrypted: ~113MB** +- **After encryption + Base64: ~150MB** + +**Reduction: 786MB → 150MB = 81% smaller** 🎉 + +**Design Decision:** Use single `Record` for members, ciphers, AND Record for applicationData: + +- **memberRefs:** No duplicate member IDs, ~60MB saved (vs separate atRiskMemberDetails) +- **cipherRefs:** No duplicate cipher IDs, ~10MB saved (vs separate atRiskCipherIds array) +- **applicationData:** O(1) lookup, no functional size change but better performance +- **Trade-off:** Definitely worth it - saves ~70MB and prevents duplicate storage + +--- + +## 4. Storage Structure Comparison + +### Current Storage (What's in DB Today) + +```typescript +// Stored as RiskInsightsData in database +{ + id: OrganizationReportId, + creationDate: Date, + contentEncryptionKey: EncString, + + // ENCRYPTED FIELD 1: reportData (~700MB for large orgs) + reportData: [ + { + applicationName: "google.com", + cipherIds: ["cipher-id-1", "cipher-id-2", ...], // ~100 ciphers - ARRAY + atRiskCipherIds: ["cipher-id-1", ...], // ~50 at-risk - ARRAY (duplicates IDs from cipherIds) + memberDetails: [ // ~5,000 members - ARRAY + { userGuid: "abc", userName: "Alice", email: "alice@...", cipherId: "x" }, + { userGuid: "def", userName: "Bob", email: "bob@...", cipherId: "y" }, + // ... FULL member objects, deduplicated per app, duplicated across apps + ], + atRiskMemberDetails: [ // ~3,000 at-risk members - ARRAY (duplicates from memberDetails) + { userGuid: "abc", userName: "Alice", email: "alice@...", cipherId: "x" }, + // ... FULL member objects (subset of memberDetails) + ], + passwordCount: 100, + atRiskPasswordCount: 50, + memberCount: 5000, + atRiskMemberCount: 3000 + }, + // ... 400 applications + ], + + // ENCRYPTED FIELD 2: applicationData (~10KB) - ARRAY with O(n) lookup + applicationData: [ + { applicationName: "google.com", isCritical: true, reviewedDate: Date | null }, + // ... 400 applications + ], + + // ENCRYPTED FIELD 3: summaryData (~1KB) + summaryData: { + totalMemberCount: 10000, + totalApplicationCount: 400, + totalAtRiskMemberCount: 6000, + totalAtRiskApplicationCount: 300, + totalCriticalApplicationCount: 50, + totalCriticalMemberCount: 8000, + totalCriticalAtRiskMemberCount: 4500, + totalCriticalAtRiskApplicationCount: 40 + } +} +``` + +**Total size:** ~786MB encrypted for 10K member org +**Problem:** Member data duplicated across applications (360MB + 216MB = 576MB just for members) + +--- + +### Target Storage (With Member Registry) + +```typescript +// Stored as RiskInsightsData in database +{ + id: OrganizationReportId, + creationDate: Date, + contentEncryptionKey: EncString, + + // NEW: ENCRYPTED FIELD 0: memberRegistry (~1.4MB for 10K members) + memberRegistry: { + "abc": { userGuid: "abc", userName: "Alice", email: "alice@..." }, + "def": { userGuid: "def", userName: "Bob", email: "bob@..." }, + // ... 10,000 members stored ONCE + }, + + // ENCRYPTED FIELD 1: reportData (~116MB for 10K org - 80% reduction!) + reportData: [ + { + applicationName: "google.com", + cipherRefs: { // ~100 cipher IDs with at-risk flag - RECORD + "cipher-id-1": true, // at-risk + "cipher-id-2": false, // not at-risk + "cipher-id-3": true, // at-risk + // ... (no separate atRiskCipherIds array needed) + }, + memberRefs: { // ~5,000 member IDs with at-risk flag - RECORD + "abc": true, // at-risk member + "def": false, // not at-risk + "ghi": true, // at-risk member + // ... (no separate atRiskMemberRefs array needed) + }, + passwordCount: 100, + atRiskPasswordCount: 50, + memberCount: 5000, + atRiskMemberCount: 3000 + }, + // ... 400 applications + ], + + // ENCRYPTED FIELD 2: applicationData (~10KB) - RECORD with O(1) lookup + applicationData: { + "google.com": { isCritical: true, reviewedDate: new Date("2026-01-15") }, + "github.com": { isCritical: false, reviewedDate: null }, + "slack.com": { isCritical: true, reviewedDate: new Date("2026-02-01") } + // ... 400 applications as Record entries + }, + + // ENCRYPTED FIELD 3: summaryData (~1KB - unchanged) + summaryData: { + totalMemberCount: 10000, + totalApplicationCount: 400, + totalAtRiskMemberCount: 6000, + totalAtRiskApplicationCount: 300, + totalCriticalApplicationCount: 50, + totalCriticalMemberCount: 8000, + totalCriticalAtRiskMemberCount: 4500, + totalCriticalAtRiskApplicationCount: 40 + } +} +``` + +**Total size:** ~150MB encrypted for 10K member org (81% reduction) +**Benefits:** + +- Members stored once in registry, referenced by ID from applications +- Member and cipher IDs stored with at-risk flag (no duplicate arrays) +- ApplicationData as Record enables O(1) lookup instead of O(n) find operations + +--- + +### Design Decision: Single Record with Boolean Flag (for Members AND Ciphers) + +**Chosen approach:** Use single `Record` where the boolean indicates at-risk status for BOTH members and ciphers. + +```typescript +{ + applicationName: "google.com", + memberRefs: { + "abc": true, // at-risk member + "def": false, // not at-risk + "ghi": true // at-risk member + }, + cipherRefs: { + "cipher-1": true, // at-risk cipher + "cipher-2": false, // not at-risk + "cipher-3": true // at-risk cipher + } +} +``` + +**Pros:** + +- ✅ **Members:** No duplicate IDs (previously stored in both memberDetails AND atRiskMemberDetails) +- ✅ **Ciphers:** No duplicate IDs (previously stored in both cipherIds AND atRiskCipherIds) +- ✅ O(1) lookup for both membership/presence and at-risk status +- ✅ Automatic deduplication (can't have duplicate keys) +- ✅ Saves ~60MB for members + ~10MB for ciphers = **~70MB saved** compared to separate arrays +- ✅ Clear intent - one ID, one entry, one flag +- ✅ Consistent pattern across both members and ciphers + +**Cons:** + +- ⚠️ Slightly more complex iteration (need to check boolean value when filtering at-risk) +- ⚠️ Summary recalculation requires iterating entries instead of just counting keys + +**Trade-off Analysis:** + +- **Member size savings:** ~60MB (400 apps × 3K at-risk IDs × 50 bytes per duplicate entry) +- **Cipher size savings:** ~10MB (400 apps × 50 at-risk IDs × 50 bytes per duplicate entry) +- **Total savings:** ~70MB for 10K org +- **Performance:** Negligible - `Object.entries().filter()` is still O(n) like array iteration +- **Correctness:** Better - impossible to have ID in at-risk array but not in main array + +**Verdict:** Single Record with boolean flag is the clear winner for both members AND ciphers. + +--- + +## 5. Encryption Approaches (Current vs Future Options) + +### Current: Encrypt Whole Objects (No Compression) + +**How it works:** + +1. `JSON.stringify(reportData)` → encrypt → EncString (~700MB for large orgs) +2. `JSON.stringify(summaryData)` → encrypt → EncString (~1KB) +3. `JSON.stringify(applicationData)` → encrypt → EncString (~10KB) + +**Stored structure:** + +```typescript +{ + reportData: EncString, // ← Entire reportData[] array as one encrypted blob + summaryData: EncString, // ← Entire summary object as one encrypted blob + applicationData: EncString // ← Entire applicationData[] array as one encrypted blob + contentEncryptionKey: EncString, + id: OrganizationReportId, + creationDate: Date +} +``` + +**Problems:** + +- For large orgs (700MB+), may approach or exceed WASM encryption limits +- Must decrypt entire report to access any application +- Can't do field-level encryption with this approach + +--- + +### Option 1: Encrypt Per Top-Level Field (Current Approach) + +Encrypt `reportData`, `summaryData`, `applicationData` as separate EncStrings. + +**Pros:** + +- ✅ Allows decrypting summary without decrypting full report +- ✅ Simple encryption logic +- ✅ Separates metadata (summary, applicationData) from payload (reportData) + +**Cons:** + +- ❌ Can't access individual applications without decrypting entire report +- ❌ Can't do field-level encryption +- ❌ May hit WASM limits for very large orgs (700MB+ unencrypted) + +**Status:** This is what we have today. + +--- + +### Option 2: True Field-Level Encryption (Ideal) + +**Each field** within each object is encrypted separately, preserving JSON structure: + +```typescript +{ + memberRegistry: { + "abc": { + userGuid: EncString("abc"), + userName: EncString("Alice"), + email: EncString("alice@...") + }, + "def": { /* ... */ } + }, + reportData: [ + { + applicationName: EncString("google.com"), + cipherIds: [EncString("id1"), EncString("id2"), ...], + memberRefs: [EncString("abc"), EncString("def"), ...], + atRiskMemberRefs: [EncString("abc"), ...], + passwordCount: EncString("100"), + atRiskPasswordCount: EncString("50"), + memberCount: EncString("5000"), + atRiskMemberCount: EncString("3000") + }, + // ... each application + ], + summaryData: { + totalMemberCount: EncString("10000"), + totalApplicationCount: EncString("400"), + // ... each field encrypted + }, + applicationData: [ + { + applicationName: EncString("google.com"), + isCritical: EncString("true"), + reviewedDate: EncString("2026-02-10") + }, + // ... each application + ] +} +``` + +**Pros:** + +- ✅ Can decrypt individual fields on-demand +- ✅ Can access single application without decrypting all +- ✅ Each field is small enough for SDK (no size limits) +- ✅ Better for partial updates (re-encrypt only changed fields) +- ✅ Aligns with Bitwarden's data model architecture + +**Cons:** + +- ❌ More complex encryption/decryption logic +- ❌ Slightly larger overhead (each EncString has IV + metadata ~20 bytes) +- ❌ Requires updating all encryption/decryption code paths + +**Status:** **Can be implemented alongside member registry.** The member registry will reduce report size (making this easier), but field-level encryption is not blocked by it. + +**Size estimate with field-level encryption:** + +- Member registry (10K members): ~1.4MB unencrypted → ~2MB encrypted (each field encrypted) +- Report data: ~116MB unencrypted → ~145MB encrypted (overhead from EncString metadata) +- **Total: ~147MB** (vs ~154MB with whole-object encryption) + +Field-level encryption adds ~10MB overhead but enables partial decryption and avoids WASM limits. + +--- + +### Option 3: Compress Then Encrypt (Draft PR, Want to Avoid) + +Compress `reportData` before encrypting. `summaryData` and `applicationData` remain uncompressed (TBD if `applicationData` needs compression). + +**How it would work:** + +1. `JSON.stringify(reportData)` → compress with pako → encrypt → EncString +2. `JSON.stringify(summaryData)` → encrypt → EncString (no compression) +3. `JSON.stringify(applicationData)` → encrypt → EncString (compression TBD) + +**Pros:** + +- ✅ Stored object is as small as we can get it (compression reduces size by ~70%) +- ✅ Works for very large orgs without hitting WASM limits + +**Cons:** + +- ❌ Can't decrypt summary without decompressing everything if whole object is compressed +- ❌ Makes field-level encryption impossible (can't decrypt individual fields from compressed blob) +- ❌ More complex decryption logic (decompress → decrypt) +- ❌ Not the direction we want to go architecturally + +**Decision:** **Avoid if possible.** This was explored in a draft PR as a workaround, but we'd prefer to implement member registry (reduces size without compression) and move toward field-level encryption (Option 2). From 9b5f3a866dfd3f1aa0ed1b8d93de533ef252fa9f Mon Sep 17 00:00:00 2001 From: Zhaolin Liang Date: Wed, 11 Feb 2026 16:49:57 +0800 Subject: [PATCH 08/42] Fix lock vault from system tray not working (#18323) Co-authored-by: Bernd Schoolmann --- apps/desktop/src/app/app.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 01eb8c728e5..fdd5012f5ee 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -273,7 +273,7 @@ export class AppComponent implements OnInit, OnDestroy { this.loading = false; break; case "lockVault": - await this.lockService.lock(message.userId); + await this.lockService.lock(message.userId ?? this.activeUserId); break; case "lockAllVaults": { await this.lockService.lockAll(); From d237994aae0e50d90bfddd5cd5b4c3b9ce898e6b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:24:06 +0100 Subject: [PATCH 09/42] [deps] Platform: Update webpack to v5.104.1 [SECURITY] (#18797) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 29 ++++++++++++++++++----------- package.json | 2 +- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8f6a527627f..bf1b1798699 100644 --- a/package-lock.json +++ b/package-lock.json @@ -179,7 +179,7 @@ "url": "0.11.4", "util": "0.12.5", "wait-on": "9.0.3", - "webpack": "5.103.0", + "webpack": "5.104.1", "webpack-cli": "6.0.1", "webpack-dev-server": "5.2.2", "webpack-node-externals": "3.0.0" @@ -40910,9 +40910,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -43334,9 +43334,9 @@ } }, "node_modules/webpack": { - "version": "5.103.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.103.0.tgz", - "integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==", + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", + "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", "dependencies": { @@ -43348,10 +43348,10 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.26.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.17.4", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", @@ -43362,7 +43362,7 @@ "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.11", + "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, @@ -44154,6 +44154,13 @@ "dev": true, "license": "MIT" }, + "node_modules/webpack/node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", diff --git a/package.json b/package.json index 751c67afcd1..f6cd3dd28c8 100644 --- a/package.json +++ b/package.json @@ -146,7 +146,7 @@ "url": "0.11.4", "util": "0.12.5", "wait-on": "9.0.3", - "webpack": "5.103.0", + "webpack": "5.104.1", "webpack-cli": "6.0.1", "webpack-dev-server": "5.2.2", "webpack-node-externals": "3.0.0" From e2710ee14aa6aec02cb00c0138dc38d017ac70ea Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 11 Feb 2026 15:34:24 +0100 Subject: [PATCH 10/42] [No ticket] Disable process reload on desktop during dev builds (#18905) * Disable process reload on desktop during dev builds * Fix linting --- apps/desktop/src/main/window.main.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index b2008d57bcd..2872154aa44 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -55,6 +55,11 @@ export class WindowMain { // Perform a hard reload of the render process by crashing it. This is suboptimal but ensures that all memory gets // cleared, as the process itself will be completely garbage collected. ipcMain.on("reload-process", async () => { + if (isDev()) { + this.logService.info("Process reload requested, but skipping in development mode"); + return; + } + this.logService.info("Reloading render process"); // User might have changed theme, ensure the window is updated. this.win.setBackgroundColor(await this.getBackgroundColor()); From 952996099ad087e68cd66c65be8cab7560488e1b Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 11 Feb 2026 15:36:40 +0100 Subject: [PATCH 11/42] Remove duplicate import of VaultComponent (#18904) --- .../src/vault/popup/components/vault/vault.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/browser/src/vault/popup/components/vault/vault.component.spec.ts b/apps/browser/src/vault/popup/components/vault/vault.component.spec.ts index 55cb18ba637..70affd73ef3 100644 --- a/apps/browser/src/vault/popup/components/vault/vault.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault/vault.component.spec.ts @@ -53,7 +53,7 @@ import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injec import { NewItemDropdownComponent } from "./new-item-dropdown/new-item-dropdown.component"; import { VaultHeaderComponent } from "./vault-header/vault-header.component"; import { VaultListItemsContainerComponent } from "./vault-list-items-container/vault-list-items-container.component"; -import { VaultComponent, VaultComponent } from "./vault.component"; +import { VaultComponent } from "./vault.component"; @Component({ selector: "popup-header", From 3c9569a90f491e702b4a01534d79ab168e76aaf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Wed, 11 Feb 2026 15:45:14 +0100 Subject: [PATCH 12/42] Downgrade open to 8.4.2 (#18459) --- apps/cli/package.json | 2 +- package-lock.json | 248 ++++++++---------------------------------- package.json | 2 +- 3 files changed, 47 insertions(+), 205 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 79653ec970f..40058bed16e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -83,7 +83,7 @@ "multer": "2.0.2", "node-fetch": "2.7.0", "node-forge": "1.3.2", - "open": "11.0.0", + "open": "8.4.2", "papaparse": "5.5.3", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", diff --git a/package-lock.json b/package-lock.json index bf1b1798699..680aad40adf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,7 @@ "node-fetch": "2.7.0", "node-forge": "1.3.2", "oidc-client-ts": "2.4.1", - "open": "11.0.0", + "open": "8.4.2", "papaparse": "5.5.3", "proper-lockfile": "4.1.2", "qrcode-parser": "2.1.3", @@ -218,7 +218,7 @@ "multer": "2.0.2", "node-fetch": "2.7.0", "node-forge": "1.3.2", - "open": "11.0.0", + "open": "8.4.2", "papaparse": "5.5.3", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", @@ -230,51 +230,6 @@ "bw": "build/bw.js" } }, - "apps/cli/node_modules/define-lazy-prop": { - "version": "2.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "apps/cli/node_modules/is-docker": { - "version": "2.2.1", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "apps/cli/node_modules/is-wsl": { - "version": "2.2.0", - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "apps/cli/node_modules/open": { - "version": "8.4.2", - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "apps/desktop": { "name": "@bitwarden/desktop", "version": "2026.2.0", @@ -18920,63 +18875,6 @@ "node": ">=12.0.0" } }, - "node_modules/better-opn/node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/better-opn/node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/better-opn/node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/better-opn/node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -19384,6 +19282,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, "license": "MIT", "dependencies": { "run-applescript": "^7.0.0" @@ -21648,6 +21547,7 @@ "version": "5.4.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", + "dev": true, "license": "MIT", "dependencies": { "bundle-name": "^4.1.0", @@ -21664,6 +21564,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -21732,6 +21633,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -26848,6 +26750,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, "license": "MIT", "bin": { "is-docker": "cli.js" @@ -26936,22 +26839,11 @@ "node": ">=0.10.0" } }, - "node_modules/is-in-ssh": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", - "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, "license": "MIT", "dependencies": { "is-docker": "^3.0.0" @@ -27281,6 +27173,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, "license": "MIT", "dependencies": { "is-inside-container": "^1.0.0" @@ -33968,16 +33861,6 @@ } } }, - "node_modules/nx/node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/nx/node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -34001,35 +33884,6 @@ "node": ">= 4" } }, - "node_modules/nx/node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/nx/node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/nx/node_modules/jsonc-parser": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", @@ -34037,24 +33891,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nx/node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/nx/node_modules/ora": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/ora/-/ora-5.3.0.tgz", @@ -34629,41 +34465,58 @@ } }, "node_modules/open": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", - "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", "license": "MIT", "dependencies": { - "default-browser": "^5.4.0", - "define-lazy-prop": "^3.0.0", - "is-in-ssh": "^1.0.0", - "is-inside-container": "^1.0.0", - "powershell-utils": "^0.1.0", - "wsl-utils": "^0.3.0" + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" }, "engines": { - "node": ">=20" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/open/node_modules/wsl-utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.0.tgz", - "integrity": "sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==", + "node_modules/open/node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0", - "powershell-utils": "^0.1.0" + "engines": { + "node": ">=8" + } + }, + "node_modules/open/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" }, "engines": { - "node": ">=20" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/opencollective-postinstall": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", @@ -36653,18 +36506,6 @@ "node": "^12.20.0 || >=14" } }, - "node_modules/powershell-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", - "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -38066,6 +37907,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index f6cd3dd28c8..c95d6af7437 100644 --- a/package.json +++ b/package.json @@ -193,7 +193,7 @@ "node-fetch": "2.7.0", "node-forge": "1.3.2", "oidc-client-ts": "2.4.1", - "open": "11.0.0", + "open": "8.4.2", "papaparse": "5.5.3", "proper-lockfile": "4.1.2", "qrcode-parser": "2.1.3", From 428a96902c77581b5338400527b4845694266877 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Wed, 11 Feb 2026 09:46:21 -0500 Subject: [PATCH 13/42] [PM-31679] remove archive from browser edit (#18854) * removing archive btns from browser edit form footer, remove archive items from showing in expired premium users vault --- .../vault/add-edit/add-edit.component.html | 18 --- .../vault/add-edit/add-edit.component.spec.ts | 105 ------------------ .../vault/add-edit/add-edit.component.ts | 46 -------- .../vault-popup-items.service.spec.ts | 2 +- .../services/vault-popup-items.service.ts | 17 ++- 5 files changed, 9 insertions(+), 179 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.html b/apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.html index f8238a188e0..d4495cf4c92 100644 --- a/apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.html +++ b/apps/browser/src/vault/popup/components/vault/add-edit/add-edit.component.html @@ -41,24 +41,6 @@ - @if (isEditMode) { - @if ((archiveFlagEnabled$ | async) && isCipherArchived) { - - } - @if ((userCanArchive$ | async) && canCipherBeArchived) { - - } - } @if (canDeleteCipher$ | async) { + @if (!attachment.hasDecryptionError) { + + + + {{ attachment.fileName }} + + {{ attachment.sizeName }} + @if (attachment.key == null) { + } - - @if (cipher().edit) { + + + - + @if (attachment.key != null) { + + } @else { + + } - } - - + @if (cipher().edit) { + + + + } + + + } @else { + + + + {{ "errorCannotDecrypt" | i18n }} + + + + + @if (cipher().edit) { + + + + } + + + } } diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts index 002ad019653..88ee1f9b599 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts @@ -173,7 +173,7 @@ describe("CipherAttachmentsComponent", () => { const fileSize = fixture.debugElement.query(By.css('[data-testid="file-size"]')); expect(fileName.nativeElement.textContent.trim()).toEqual(attachment.fileName); - expect(fileSize.nativeElement.textContent).toEqual(attachment.sizeName); + expect(fileSize.nativeElement.textContent.trim()).toEqual(attachment.sizeName); }); describe("bitSubmit", () => { diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html b/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html index 0a46b83b086..c8110b9e863 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html +++ b/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html @@ -5,8 +5,12 @@ - {{ attachment.fileName }} - {{ attachment.sizeName }} + + {{ getAttachmentFileName(attachment) }} + + @if (!attachment.hasDecryptionError) { + {{ attachment.sizeName }} + } diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.ts b/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.ts index 4e324d8002e..3826d3a3ad0 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.ts +++ b/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.ts @@ -8,9 +8,11 @@ import { NEVER, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { EmergencyAccessId, OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; +import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { ItemModule, @@ -59,6 +61,7 @@ export class AttachmentsV2ViewComponent { private billingAccountProfileStateService: BillingAccountProfileStateService, private stateProvider: StateProvider, private accountService: AccountService, + private i18nService: I18nService, ) { this.subscribeToHasPremiumCheck(); this.subscribeToOrgKey(); @@ -89,4 +92,12 @@ export class AttachmentsV2ViewComponent { } }); } + + getAttachmentFileName(attachment: AttachmentView): string { + if (attachment.hasDecryptionError) { + return this.i18nService.t("errorCannotDecrypt"); + } + + return attachment.fileName ?? ""; + } } diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts b/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts index a46ce28fca8..05d5e9bc276 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts +++ b/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts @@ -36,12 +36,11 @@ describe("DownloadAttachmentComponent", () => { .mockResolvedValue({ url: "https://www.downloadattachement.com" }); const download = jest.fn(); - const attachment = { - id: "222-3333-4444", - url: "https://www.attachment.com", - fileName: "attachment-filename", - size: "1234", - } as AttachmentView; + const attachment = new AttachmentView(); + attachment.id = "222-3333-4444"; + attachment.url = "https://www.attachment.com"; + attachment.fileName = "attachment-filename"; + attachment.size = "1234"; const cipherView = { id: "5555-444-3333", @@ -123,7 +122,12 @@ describe("DownloadAttachmentComponent", () => { }); it("hides download button when the attachment has decryption failure", () => { - const decryptFailureAttachment = { ...attachment, fileName: DECRYPT_ERROR }; + const decryptFailureAttachment = new AttachmentView(); + decryptFailureAttachment.id = attachment.id; + decryptFailureAttachment.url = attachment.url; + decryptFailureAttachment.size = attachment.size; + decryptFailureAttachment.fileName = DECRYPT_ERROR; + fixture.componentRef.setInput("attachment", decryptFailureAttachment); fixture.detectChanges(); diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.ts b/libs/vault/src/components/download-attachment/download-attachment.component.ts index 31ed609637c..bdca510c5aa 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.ts +++ b/libs/vault/src/components/download-attachment/download-attachment.component.ts @@ -4,7 +4,6 @@ import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { DECRYPT_ERROR } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -46,9 +45,7 @@ export class DownloadAttachmentComponent { private cipherService: CipherService, ) {} - protected readonly isDecryptionFailure = computed( - () => this.attachment().fileName === DECRYPT_ERROR, - ); + protected readonly isDecryptionFailure = computed(() => this.attachment().hasDecryptionError); /** Download the attachment */ download = async () => { From a9ccb421c441d79ca69e348c248144a6215299b3 Mon Sep 17 00:00:00 2001 From: Brad <44413459+lastbestdev@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:00:14 -0800 Subject: [PATCH 20/42] [PM-30542] Conditionally render old Access Intelligence tabs, sub heading copy update (#18847) First step of removing code for old Access Intelligence tabs. The old tabs should not appear when the milestone 11 feature flag is on. Once flipped in Production, the remainder of this ticket can be completed (old code entirely removed) Also included in this change is a copy update for the sub heading of the page. --- apps/web/src/locales/en/messages.json | 4 +-- .../risk-insights.component.html | 30 ++++++++++--------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index b86d88b42ad..59f5bc88419 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -47,8 +47,8 @@ "noEditPermissions": { "message": "You don't have permission to edit this item" }, - "reviewAtRiskPasswords": { - "message": "Review at-risk passwords (weak, exposed, or reused) across applications. Select your most critical applications to prioritize security actions for your users to address at-risk passwords." + "reviewAccessIntelligence": { + "message": "Review security reports to find and fix credential risks before they escalate." }, "reviewAtRiskLoginsPrompt": { "message": "Review at-risk logins" diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index 1e58d334288..169c5d920ff 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html @@ -46,7 +46,7 @@
@if (appsCount > 0) {
- {{ "reviewAtRiskPasswords" | i18n }} + {{ "reviewAccessIntelligence" | i18n }}
}
+ } @else { + + + + + + + {{ + "criticalApplicationsWithCount" + | i18n + : (dataService.criticalReportResults$ | async)?.reportData?.length ?? 0 + }} + + + } - - - - - - - {{ - "criticalApplicationsWithCount" - | i18n: (dataService.criticalReportResults$ | async)?.reportData?.length ?? 0 - }} - - -
From ea55aaaede0b917e6e582613503a3b5fbd278496 Mon Sep 17 00:00:00 2001 From: Leslie Xiong Date: Wed, 11 Feb 2026 12:21:33 -0500 Subject: [PATCH 21/42] [CL-1022] Update Berry Styles (#18799) * created 'berry' component * added 'bit-berry' to 'popup-tab-navigation' * simplified - removed null checks * changed 'effectiveSize' to 'computedSize' * fixed 'accentPrimary' color * updated to not render berry if 'count' is 0 or negative number * simplified checking count undefined * updated computed padding * switched from `[ngClass]` to `[class]` * updated 'popup-tab-navigation' berry to use 'danger' variant * fixed berry positioning in popup-tab-navigation * updated content logic * cleanup unused 'ngClass' * updated conditional rendering of berry * updated story 'Usage' * updates with adding berry 'type' * added type "status" to popup-tab-navigation * fixed type error * updated 'Count Behavior' description --- .../popup-tab-navigation.component.html | 4 +- .../layout/popup-tab-navigation.component.ts | 4 +- .../components/src/berry/berry.component.html | 3 + libs/components/src/berry/berry.component.ts | 80 +++++++++ libs/components/src/berry/berry.mdx | 48 +++++ libs/components/src/berry/berry.stories.ts | 167 ++++++++++++++++++ libs/components/src/berry/index.ts | 1 + libs/components/src/index.ts | 1 + libs/components/tailwind.config.base.js | 1 + 9 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 libs/components/src/berry/berry.component.html create mode 100644 libs/components/src/berry/berry.component.ts create mode 100644 libs/components/src/berry/berry.mdx create mode 100644 libs/components/src/berry/berry.stories.ts create mode 100644 libs/components/src/berry/index.ts diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html index e04d302ea2c..919d01f2d51 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html @@ -27,8 +27,8 @@ {{ button.label | i18n }} -
-
+
+
diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts index 5a40b72daff..f873d25641b 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts @@ -5,7 +5,7 @@ import { RouterModule } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { BitSvg } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SvgModule, LinkModule } from "@bitwarden/components"; +import { SvgModule, LinkModule, BerryComponent } from "@bitwarden/components"; export type NavButton = { label: string; @@ -20,7 +20,7 @@ export type NavButton = { @Component({ selector: "popup-tab-navigation", templateUrl: "popup-tab-navigation.component.html", - imports: [CommonModule, LinkModule, RouterModule, JslibModule, SvgModule], + imports: [CommonModule, LinkModule, RouterModule, JslibModule, SvgModule, BerryComponent], host: { class: "tw-block tw-size-full tw-flex tw-flex-col", }, diff --git a/libs/components/src/berry/berry.component.html b/libs/components/src/berry/berry.component.html new file mode 100644 index 00000000000..2a05f534843 --- /dev/null +++ b/libs/components/src/berry/berry.component.html @@ -0,0 +1,3 @@ +@if (type() === "status" || content()) { + {{ content() }} +} diff --git a/libs/components/src/berry/berry.component.ts b/libs/components/src/berry/berry.component.ts new file mode 100644 index 00000000000..8e58b888f39 --- /dev/null +++ b/libs/components/src/berry/berry.component.ts @@ -0,0 +1,80 @@ +import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core"; + +export type BerryVariant = + | "primary" + | "subtle" + | "success" + | "warning" + | "danger" + | "accentPrimary" + | "contrast"; + +/** + * The berry component is a compact visual indicator used to display short, + * supplemental status information about another element, + * like a navigation item, button, or icon button. + * They draw users’ attention to status changes or new notifications. + * + * > `NOTE:` The maximum displayed value is 999. If the value is over 999, a “+” character is appended to indicate more. + */ +@Component({ + selector: "bit-berry", + templateUrl: "berry.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BerryComponent { + protected readonly variant = input("primary"); + protected readonly value = input(); + protected readonly type = input<"status" | "count">("count"); + + protected readonly content = computed(() => { + const value = this.value(); + const type = this.type(); + + if (type === "status" || !value || value < 0) { + return undefined; + } + return value > 999 ? "999+" : `${value}`; + }); + + protected readonly textColor = computed(() => { + return this.variant() === "contrast" ? "tw-text-fg-dark" : "tw-text-fg-white"; + }); + + protected readonly padding = computed(() => { + return (this.value()?.toString().length ?? 0) > 2 ? "tw-px-1.5 tw-py-0.5" : ""; + }); + + protected readonly containerClasses = computed(() => { + const baseClasses = [ + "tw-inline-flex", + "tw-items-center", + "tw-justify-center", + "tw-align-middle", + "tw-text-xxs", + "tw-rounded-full", + ]; + + const typeClasses = { + status: ["tw-h-2", "tw-w-2"], + count: ["tw-h-4", "tw-min-w-4", this.padding()], + }; + + const variantClass = { + primary: "tw-bg-bg-brand", + subtle: "tw-bg-bg-contrast", + success: "tw-bg-bg-success", + warning: "tw-bg-bg-warning", + danger: "tw-bg-bg-danger", + accentPrimary: "tw-bg-fg-accent-primary-strong", + contrast: "tw-bg-bg-white", + }; + + return [ + ...baseClasses, + ...typeClasses[this.type()], + variantClass[this.variant()], + this.textColor(), + ].join(" "); + }); +} diff --git a/libs/components/src/berry/berry.mdx b/libs/components/src/berry/berry.mdx new file mode 100644 index 00000000000..b79ed35cac8 --- /dev/null +++ b/libs/components/src/berry/berry.mdx @@ -0,0 +1,48 @@ +import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs/blocks"; + +import * as stories from "./berry.stories"; + + + +```ts +import { BerryComponent } from "@bitwarden/components"; +``` + + +<Description /> + +<Primary /> +<Controls /> + +## Usage + +### Status + +- Use a status berry to indicate a new notification of a status change that is not related to a + specific count. + +<Canvas of={stories.statusType} /> + +### Count + +- Use a count berry with text to indicate item count information for multiple new notifications. + +<Canvas of={stories.countType} /> + +### All Variants + +<Canvas of={stories.AllVariants} /> + +## Count Behavior + +- Counts of **1-99**: Display in a compact circular shape +- Counts of **100-999**: Display in a pill shape with padding +- Counts **over 999**: Display as "999+" to prevent overflow + +## Accessibility + +- Use berries as **supplemental visual indicators** alongside descriptive text +- Ensure sufficient color contrast with surrounding elements +- For screen readers, provide appropriate labels on parent elements that describe the berry's + meaning +- Berries are decorative; important information should not rely solely on the berry color diff --git a/libs/components/src/berry/berry.stories.ts b/libs/components/src/berry/berry.stories.ts new file mode 100644 index 00000000000..0b71e7259d8 --- /dev/null +++ b/libs/components/src/berry/berry.stories.ts @@ -0,0 +1,167 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { BerryComponent } from "./berry.component"; + +export default { + title: "Component Library/Berry", + component: BerryComponent, + decorators: [ + moduleMetadata({ + imports: [BerryComponent], + }), + ], + args: { + type: "count", + variant: "primary", + value: 5, + }, + argTypes: { + type: { + control: "select", + options: ["status", "count"], + description: "The type of the berry, which determines its size and content", + table: { + category: "Inputs", + type: { summary: '"status" | "count"' }, + defaultValue: { summary: '"count"' }, + }, + }, + variant: { + control: "select", + options: ["primary", "subtle", "success", "warning", "danger", "accentPrimary", "contrast"], + description: "The visual style variant of the berry", + table: { + category: "Inputs", + type: { summary: "BerryVariant" }, + defaultValue: { summary: "primary" }, + }, + }, + value: { + control: "number", + description: + "Optional value to display for berries with type 'count'. Maximum displayed is 999, values above show '999+'. If undefined, a small small berry is shown. If 0 or negative, the berry is hidden.", + table: { + category: "Inputs", + type: { summary: "number | undefined" }, + defaultValue: { summary: "undefined" }, + }, + }, + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/branch/rKUVGKb7Kw3d6YGoQl6Ho7/Tailwind-Component-Library?node-id=38367-199458&p=f&m=dev", + }, + }, +} as Meta<BerryComponent>; + +type Story = StoryObj<BerryComponent>; + +export const Primary: Story = { + render: (args) => ({ + props: args, + template: `<bit-berry [type]="type" [variant]="variant" [value]="value"></bit-berry>`, + }), +}; + +export const statusType: Story = { + render: (args) => ({ + props: args, + template: ` + <div class="tw-flex tw-items-center tw-gap-4"> + <bit-berry [type]="'status'" variant="primary"></bit-berry> + <bit-berry [type]="'status'" variant="subtle"></bit-berry> + <bit-berry [type]="'status'" variant="success"></bit-berry> + <bit-berry [type]="'status'" variant="warning"></bit-berry> + <bit-berry [type]="'status'" variant="danger"></bit-berry> + <bit-berry [type]="'status'" variant="accentPrimary"></bit-berry> + <bit-berry [type]="'status'" variant="contrast"></bit-berry> + </div> + `, + }), +}; + +export const countType: Story = { + render: (args) => ({ + props: args, + template: ` + <div class="tw-flex tw-items-center tw-gap-4"> + <bit-berry [value]="5"></bit-berry> + <bit-berry [value]="50"></bit-berry> + <bit-berry [value]="500"></bit-berry> + <bit-berry [value]="5000"></bit-berry> + </div> + `, + }), +}; + +export const AllVariants: Story = { + render: () => ({ + template: ` + <div class="tw-flex tw-flex-col tw-gap-4"> + <div class="tw-flex tw-items-center tw-gap-4"> + <span class="tw-w-20">Primary:</span> + <bit-berry type="status" variant="primary"></bit-berry> + <bit-berry variant="primary" [value]="5"></bit-berry> + <bit-berry variant="primary" [value]="50"></bit-berry> + <bit-berry variant="primary" [value]="500"></bit-berry> + <bit-berry variant="primary" [value]="5000"></bit-berry> + </div> + + <div class="tw-flex tw-items-center tw-gap-4"> + <span class="tw-w-20">Subtle:</span> + <bit-berry type="status"variant="subtle"></bit-berry> + <bit-berry variant="subtle" [value]="5"></bit-berry> + <bit-berry variant="subtle" [value]="50"></bit-berry> + <bit-berry variant="subtle" [value]="500"></bit-berry> + <bit-berry variant="subtle" [value]="5000"></bit-berry> + </div> + + <div class="tw-flex tw-items-center tw-gap-4"> + <span class="tw-w-20">Success:</span> + <bit-berry type="status" variant="success"></bit-berry> + <bit-berry variant="success" [value]="5"></bit-berry> + <bit-berry variant="success" [value]="50"></bit-berry> + <bit-berry variant="success" [value]="500"></bit-berry> + <bit-berry variant="success" [value]="5000"></bit-berry> + </div> + + <div class="tw-flex tw-items-center tw-gap-4"> + <span class="tw-w-20">Warning:</span> + <bit-berry type="status" variant="warning"></bit-berry> + <bit-berry variant="warning" [value]="5"></bit-berry> + <bit-berry variant="warning" [value]="50"></bit-berry> + <bit-berry variant="warning" [value]="500"></bit-berry> + <bit-berry variant="warning" [value]="5000"></bit-berry> + </div> + + <div class="tw-flex tw-items-center tw-gap-4"> + <span class="tw-w-20">Danger:</span> + <bit-berry type="status" variant="danger"></bit-berry> + <bit-berry variant="danger" [value]="5"></bit-berry> + <bit-berry variant="danger" [value]="50"></bit-berry> + <bit-berry variant="danger" [value]="500"></bit-berry> + <bit-berry variant="danger" [value]="5000"></bit-berry> + </div> + + <div class="tw-flex tw-items-center tw-gap-4"> + <span class="tw-w-20">Accent primary:</span> + <bit-berry type="status" variant="accentPrimary"></bit-berry> + <bit-berry variant="accentPrimary" [value]="5"></bit-berry> + <bit-berry variant="accentPrimary" [value]="50"></bit-berry> + <bit-berry variant="accentPrimary" [value]="500"></bit-berry> + <bit-berry variant="accentPrimary" [value]="5000"></bit-berry> + </div> + + <div class="tw-flex tw-items-center tw-gap-4 tw-bg-bg-dark"> + <span class="tw-w-20 tw-text-fg-white">Contrast:</span> + <bit-berry type="status" variant="contrast"></bit-berry> + <bit-berry variant="contrast" [value]="5"></bit-berry> + <bit-berry variant="contrast" [value]="50"></bit-berry> + <bit-berry variant="contrast" [value]="500"></bit-berry> + <bit-berry variant="contrast" [value]="5000"></bit-berry> + </div> + </div> + `, + }), +}; diff --git a/libs/components/src/berry/index.ts b/libs/components/src/berry/index.ts new file mode 100644 index 00000000000..8f85908653e --- /dev/null +++ b/libs/components/src/berry/index.ts @@ -0,0 +1 @@ +export * from "./berry.component"; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index d92e0770e49..d0bb8576095 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -7,6 +7,7 @@ export * from "./avatar"; export * from "./badge-list"; export * from "./badge"; export * from "./banner"; +export * from "./berry"; export * from "./breadcrumbs"; export * from "./button"; export * from "./callout"; diff --git a/libs/components/tailwind.config.base.js b/libs/components/tailwind.config.base.js index d8220c39ff8..5de00fac34f 100644 --- a/libs/components/tailwind.config.base.js +++ b/libs/components/tailwind.config.base.js @@ -317,6 +317,7 @@ module.exports = { base: ["1rem", "150%"], sm: ["0.875rem", "150%"], xs: [".75rem", "150%"], + xxs: [".5rem", "150%"], }, container: { "@5xl": "1100px", From bd3f8dd4c13717b771620a1f30b420c973518a57 Mon Sep 17 00:00:00 2001 From: Daniel Riera <driera@livefront.com> Date: Wed, 11 Feb 2026 12:24:02 -0500 Subject: [PATCH 22/42] [PM-29519]Remove @ts-strict-ignore in browser-fido2-user-interface.service.ts (#18691) --- .../fido2/services/browser-fido2-user-interface.service.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts b/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts index e0ab45e9f84..19c1dbc8790 100644 --- a/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts +++ b/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { BehaviorSubject, EmptyError, @@ -79,7 +77,7 @@ export type BrowserFido2Message = { sessionId: string } & ( } | { type: typeof BrowserFido2MessageTypes.PickCredentialResponse; - cipherId?: string; + cipherId: string; userVerified: boolean; } | { From 32a22aa8cfbd6ffc1ef2a10cbb5102ef41ee6fce Mon Sep 17 00:00:00 2001 From: Brad <44413459+lastbestdev@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:40:39 -0800 Subject: [PATCH 23/42] [PM-32060] Access Intelligence: Disable select all checkbox when table is empty (#18914) --- .../shared/app-table-row-scrollable-m11.component.html | 1 + 1 file changed, 1 insertion(+) diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html index 29da8a7a818..05dec048328 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html @@ -10,6 +10,7 @@ [bitTooltip]="allAppsSelected() ? ('deselectAll' | i18n) : ('selectAll' | i18n)" (change)="selectAllChanged($event.target)" [attr.aria-label]="allAppsSelected() ? ('deselectAll' | i18n) : ('selectAll' | i18n)" + [disabled]="dataSource().filteredData?.length === 0" /> </th> <th bitCell></th> From 4b7e3eae41a421c48e2266f921d1179ed5e0df22 Mon Sep 17 00:00:00 2001 From: Bryan Cunningham <bcunningham@bitwarden.com> Date: Wed, 11 Feb 2026 12:58:59 -0500 Subject: [PATCH 24/42] show underline on focus (#18916) --- libs/components/src/link/link.component.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/libs/components/src/link/link.component.ts b/libs/components/src/link/link.component.ts index d826a4633a9..79cf55da637 100644 --- a/libs/components/src/link/link.component.ts +++ b/libs/components/src/link/link.component.ts @@ -58,13 +58,12 @@ const commonStyles = [ "[&.tw-test-hover_span]:tw-underline", "[&:hover_span]:tw-decoration-[.125em]", "[&.tw-test-hover_span]:tw-decoration-[.125em]", - "disabled:tw-no-underline", - "disabled:tw-cursor-not-allowed", - "disabled:!tw-text-fg-disabled", - "disabled:hover:!tw-text-fg-disabled", - "disabled:hover:tw-no-underline", "focus-visible:tw-outline-none", "focus-visible:before:tw-ring-border-focus", + "[&:focus-visible_span]:tw-underline", + "[&:focus-visible_span]:tw-decoration-[.125em]", + "[&.tw-test-focus-visible_span]:tw-underline", + "[&.tw-test-focus-visible_span]:tw-decoration-[.125em]", // Workaround for html button tag not being able to be set to `display: inline` // and at the same time not being able to use `tw-ring-offset` because of box-shadow issue. @@ -93,6 +92,7 @@ const commonStyles = [ "aria-disabled:!tw-text-fg-disabled", "aria-disabled:hover:!tw-text-fg-disabled", "aria-disabled:hover:tw-no-underline", + "[&[aria-disabled]:focus-visible_span]:!tw-no-underline", ]; @Component({ From 975c8fb6f81435b0ac047d997d945ca4f2326b0f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:05:48 -0500 Subject: [PATCH 25/42] [deps] Autofill: Update tldts to v7.0.22 (#18881) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/cli/package.json | 2 +- package-lock.json | 18 +++++++++--------- package.json | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 40058bed16e..6c27267054f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -88,7 +88,7 @@ "proper-lockfile": "4.1.2", "rxjs": "7.8.1", "semver": "7.7.3", - "tldts": "7.0.19", + "tldts": "7.0.22", "zxcvbn": "4.4.2" } } diff --git a/package-lock.json b/package-lock.json index 680aad40adf..dbdcd6d083d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "rxjs": "7.8.1", "semver": "7.7.3", "tabbable": "6.3.0", - "tldts": "7.0.19", + "tldts": "7.0.22", "ts-node": "10.9.2", "utf-8-validate": "6.0.5", "vite-tsconfig-paths": "5.1.4", @@ -223,7 +223,7 @@ "proper-lockfile": "4.1.2", "rxjs": "7.8.1", "semver": "7.7.3", - "tldts": "7.0.19", + "tldts": "7.0.22", "zxcvbn": "4.4.2" }, "bin": { @@ -41006,21 +41006,21 @@ } }, "node_modules/tldts": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", - "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "version": "7.0.22", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.22.tgz", + "integrity": "sha512-nqpKFC53CgopKPjT6Wfb6tpIcZXHcI6G37hesvikhx0EmUGPkZrujRyAjgnmp1SHNgpQfKVanZ+KfpANFt2Hxw==", "license": "MIT", "dependencies": { - "tldts-core": "^7.0.19" + "tldts-core": "^7.0.22" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", - "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", "license": "MIT" }, "node_modules/tmp": { diff --git a/package.json b/package.json index c95d6af7437..bc1553c4622 100644 --- a/package.json +++ b/package.json @@ -201,7 +201,7 @@ "rxjs": "7.8.1", "semver": "7.7.3", "tabbable": "6.3.0", - "tldts": "7.0.19", + "tldts": "7.0.22", "ts-node": "10.9.2", "utf-8-validate": "6.0.5", "vite-tsconfig-paths": "5.1.4", From b2f8fd67ef8411d5ea4085896c92fef4a87d5553 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik <jprusik@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:53:26 -0500 Subject: [PATCH 26/42] consolidate excluded domains copy to allow removal of service invocation (#18610) --- apps/browser/src/_locales/en/messages.json | 3 --- .../autofill/popup/settings/excluded-domains.component.html | 6 +----- .../autofill/popup/settings/excluded-domains.component.ts | 6 +----- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 5768d336115..7944904c44a 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2747,9 +2747,6 @@ "excludedDomainsDesc": { "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." }, - "excludedDomainsDescAlt": { - "message": "Bitwarden will not ask to save login details for these domains for all logged in accounts. You must refresh the page for changes to take effect." - }, "blockedDomainsDesc": { "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." }, diff --git a/apps/browser/src/autofill/popup/settings/excluded-domains.component.html b/apps/browser/src/autofill/popup/settings/excluded-domains.component.html index 30170820a27..74ff0de6f5c 100644 --- a/apps/browser/src/autofill/popup/settings/excluded-domains.component.html +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.html @@ -7,11 +7,7 @@ <div class="tw-bg-background-alt"> <p> - {{ - (accountSwitcherEnabled$ | async) - ? ("excludedDomainsDescAlt" | i18n) - : ("excludedDomainsDesc" | i18n) - }} + {{ "excludedDomainsDesc" | i18n }} </p> <bit-section *ngIf="!isLoading"> <bit-section-header> diff --git a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts index 6714f749d2d..2316aef390e 100644 --- a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts @@ -15,7 +15,7 @@ import { FormArray, } from "@angular/forms"; import { RouterModule } from "@angular/router"; -import { Observable, Subject, takeUntil } from "rxjs"; +import { Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; @@ -35,7 +35,6 @@ import { TypographyModule, } from "@bitwarden/components"; -import { AccountSwitcherService } from "../../../auth/popup/account-switching/services/account-switcher.service"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; @@ -74,8 +73,6 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { @ViewChildren("uriInput") uriInputElements: QueryList<ElementRef<HTMLInputElement>> = new QueryList(); - readonly accountSwitcherEnabled$: Observable<boolean> = - this.accountSwitcherService.accountSwitchingEnabled$(); dataIsPristine = true; isLoading = false; excludedDomainsState: string[] = []; @@ -96,7 +93,6 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { private toastService: ToastService, private formBuilder: FormBuilder, private popupRouterCacheService: PopupRouterCacheService, - private accountSwitcherService: AccountSwitcherService, ) {} get domainForms() { From f8976f992a968b33e4b82aad9c84d97e18ebe7ba Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:37:20 -0700 Subject: [PATCH 27/42] [PM-31611] [Defect] After entering an email, the Anyone with the link option cannot be selected anymore (#18844) * add authType to to sendDetailsForm valueChanges --- .../send-form/components/send-details/send-details.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts index 46eded5e86d..ac1453a925c 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts @@ -199,6 +199,7 @@ export class SendDetailsComponent implements OnInit { deletionDate: new Date(this.formattedDeletionDate), expirationDate: new Date(this.formattedDeletionDate), password: value.password, + authType: value.authType, emails: value.emails ? value.emails .split(",") From d7cca1bedf2c87f94ae3b178728af1aa45b2c07b Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:44:49 -0700 Subject: [PATCH 28/42] [PM-23108] CLI Add Email Verification to Send Receive (#18649) --- .../service-container/service-container.ts | 13 + .../send/commands/receive.command.spec.ts | 560 ++++++++++++++++++ .../tools/send/commands/receive.command.ts | 418 +++++++++++-- apps/cli/src/tools/send/send.program.ts | 2 + 4 files changed, 943 insertions(+), 50 deletions(-) create mode 100644 apps/cli/src/tools/send/commands/receive.command.spec.ts diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 2033a2dd064..b5a2b1b8196 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -39,6 +39,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { MasterPasswordApiService as MasterPasswordApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; +import { SendTokenService, DefaultSendTokenService } from "@bitwarden/common/auth/send-access"; import { AccountServiceImplementation, getUserId, @@ -91,6 +92,8 @@ import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin. import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service"; +import { SendPasswordService } from "@bitwarden/common/key-management/sends/abstractions/send-password.service"; +import { DefaultSendPasswordService } from "@bitwarden/common/key-management/sends/services/default-send-password.service"; import { DefaultVaultTimeoutService, DefaultVaultTimeoutSettingsService, @@ -306,6 +309,8 @@ export class ServiceContainer { userVerificationApiService: UserVerificationApiService; organizationApiService: OrganizationApiServiceAbstraction; sendApiService: SendApiService; + sendTokenService: SendTokenService; + sendPasswordService: SendPasswordService; devicesApiService: DevicesApiServiceAbstraction; deviceTrustService: DeviceTrustServiceAbstraction; authRequestService: AuthRequestService; @@ -629,6 +634,8 @@ export class ServiceContainer { this.sendService, ); + this.sendPasswordService = new DefaultSendPasswordService(this.cryptoFunctionService); + this.searchService = new SearchService(this.logService, this.i18nService, this.stateProvider); this.collectionService = new DefaultCollectionService( @@ -675,6 +682,12 @@ export class ServiceContainer { customUserAgent, ); + this.sendTokenService = new DefaultSendTokenService( + this.globalStateProvider, + this.sdkService, + this.sendPasswordService, + ); + this.keyConnectorService = new KeyConnectorService( this.accountService, this.masterPasswordService, diff --git a/apps/cli/src/tools/send/commands/receive.command.spec.ts b/apps/cli/src/tools/send/commands/receive.command.spec.ts new file mode 100644 index 00000000000..fe982905059 --- /dev/null +++ b/apps/cli/src/tools/send/commands/receive.command.spec.ts @@ -0,0 +1,560 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { SendTokenService, SendAccessToken } from "@bitwarden/common/auth/send-access"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { KeyService } from "@bitwarden/key-management"; + +import { Response } from "../../../models/response"; + +import { SendReceiveCommand } from "./receive.command"; + +describe("SendReceiveCommand", () => { + let command: SendReceiveCommand; + + const keyService = mock<KeyService>(); + const encryptService = mock<EncryptService>(); + const cryptoFunctionService = mock<CryptoFunctionService>(); + const platformUtilsService = mock<PlatformUtilsService>(); + const environmentService = mock<EnvironmentService>(); + const sendApiService = mock<SendApiService>(); + const apiService = mock<ApiService>(); + const sendTokenService = mock<SendTokenService>(); + const configService = mock<ConfigService>(); + + const testUrl = "https://send.bitwarden.com/#/send/abc123/key456"; + const testSendId = "abc123"; + + beforeEach(() => { + jest.clearAllMocks(); + + environmentService.environment$ = of({ + getUrls: () => ({ + api: "https://api.bitwarden.com", + webVault: "https://vault.bitwarden.com", + }), + } as any); + + platformUtilsService.isDev.mockReturnValue(false); + + keyService.makeSendKey.mockResolvedValue({} as any); + + cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32)); + + command = new SendReceiveCommand( + keyService, + encryptService, + cryptoFunctionService, + platformUtilsService, + environmentService, + sendApiService, + apiService, + sendTokenService, + configService, + ); + }); + + describe("URL parsing", () => { + it("should return error for invalid URL", async () => { + const response = await command.run("not-a-valid-url", {}); + + expect(response.success).toBe(false); + expect(response.message).toContain("Failed to parse"); + }); + + it("should return error when URL is missing send ID or key", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + const response = await command.run("https://send.bitwarden.com/#/send/", {}); + + expect(response.success).toBe(false); + expect(response.message).toContain("not a valid Send url"); + }); + }); + + describe("V1 Flow (Feature Flag Off)", () => { + beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue(false); + }); + + it("should successfully access unprotected Send", async () => { + const mockSendAccess = { + id: testSendId, + type: SendType.Text, + text: { text: "secret message" }, + }; + + sendApiService.postSendAccess.mockResolvedValue({} as any); + + jest.spyOn(command as any, "sendRequest").mockResolvedValue(mockSendAccess); + + const response = await command.run(testUrl, {}); + + expect(response.success).toBe(true); + }); + + it("should successfully access password-protected Send with --password option", async () => { + const mockSendAccess = { + id: testSendId, + type: SendType.Text, + text: { text: "secret message" }, + }; + + sendApiService.postSendAccess.mockResolvedValue({} as any); + jest.spyOn(command as any, "sendRequest").mockResolvedValue(mockSendAccess); + + const response = await command.run(testUrl, { password: "test-password" }); + + expect(response.success).toBe(true); + expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledWith( + "test-password", + expect.any(Uint8Array), + "sha256", + 100000, + ); + }); + + it("should return error for incorrect password in non-interactive mode", async () => { + process.env.BW_NOINTERACTION = "true"; + + const error = new ErrorResponse( + { + statusCode: 401, + message: "Unauthorized", + }, + 401, + ); + + sendApiService.postSendAccess.mockRejectedValue(error); + + const response = await command.run(testUrl, { password: "wrong-password" }); + + expect(response.success).toBe(false); + expect(response.message).toContain("Incorrect or missing password"); + + delete process.env.BW_NOINTERACTION; + }); + + it("should return 404 for non-existent Send", async () => { + const error = new ErrorResponse( + { + statusCode: 404, + message: "Not found", + }, + 404, + ); + + sendApiService.postSendAccess.mockRejectedValue(error); + + const response = await command.run(testUrl, {}); + + expect(response.success).toBe(false); + }); + }); + + describe("V2 Flow (Feature Flag On)", () => { + beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue(true); + }); + + describe("Unprotected Sends", () => { + it("should successfully access Send with cached token", async () => { + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken)); + sendApiService.postSendAccessV2.mockResolvedValue({} as any); + jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success()); + + const response = await command.run(testUrl, {}); + + expect(response.success).toBe(true); + expect(sendTokenService.tryGetSendAccessToken$).toHaveBeenCalledWith(testSendId); + }); + + it("should handle expired token and determine auth type", async () => { + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "password_hash_b64_required", + }, + } as any), + ); + + // Mock password auth flow + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.getSendAccessToken$.mockReturnValue(of(mockToken)); + jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success()); + + const response = await command.run(testUrl, { password: "test-password" }); + + expect(response.success).toBe(true); + }); + }); + + describe("Password Authentication (V2)", () => { + it("should successfully authenticate with password", async () => { + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "password_hash_b64_required", + }, + } as any), + ); + + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.getSendAccessToken$.mockReturnValue(of(mockToken)); + sendApiService.postSendAccessV2.mockResolvedValue({} as any); + jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success()); + + const response = await command.run(testUrl, { password: "correct-password" }); + + expect(response.success).toBe(true); + expect(sendTokenService.getSendAccessToken$).toHaveBeenCalledWith( + testSendId, + expect.objectContaining({ + kind: "password", + passwordHashB64: expect.any(String), + }), + ); + }); + + it("should return error for invalid password", async () => { + process.env.BW_NOINTERACTION = "true"; + + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "password_hash_b64_required", + }, + } as any), + ); + + sendTokenService.getSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_grant", + send_access_error_type: "password_hash_b64_invalid", + }, + } as any), + ); + + const response = await command.run(testUrl, { password: "wrong-password" }); + + expect(response.success).toBe(false); + expect(response.message).toContain("Invalid password"); + + delete process.env.BW_NOINTERACTION; + }); + + it("should work with --passwordenv option", async () => { + process.env.TEST_SEND_PASSWORD = "env-password"; + process.env.BW_NOINTERACTION = "true"; + + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "password_hash_b64_required", + }, + } as any), + ); + + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.getSendAccessToken$.mockReturnValue(of(mockToken)); + jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success()); + + const response = await command.run(testUrl, { passwordenv: "TEST_SEND_PASSWORD" }); + + expect(response.success).toBe(true); + + delete process.env.TEST_SEND_PASSWORD; + delete process.env.BW_NOINTERACTION; + }); + }); + + describe("Email OTP Authentication (V2)", () => { + it("should return error in non-interactive mode for email OTP", async () => { + process.env.BW_NOINTERACTION = "true"; + + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "email_required", + }, + } as any), + ); + + const response = await command.run(testUrl, {}); + + expect(response.success).toBe(false); + expect(response.message).toContain("Email verification required"); + expect(response.message).toContain("interactive mode"); + + delete process.env.BW_NOINTERACTION; + }); + + it("should handle email submission and OTP prompt flow", async () => { + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "email_required", + }, + } as any), + ); + + sendTokenService.getSendAccessToken$.mockReturnValueOnce( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "email_and_otp_required_otp_sent", + }, + } as any), + ); + + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.getSendAccessToken$.mockReturnValueOnce(of(mockToken)); + + // We can't easily test the interactive prompts, but we can verify the token service calls + // would be made in the right order + expect(sendTokenService.getSendAccessToken$).toBeDefined(); + }); + + it("should handle invalid email error", async () => { + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_request", + send_access_error_type: "email_required", + }, + } as any), + ); + + sendTokenService.getSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_grant", + send_access_error_type: "email_invalid", + }, + } as any), + ); + + // In a real scenario with interactive prompts, this would retry + // For unit tests, we verify the error is recognized + expect(sendTokenService.getSendAccessToken$).toBeDefined(); + }); + + it("should handle invalid OTP error", async () => { + sendTokenService.getSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_grant", + send_access_error_type: "otp_invalid", + }, + } as any), + ); + + // Verify OTP validation would be handled + expect(sendTokenService.getSendAccessToken$).toBeDefined(); + }); + }); + + describe("File Downloads (V2)", () => { + it("should successfully download file Send with V2 API", async () => { + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken)); + + const mockSendResponse = { + id: testSendId, + type: SendType.File, + file: { + id: "file-123", + fileName: "test.pdf", + size: 1024, + }, + }; + + sendApiService.postSendAccessV2.mockResolvedValue(mockSendResponse as any); + sendApiService.getSendFileDownloadDataV2.mockResolvedValue({ + url: "https://example.com/download", + } as any); + + encryptService.decryptFileData.mockResolvedValue(new ArrayBuffer(1024) as any); + jest.spyOn(command as any, "saveAttachmentToFile").mockResolvedValue(Response.success()); + + await command.run(testUrl, { output: "./test.pdf" }); + + expect(sendApiService.getSendFileDownloadDataV2).toHaveBeenCalledWith( + expect.any(Object), + mockToken, + "https://api.bitwarden.com", + ); + }); + }); + + describe("Invalid Send ID", () => { + it("should return 404 for invalid Send ID", async () => { + sendTokenService.tryGetSendAccessToken$.mockReturnValue( + of({ + kind: "expected_server", + error: { + error: "invalid_grant", + send_access_error_type: "send_id_invalid", + }, + } as any), + ); + + const response = await command.run(testUrl, {}); + + expect(response.success).toBe(false); + }); + }); + + describe("Text Send Output", () => { + it("should output text to stdout for text Sends", async () => { + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken)); + + const secretText = "This is a secret message"; + + sendApiService.postSendAccessV2.mockResolvedValue({} as any); + + // Mock the entire accessSendWithToken to avoid encryption issues + jest.spyOn(command as any, "accessSendWithToken").mockImplementation(async () => { + process.stdout.write(secretText); + return Response.success(); + }); + + const stdoutSpy = jest.spyOn(process.stdout, "write").mockImplementation(() => true); + + const response = await command.run(testUrl, {}); + + expect(response.success).toBe(true); + expect(stdoutSpy).toHaveBeenCalledWith(secretText); + + stdoutSpy.mockRestore(); + }); + + it("should return JSON object when --obj flag is used", async () => { + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken)); + + const mockDecryptedView = { + id: testSendId, + type: SendType.Text, + text: { text: "secret message" }, + }; + + sendApiService.postSendAccessV2.mockResolvedValue({} as any); + + // Mock the entire accessSendWithToken to avoid encryption issues + jest.spyOn(command as any, "accessSendWithToken").mockImplementation(async () => { + const sendAccessResponse = new SendAccessResponse(mockDecryptedView as any); + const res = new Response(); + res.success = true; + res.data = sendAccessResponse as any; + return res; + }); + + const response = await command.run(testUrl, { obj: true }); + + expect(response.success).toBe(true); + expect(response.data).toBeDefined(); + expect(response.data.constructor.name).toBe("SendAccessResponse"); + }); + }); + }); + + describe("API URL Resolution", () => { + it("should resolve send.bitwarden.com to api.bitwarden.com", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + const sendUrl = "https://send.bitwarden.com/#/send/abc123/key456"; + sendApiService.postSendAccess.mockResolvedValue({} as any); + jest.spyOn(command as any, "sendRequest").mockResolvedValue({ + type: SendType.Text, + text: { text: "test" }, + }); + + await command.run(sendUrl, {}); + + const apiUrl = await (command as any).getApiUrl(new URL(sendUrl)); + expect(apiUrl).toBe("https://api.bitwarden.com"); + }); + + it("should handle custom domain URLs", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + const customUrl = "https://custom.example.com/#/send/abc123/key456"; + sendApiService.postSendAccess.mockResolvedValue({} as any); + jest.spyOn(command as any, "sendRequest").mockResolvedValue({ + type: SendType.Text, + text: { text: "test" }, + }); + + await command.run(customUrl, {}); + + const apiUrl = await (command as any).getApiUrl(new URL(customUrl)); + expect(apiUrl).toBe("https://custom.example.com/api"); + }); + }); + + describe("Feature Flag Routing", () => { + it("should route to V1 flow when feature flag is off", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + sendApiService.postSendAccess.mockResolvedValue({} as any); + const v1Spy = jest.spyOn(command as any, "attemptV1Access"); + jest.spyOn(command as any, "sendRequest").mockResolvedValue({ + type: SendType.Text, + text: { text: "test" }, + }); + + await command.run(testUrl, {}); + + expect(configService.getFeatureFlag).toHaveBeenCalledWith(FeatureFlag.SendEmailOTP); + expect(v1Spy).toHaveBeenCalled(); + }); + + it("should route to V2 flow when feature flag is on", async () => { + configService.getFeatureFlag.mockResolvedValue(true); + + const mockToken = new SendAccessToken("test-token", Date.now() + 3600000); + sendTokenService.tryGetSendAccessToken$.mockReturnValue(of(mockToken)); + + const v2Spy = jest.spyOn(command as any, "attemptV2Access"); + jest.spyOn(command as any, "accessSendWithToken").mockResolvedValue(Response.success()); + + await command.run(testUrl, {}); + + expect(configService.getFeatureFlag).toHaveBeenCalledWith(FeatureFlag.SendEmailOTP); + expect(v2Spy).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/cli/src/tools/send/commands/receive.command.ts b/apps/cli/src/tools/send/commands/receive.command.ts index 5cbf458c87f..9496855a7a5 100644 --- a/apps/cli/src/tools/send/commands/receive.command.ts +++ b/apps/cli/src/tools/send/commands/receive.command.ts @@ -5,9 +5,25 @@ import * as inquirer from "inquirer"; import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { + SendTokenService, + SendAccessToken, + emailRequired, + emailAndOtpRequired, + otpInvalid, + passwordHashB64Required, + passwordHashB64Invalid, + sendIdInvalid, + SendHashedPasswordB64, + SendOtp, + GetSendAccessTokenError, + SendAccessDomainCredentials, +} from "@bitwarden/common/auth/send-access"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -17,6 +33,7 @@ import { SendAccess } from "@bitwarden/common/tools/send/models/domain/send-acce import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { KeyService } from "@bitwarden/key-management"; import { NodeUtils } from "@bitwarden/node/node-utils"; @@ -38,6 +55,8 @@ export class SendReceiveCommand extends DownloadCommand { private environmentService: EnvironmentService, private sendApiService: SendApiService, apiService: ApiService, + private sendTokenService: SendTokenService, + private configService: ConfigService, ) { super(encryptService, apiService); } @@ -62,58 +81,13 @@ export class SendReceiveCommand extends DownloadCommand { } const keyArray = Utils.fromUrlB64ToArray(key); - this.sendAccessRequest = new SendAccessRequest(); - let password = options.password; - if (password == null || password === "") { - if (options.passwordfile) { - password = await NodeUtils.readFirstLine(options.passwordfile); - } else if (options.passwordenv && process.env[options.passwordenv]) { - password = process.env[options.passwordenv]; - } - } + const sendEmailOtpEnabled = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP); - if (password != null && password !== "") { - this.sendAccessRequest.password = await this.getUnlockedPassword(password, keyArray); - } - - const response = await this.sendRequest(apiUrl, id, keyArray); - - if (response instanceof Response) { - // Error scenario - return response; - } - - if (options.obj != null) { - return Response.success(new SendAccessResponse(response)); - } - - switch (response.type) { - case SendType.Text: - // Write to stdout and response success so we get the text string only to stdout - process.stdout.write(response?.text?.text); - return Response.success(); - case SendType.File: { - const downloadData = await this.sendApiService.getSendFileDownloadData( - response, - this.sendAccessRequest, - apiUrl, - ); - - const decryptBufferFn = async (resp: globalThis.Response) => { - const encBuf = await EncArrayBuffer.fromResponse(resp); - return this.encryptService.decryptFileData(encBuf, this.decKey); - }; - - return await this.saveAttachmentToFile( - downloadData.url, - response?.file?.fileName, - decryptBufferFn, - options.output, - ); - } - default: - return Response.success(new SendAccessResponse(response)); + if (sendEmailOtpEnabled) { + return await this.attemptV2Access(apiUrl, id, keyArray, options); + } else { + return await this.attemptV1Access(apiUrl, id, keyArray, options); } } @@ -146,6 +120,350 @@ export class SendReceiveCommand extends DownloadCommand { return Utils.fromBufferToB64(passwordHash); } + private async attemptV1Access( + apiUrl: string, + id: string, + keyArray: Uint8Array, + options: OptionValues, + ): Promise<Response> { + this.sendAccessRequest = new SendAccessRequest(); + + let password = options.password; + if (password == null || password === "") { + if (options.passwordfile) { + password = await NodeUtils.readFirstLine(options.passwordfile); + } else if (options.passwordenv && process.env[options.passwordenv]) { + password = process.env[options.passwordenv]; + } + } + + if (password != null && password !== "") { + this.sendAccessRequest.password = await this.getUnlockedPassword(password, keyArray); + } + + const response = await this.sendRequest(apiUrl, id, keyArray); + + if (response instanceof Response) { + return response; + } + + if (options.obj != null) { + return Response.success(new SendAccessResponse(response)); + } + + switch (response.type) { + case SendType.Text: + process.stdout.write(response?.text?.text); + return Response.success(); + case SendType.File: { + const downloadData = await this.sendApiService.getSendFileDownloadData( + response, + this.sendAccessRequest, + apiUrl, + ); + + const decryptBufferFn = async (resp: globalThis.Response) => { + const encBuf = await EncArrayBuffer.fromResponse(resp); + return this.encryptService.decryptFileData(encBuf, this.decKey); + }; + + return await this.saveAttachmentToFile( + downloadData.url, + response?.file?.fileName, + decryptBufferFn, + options.output, + ); + } + default: + return Response.success(new SendAccessResponse(response)); + } + } + + private async attemptV2Access( + apiUrl: string, + id: string, + keyArray: Uint8Array, + options: OptionValues, + ): Promise<Response> { + let authType: AuthType = AuthType.None; + + const currentResponse = await this.getTokenWithRetry(id); + + if (currentResponse instanceof SendAccessToken) { + return await this.accessSendWithToken(currentResponse, keyArray, apiUrl, options); + } + + if (currentResponse.kind === "expected_server") { + const error = currentResponse.error; + + if (emailRequired(error)) { + authType = AuthType.Email; + } else if (passwordHashB64Required(error)) { + authType = AuthType.Password; + } else if (sendIdInvalid(error)) { + return Response.notFound(); + } + } else { + return this.handleError(currentResponse); + } + + // Handle authentication based on type + if (authType === AuthType.Email) { + if (!this.canInteract) { + return Response.badRequest("Email verification required. Run in interactive mode."); + } + return await this.handleEmailOtpAuth(id, keyArray, apiUrl, options); + } else if (authType === AuthType.Password) { + return await this.handlePasswordAuth(id, keyArray, apiUrl, options); + } + + // The auth layer will immediately return a token for Sends with AuthType.None + // If this code is reached, something has gone wrong + if (authType === AuthType.None) { + return Response.error("Could not determine authentication requirements"); + } + + return Response.error("Authentication failed"); + } + + private async getTokenWithRetry( + sendId: string, + credentials?: SendAccessDomainCredentials, + ): Promise<SendAccessToken | GetSendAccessTokenError> { + let expiredAttempts = 0; + + while (expiredAttempts < 3) { + const response = credentials + ? await firstValueFrom(this.sendTokenService.getSendAccessToken$(sendId, credentials)) + : await firstValueFrom(this.sendTokenService.tryGetSendAccessToken$(sendId)); + + if (response instanceof SendAccessToken) { + return response; + } + + if (response.kind === "expired") { + expiredAttempts++; + continue; + } + + // Not expired, return the response for caller to handle + return response; + } + + // After 3 expired attempts, return an error response + return { + kind: "unknown", + error: "Send access token has expired and could not be refreshed", + }; + } + + private handleError(error: GetSendAccessTokenError): Response { + if (error.kind === "unexpected_server") { + return Response.error("Server error: " + JSON.stringify(error.error)); + } + + return Response.error("Error: " + JSON.stringify(error.error)); + } + + private async promptForOtp(sendId: string, email: string): Promise<SendOtp> { + const otpAnswer = await inquirer.createPromptModule({ output: process.stderr })({ + type: "input", + name: "otp", + message: "Enter the verification code sent to your email:", + }); + return otpAnswer.otp; + } + + private async promptForEmail(): Promise<string> { + const emailAnswer = await inquirer.createPromptModule({ output: process.stderr })({ + type: "input", + name: "email", + message: "Enter your email address:", + validate: (input: string) => { + if (!input || !input.includes("@")) { + return "Please enter a valid email address"; + } + return true; + }, + }); + return emailAnswer.email; + } + + private async handleEmailOtpAuth( + sendId: string, + keyArray: Uint8Array, + apiUrl: string, + options: OptionValues, + ): Promise<Response> { + const email = await this.promptForEmail(); + + const emailResponse = await this.getTokenWithRetry(sendId, { + kind: "email", + email: email, + }); + + if (emailResponse instanceof SendAccessToken) { + /* + At this point emailResponse should only be expected to be a GetSendAccessTokenError type, + but TS must have a logical branch in case it is a SendAccessToken type. If a valid token is + returned by the method above, something has gone wrong. + */ + + return Response.error("Unexpected server response"); + } + + if (emailResponse.kind === "expected_server") { + const error = emailResponse.error; + + if (emailAndOtpRequired(error)) { + const promptResponse = await this.promptForOtp(sendId, email); + + // Use retry helper for expired token handling + const otpResponse = await this.getTokenWithRetry(sendId, { + kind: "email_otp", + email: email, + otp: promptResponse, + }); + + if (otpResponse instanceof SendAccessToken) { + return await this.accessSendWithToken(otpResponse, keyArray, apiUrl, options); + } + + if (otpResponse.kind === "expected_server") { + const error = otpResponse.error; + + if (otpInvalid(error)) { + return Response.badRequest("Invalid email or verification code"); + } + + /* + If the following evaluates to true, it means that the email address provided was not + configured to be used for email OTP for this Send. + + To avoid leaking information that would allow email enumeration, instead return an + error indicating that some component of the email OTP challenge was invalid. + */ + if (emailAndOtpRequired(error)) { + return Response.badRequest("Invalid email or verification code"); + } + } + return this.handleError(otpResponse); + } + } + return this.handleError(emailResponse); + } + + private async handlePasswordAuth( + sendId: string, + keyArray: Uint8Array, + apiUrl: string, + options: OptionValues, + ): Promise<Response> { + let password = options.password; + + if (password == null || password === "") { + if (options.passwordfile) { + password = await NodeUtils.readFirstLine(options.passwordfile); + } else if (options.passwordenv && process.env[options.passwordenv]) { + password = process.env[options.passwordenv]; + } + } + + if ((password == null || password === "") && this.canInteract) { + const answer = await inquirer.createPromptModule({ output: process.stderr })({ + type: "password", + name: "password", + message: "Send password:", + }); + password = answer.password; + } + + if (!password) { + return Response.badRequest("Password required"); + } + + const passwordHashB64 = await this.getUnlockedPassword(password, keyArray); + + // Use retry helper for expired token handling + const response = await this.getTokenWithRetry(sendId, { + kind: "password", + passwordHashB64: passwordHashB64 as SendHashedPasswordB64, + }); + + if (response instanceof SendAccessToken) { + return await this.accessSendWithToken(response, keyArray, apiUrl, options); + } + + if (response.kind === "expected_server") { + const error = response.error; + + if (passwordHashB64Invalid(error)) { + return Response.badRequest("Invalid password"); + } + } else if (response.kind === "unexpected_server") { + return Response.error("Server error: " + JSON.stringify(response.error)); + } else if (response.kind === "unknown") { + return Response.error("Error: " + response.error); + } + + return Response.error("Authentication failed"); + } + + private async accessSendWithToken( + accessToken: SendAccessToken, + keyArray: Uint8Array, + apiUrl: string, + options: OptionValues, + ): Promise<Response> { + try { + const sendResponse = await this.sendApiService.postSendAccessV2(accessToken, apiUrl); + + const sendAccess = new SendAccess(sendResponse); + this.decKey = await this.keyService.makeSendKey(keyArray); + const decryptedView = await sendAccess.decrypt(this.decKey); + + if (options.obj != null) { + return Response.success(new SendAccessResponse(decryptedView)); + } + + switch (decryptedView.type) { + case SendType.Text: + process.stdout.write(decryptedView?.text?.text); + return Response.success(); + + case SendType.File: { + const downloadData = await this.sendApiService.getSendFileDownloadDataV2( + decryptedView, + accessToken, + apiUrl, + ); + + const decryptBufferFn = async (resp: globalThis.Response) => { + const encBuf = await EncArrayBuffer.fromResponse(resp); + return this.encryptService.decryptFileData(encBuf, this.decKey); + }; + + return await this.saveAttachmentToFile( + downloadData.url, + decryptedView?.file?.fileName, + decryptBufferFn, + options.output, + ); + } + + default: + return Response.success(new SendAccessResponse(decryptedView)); + } + } catch (e) { + if (e instanceof ErrorResponse) { + if (e.statusCode === 404) { + return Response.notFound(); + } + } + return Response.error(e); + } + } + private async sendRequest( url: string, id: string, diff --git a/apps/cli/src/tools/send/send.program.ts b/apps/cli/src/tools/send/send.program.ts index a84b6c15ead..e40cea4daa9 100644 --- a/apps/cli/src/tools/send/send.program.ts +++ b/apps/cli/src/tools/send/send.program.ts @@ -133,6 +133,8 @@ export class SendProgram extends BaseProgram { this.serviceContainer.environmentService, this.serviceContainer.sendApiService, this.serviceContainer.apiService, + this.serviceContainer.sendTokenService, + this.serviceContainer.configService, ); const response = await cmd.run(url, options); this.processResponse(response); From 5cf4678838c419aa2f3806de4e7118874c17f20b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:10:55 +0000 Subject: [PATCH 29/42] [PM-28300] Remove BlockClaimedDomainAccountCreation feature flag and related logic from policy component (#18720) --- .../block-claimed-domain-account-creation.component.ts | 10 ---------- libs/common/src/enums/feature-flag.enum.ts | 2 -- 2 files changed, 12 deletions(-) diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/block-claimed-domain-account-creation.component.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/block-claimed-domain-account-creation.component.ts index 75c61e3e7e3..7e6e346d3b7 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/block-claimed-domain-account-creation.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/block-claimed-domain-account-creation.component.ts @@ -1,10 +1,6 @@ import { ChangeDetectionStrategy, Component } from "@angular/core"; -import { map, Observable } from "rxjs"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { BasePolicyEditDefinition, BasePolicyEditComponent, @@ -16,12 +12,6 @@ export class BlockClaimedDomainAccountCreationPolicy extends BasePolicyEditDefin description = "blockClaimedDomainAccountCreationDesc"; type = PolicyType.BlockClaimedDomainAccountCreation; component = BlockClaimedDomainAccountCreationPolicyComponent; - - override display$(organization: Organization, configService: ConfigService): Observable<boolean> { - return configService - .getFeatureFlag$(FeatureFlag.BlockClaimedDomainAccountCreation) - .pipe(map((enabled) => enabled && organization.useOrganizationDomains)); - } } @Component({ diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 40e22cfbb5a..1edbcc4e376 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -12,7 +12,6 @@ import { ServerConfig } from "../platform/abstractions/config/server-config"; export enum FeatureFlag { /* Admin Console Team */ AutoConfirm = "pm-19934-auto-confirm-organization-users", - BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration", DefaultUserCollectionRestore = "pm-30883-my-items-restored-users", MembersComponentRefactor = "pm-29503-refactor-members-inheritance", BulkReinviteUI = "pm-28416-bulk-reinvite-ux-improvements", @@ -108,7 +107,6 @@ const FALSE = false as boolean; export const DefaultFeatureFlagValue = { /* Admin Console Team */ [FeatureFlag.AutoConfirm]: FALSE, - [FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE, [FeatureFlag.DefaultUserCollectionRestore]: FALSE, [FeatureFlag.MembersComponentRefactor]: FALSE, [FeatureFlag.BulkReinviteUI]: FALSE, From 88140604c120dc8c98c8d25eaae2f9d85a28eaec Mon Sep 17 00:00:00 2001 From: Amy Galles <9685081+AmyLGalles@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:32:23 -0800 Subject: [PATCH 30/42] Add missing bw-linux-arm64 release artifact (#18614) * duplicating changes made previously by @RoboMagus * organizing builds --- .github/workflows/release-cli.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 3f7b7e326d9..5d37c00c2d9 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -91,7 +91,9 @@ jobs: apps/cli/bw-macos-${{ env.PKG_VERSION }}.zip, apps/cli/bw-macos-arm64-${{ env.PKG_VERSION }}.zip, apps/cli/bw-oss-linux-${{ env.PKG_VERSION }}.zip, + apps/cli/bw-oss-linux-arm64-${{ env.PKG_VERSION }}.zip, apps/cli/bw-linux-${{ env.PKG_VERSION }}.zip, + apps/cli/bw-linux-arm64-${{ env.PKG_VERSION }}.zip, apps/cli/bitwarden-cli.${{ env.PKG_VERSION }}.nupkg, apps/cli/bw_${{ env.PKG_VERSION }}_amd64.snap, apps/cli/bitwarden-cli-${{ env.PKG_VERSION }}-npm-build.zip" From 30d3a36c7e584be138e6beab4018a2329712fc47 Mon Sep 17 00:00:00 2001 From: Jason Ng <jng@bitwarden.com> Date: Wed, 11 Feb 2026 17:32:35 -0500 Subject: [PATCH 31/42] [PM-31938] refactor archive btn logic in web view modal (#18874) * refactor showArchiveBtn logic in web view modal --- .../vault-item-dialog.component.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index d9eb03ea1ca..4da2d05f12b 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -10,7 +10,7 @@ import { OnInit, viewChild, } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; import { firstValueFrom, Observable, Subject, switchMap } from "rxjs"; import { map } from "rxjs/operators"; @@ -226,6 +226,9 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { ); protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$; + private readonly archiveFlagEnabled = toSignal(this.archiveFlagEnabled$, { + initialValue: false, + }); protected userId$ = this.accountService.activeAccount$.pipe(getUserId); @@ -237,6 +240,8 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { switchMap((userId) => this.archiveService.userCanArchive$(userId)), ); + private readonly userCanArchive = toSignal(this.userCanArchive$, { initialValue: false }); + protected get isTrashFilter() { return this.filter?.type === "trash"; } @@ -293,14 +298,14 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { return this.cipher?.isArchived; } - private _userCanArchive = false; - protected get showArchiveOptions(): boolean { - return this._userCanArchive && !this.params.isAdminConsoleAction && this.params.mode === "view"; + return ( + this.archiveFlagEnabled() && !this.params.isAdminConsoleAction && this.params.mode === "view" + ); } protected get showArchiveBtn(): boolean { - return this.cipher?.canBeArchived; + return this.userCanArchive() && this.cipher?.canBeArchived; } protected get showUnarchiveBtn(): boolean { @@ -355,8 +360,6 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { takeUntilDestroyed(), ) .subscribe(); - - this.userCanArchive$.pipe(takeUntilDestroyed()).subscribe((v) => (this._userCanArchive = v)); } async ngOnInit() { From 11e2b25ede7276157a0feb676b740a5752cd1d74 Mon Sep 17 00:00:00 2001 From: Ben Brooks <56796209+bensbits91@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:47:27 -0800 Subject: [PATCH 32/42] PM-28831 Add isTrusted checks to ignore programmatically generated events (#18627) * ignore events that do not originate from the user agent * [pm-28831] Add isTrusted checks and update tests * [pm-28831] Add isTrusted check to click events * [pm-28831] Replace in-code jest exceptions with new utils * [pm-28831] Move isTrusted checks to testable util * [pm-28831] Remove redundant check in cipher-action.ts * [pm-28831] Add isTrusted checks to click events in autofill-inine-menu-list --------- Signed-off-by: Ben Brooks <bbrooks@bitwarden.com> Co-authored-by: Jonathan Prusik <jprusik@classynemesis.com> --- .../components/buttons/action-button.ts | 3 +- .../components/buttons/badge-button.ts | 3 +- .../content/components/buttons/edit-button.ts | 3 +- .../notification/confirmation/message.ts | 3 +- .../option-selection/option-item.ts | 21 +++- .../option-selection/option-items.ts | 5 + .../option-selection/option-selection.ts | 3 +- .../content/content-message-handler.spec.ts | 2 + .../content/content-message-handler.ts | 7 +- .../autofill/content/context-menu-handler.ts | 8 ++ .../list/autofill-inline-menu-list.spec.ts | 2 + .../pages/list/autofill-inline-menu-list.ts | 119 +++++++++++++++--- .../autofill-inline-menu-page-element.ts | 6 +- .../autofill-overlay-content.service.spec.ts | 6 + .../autofill-overlay-content.service.ts | 12 ++ .../src/autofill/utils/event-security.spec.ts | 26 ++++ .../src/autofill/utils/event-security.ts | 13 ++ 17 files changed, 219 insertions(+), 23 deletions(-) create mode 100644 apps/browser/src/autofill/utils/event-security.spec.ts create mode 100644 apps/browser/src/autofill/utils/event-security.ts diff --git a/apps/browser/src/autofill/content/components/buttons/action-button.ts b/apps/browser/src/autofill/content/components/buttons/action-button.ts index 73fc1e79ec5..e83f2b4b77c 100644 --- a/apps/browser/src/autofill/content/components/buttons/action-button.ts +++ b/apps/browser/src/autofill/content/components/buttons/action-button.ts @@ -3,6 +3,7 @@ import { html, TemplateResult } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../utils/event-security"; import { border, themes, typography, spacing } from "../constants/styles"; import { Spinner } from "../icons"; @@ -26,7 +27,7 @@ export function ActionButton({ fullWidth = true, }: ActionButtonProps) { const handleButtonClick = (event: Event) => { - if (!disabled && !isLoading) { + if (EventSecurity.isEventTrusted(event) && !disabled && !isLoading) { handleClick(event); } }; diff --git a/apps/browser/src/autofill/content/components/buttons/badge-button.ts b/apps/browser/src/autofill/content/components/buttons/badge-button.ts index 3cdd453ee1a..98968d0b57b 100644 --- a/apps/browser/src/autofill/content/components/buttons/badge-button.ts +++ b/apps/browser/src/autofill/content/components/buttons/badge-button.ts @@ -3,6 +3,7 @@ import { html } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../utils/event-security"; import { border, themes, typography, spacing } from "../constants/styles"; export type BadgeButtonProps = { @@ -23,7 +24,7 @@ export function BadgeButton({ username, }: BadgeButtonProps) { const handleButtonClick = (event: Event) => { - if (!disabled) { + if (EventSecurity.isEventTrusted(event) && !disabled) { buttonAction(event); } }; diff --git a/apps/browser/src/autofill/content/components/buttons/edit-button.ts b/apps/browser/src/autofill/content/components/buttons/edit-button.ts index ecbb736bb8e..88caae13590 100644 --- a/apps/browser/src/autofill/content/components/buttons/edit-button.ts +++ b/apps/browser/src/autofill/content/components/buttons/edit-button.ts @@ -3,6 +3,7 @@ import { html } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../utils/event-security"; import { themes, typography, spacing } from "../constants/styles"; import { PencilSquare } from "../icons"; @@ -21,7 +22,7 @@ export function EditButton({ buttonAction, buttonText, disabled = false, theme } aria-label=${buttonText} class=${editButtonStyles({ disabled, theme })} @click=${(event: Event) => { - if (!disabled) { + if (EventSecurity.isEventTrusted(event) && !disabled) { buttonAction(event); } }} diff --git a/apps/browser/src/autofill/content/components/notification/confirmation/message.ts b/apps/browser/src/autofill/content/components/notification/confirmation/message.ts index 36ea9c1f9d6..480b2acd0dd 100644 --- a/apps/browser/src/autofill/content/components/notification/confirmation/message.ts +++ b/apps/browser/src/autofill/content/components/notification/confirmation/message.ts @@ -3,6 +3,7 @@ import { html, nothing } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../../utils/event-security"; import { spacing, themes, typography } from "../../constants/styles"; export type NotificationConfirmationMessageProps = { @@ -127,7 +128,7 @@ const AdditionalMessageStyles = ({ theme }: { theme: Theme }) => css` `; function handleButtonKeyDown(event: KeyboardEvent, handleClick: () => void) { - if (event.key === "Enter" || event.key === " ") { + if (EventSecurity.isEventTrusted(event) && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); handleClick(); } diff --git a/apps/browser/src/autofill/content/components/option-selection/option-item.ts b/apps/browser/src/autofill/content/components/option-selection/option-item.ts index 6af6a2d6538..1cbabcb4f85 100644 --- a/apps/browser/src/autofill/content/components/option-selection/option-item.ts +++ b/apps/browser/src/autofill/content/components/option-selection/option-item.ts @@ -3,6 +3,7 @@ import { html, nothing } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../utils/event-security"; import { IconProps, Option } from "../common-types"; import { themes, spacing } from "../constants/styles"; @@ -29,6 +30,13 @@ export function OptionItem({ handleSelection, }: OptionItemProps) { const handleSelectionKeyUpProxy = (event: KeyboardEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + const listenedForKeys = new Set(["Enter", "Space"]); if (listenedForKeys.has(event.code) && event.target instanceof Element) { handleSelection(); @@ -37,6 +45,17 @@ export function OptionItem({ return; }; + const handleSelectionClickProxy = (event: MouseEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + + handleSelection(); + }; + const iconProps: IconProps = { color: themes[theme].text.main, theme }; const itemIcon = icon?.(iconProps); const ariaLabel = @@ -52,7 +71,7 @@ export function OptionItem({ title=${text} role="option" aria-label=${ariaLabel} - @click=${handleSelection} + @click=${handleSelectionClickProxy} @keyup=${handleSelectionKeyUpProxy} > ${itemIcon ? html`<div class=${optionItemIconContainerStyles}>${itemIcon}</div>` : nothing} diff --git a/apps/browser/src/autofill/content/components/option-selection/option-items.ts b/apps/browser/src/autofill/content/components/option-selection/option-items.ts index 58216b6c1b2..4c24a2fde8b 100644 --- a/apps/browser/src/autofill/content/components/option-selection/option-items.ts +++ b/apps/browser/src/autofill/content/components/option-selection/option-items.ts @@ -3,6 +3,7 @@ import { html, nothing } from "lit"; import { Theme } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../utils/event-security"; import { Option } from "../common-types"; import { themes, typography, scrollbarStyles, spacing } from "../constants/styles"; @@ -57,6 +58,10 @@ export function OptionItems({ } function handleMenuKeyUp(event: KeyboardEvent) { + if (!EventSecurity.isEventTrusted(event)) { + return; + } + const items = [ ...(event.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('[tabindex="0"]'), ]; diff --git a/apps/browser/src/autofill/content/components/option-selection/option-selection.ts b/apps/browser/src/autofill/content/components/option-selection/option-selection.ts index ee711456e9c..78c7d9f0646 100644 --- a/apps/browser/src/autofill/content/components/option-selection/option-selection.ts +++ b/apps/browser/src/autofill/content/components/option-selection/option-selection.ts @@ -4,6 +4,7 @@ import { property, state } from "lit/decorators.js"; import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; +import { EventSecurity } from "../../../utils/event-security"; import { OptionSelectionButton } from "../buttons/option-selection-button"; import { Option } from "../common-types"; @@ -54,7 +55,7 @@ export class OptionSelection extends LitElement { private static currentOpenInstance: OptionSelection | null = null; private handleButtonClick = async (event: Event) => { - if (!this.disabled) { + if (EventSecurity.isEventTrusted(event) && !this.disabled) { const isOpening = !this.showMenu; if (isOpening) { diff --git a/apps/browser/src/autofill/content/content-message-handler.spec.ts b/apps/browser/src/autofill/content/content-message-handler.spec.ts index 874e1cc76ff..fb17874b0b7 100644 --- a/apps/browser/src/autofill/content/content-message-handler.spec.ts +++ b/apps/browser/src/autofill/content/content-message-handler.spec.ts @@ -3,6 +3,7 @@ import { mock } from "jest-mock-extended"; import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; import { postWindowMessage, sendMockExtensionMessage } from "../spec/testing-utils"; +import { EventSecurity } from "../utils/event-security"; describe("ContentMessageHandler", () => { const sendMessageSpy = jest.spyOn(chrome.runtime, "sendMessage"); @@ -19,6 +20,7 @@ describe("ContentMessageHandler", () => { ); beforeEach(() => { + jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true); // FIXME: Remove when updating file. Eslint update // eslint-disable-next-line @typescript-eslint/no-require-imports require("./content-message-handler"); diff --git a/apps/browser/src/autofill/content/content-message-handler.ts b/apps/browser/src/autofill/content/content-message-handler.ts index 63afc215923..874e760c4f8 100644 --- a/apps/browser/src/autofill/content/content-message-handler.ts +++ b/apps/browser/src/autofill/content/content-message-handler.ts @@ -1,6 +1,8 @@ import { ExtensionPageUrls } from "@bitwarden/common/vault/enums"; import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; +import { EventSecurity } from "../utils/event-security"; + import { ContentMessageWindowData, ContentMessageWindowEventHandlers, @@ -92,7 +94,10 @@ function handleOpenBrowserExtensionToUrlMessage({ url }: { url?: ExtensionPageUr */ function handleWindowMessageEvent(event: MessageEvent) { const { source, data, origin } = event; - if (source !== window || !data?.command) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event) || source !== window || !data?.command) { return; } diff --git a/apps/browser/src/autofill/content/context-menu-handler.ts b/apps/browser/src/autofill/content/context-menu-handler.ts index d3926d57c9a..919ab5f1a3d 100644 --- a/apps/browser/src/autofill/content/context-menu-handler.ts +++ b/apps/browser/src/autofill/content/context-menu-handler.ts @@ -1,3 +1,5 @@ +import { EventSecurity } from "../utils/event-security"; + const inputTags = ["input", "textarea", "select"]; const labelTags = ["label", "span"]; const attributeKeys = ["id", "name", "label-aria", "placeholder"]; @@ -52,6 +54,12 @@ function isNullOrEmpty(s: string | null) { // We only have access to the element that's been clicked when the context menu is first opened. // Remember it for use later. document.addEventListener("contextmenu", (event) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } clickedElement = event.target as HTMLElement; }); diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts index 1e99ac9df90..212fe6d8c89 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.spec.ts @@ -10,6 +10,7 @@ import { createInitAutofillInlineMenuListMessageMock, } from "../../../../spec/autofill-mocks"; import { flushPromises, postWindowMessage } from "../../../../spec/testing-utils"; +import { EventSecurity } from "../../../../utils/event-security"; import { AutofillInlineMenuList } from "./autofill-inline-menu-list"; @@ -28,6 +29,7 @@ describe("AutofillInlineMenuList", () => { const events: { eventName: any; callback: any }[] = []; beforeEach(() => { + jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true); const oldEv = globalThis.addEventListener; globalThis.addEventListener = (eventName: any, callback: any) => { events.push({ eventName, callback }); diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts index c13c523e30a..39b7fa5c17b 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts @@ -10,6 +10,7 @@ import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; import { InlineMenuCipherData } from "../../../../background/abstractions/overlay.background"; import { InlineMenuFillType } from "../../../../enums/autofill-overlay.enum"; import { buildSvgDomElement, specialCharacterToKeyMap, throttle } from "../../../../utils"; +import { EventSecurity } from "../../../../utils/event-security"; import { creditCardIcon, globeIcon, @@ -203,7 +204,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { private handleSaveLoginInlineMenuKeyUp = (event: KeyboardEvent) => { const listenedForKeys = new Set(["ArrowDown"]); - if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) { + if ( + /** + * Reject synthetic events (not originating from the user agent) + */ + !EventSecurity.isEventTrusted(event) || + !listenedForKeys.has(event.code) || + !(event.target instanceof Element) + ) { return; } @@ -229,7 +237,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * Handles the click event for the unlock button. * Sends a message to the parent window to unlock the vault. */ - private handleUnlockButtonClick = () => { + private handleUnlockButtonClick = (event: MouseEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + this.postMessageToParent({ command: "unlockVault" }); }; @@ -352,7 +367,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * Handles the click event for the fill generated password button. Triggers * a message to the background script to fill the generated password. */ - private handleFillGeneratedPasswordClick = () => { + private handleFillGeneratedPasswordClick = (event?: MouseEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (event && !EventSecurity.isEventTrusted(event)) { + return; + } + this.postMessageToParent({ command: "fillGeneratedPassword" }); }; @@ -362,7 +384,16 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param event - The keyup event. */ private handleFillGeneratedPasswordKeyUp = (event: KeyboardEvent) => { - if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if ( + !EventSecurity.isEventTrusted(event) || + event.ctrlKey || + event.altKey || + event.metaKey || + event.shiftKey + ) { return; } @@ -388,6 +419,13 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param event - The click event. */ private handleRefreshGeneratedPasswordClick = (event?: MouseEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (event && !EventSecurity.isEventTrusted(event)) { + return; + } + if (event) { (event.target as HTMLElement) .closest(".password-generator-actions") @@ -403,7 +441,16 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param event - The keyup event. */ private handleRefreshGeneratedPasswordKeyUp = (event: KeyboardEvent) => { - if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if ( + !EventSecurity.isEventTrusted(event) || + event.ctrlKey || + event.altKey || + event.metaKey || + event.shiftKey + ) { return; } @@ -620,7 +667,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * Handles the click event for the new item button. * Sends a message to the parent window to add a new vault item. */ - private handleNewLoginVaultItemAction = () => { + private handleNewLoginVaultItemAction = (event: MouseEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + let addNewCipherType = this.inlineMenuFillType; if (this.showInlineMenuAccountCreation) { @@ -958,7 +1012,16 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { private handleFillCipherClickEvent = (cipher: InlineMenuCipherData) => { const usePasskey = !!cipher.login?.passkey; return this.useEventHandlersMemo( - () => this.triggerFillCipherClickEvent(cipher, usePasskey), + (event: MouseEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + + this.triggerFillCipherClickEvent(cipher, usePasskey); + }, `${cipher.id}-fill-cipher-button-click-handler-${usePasskey ? "passkey" : ""}`, ); }; @@ -990,7 +1053,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { */ private handleFillCipherKeyUpEvent = (event: KeyboardEvent) => { const listenedForKeys = new Set(["ArrowDown", "ArrowUp", "ArrowRight"]); - if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if ( + !EventSecurity.isEventTrusted(event) || + !listenedForKeys.has(event.code) || + !(event.target instanceof Element) + ) { return; } @@ -1018,7 +1088,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { */ private handleNewItemButtonKeyUpEvent = (event: KeyboardEvent) => { const listenedForKeys = new Set(["ArrowDown", "ArrowUp"]); - if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if ( + !EventSecurity.isEventTrusted(event) || + !listenedForKeys.has(event.code) || + !(event.target instanceof Element) + ) { return; } @@ -1063,11 +1140,16 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param cipher - The cipher to view. */ private handleViewCipherClickEvent = (cipher: InlineMenuCipherData) => { - return this.useEventHandlersMemo( - () => - this.postMessageToParent({ command: "viewSelectedCipher", inlineMenuCipherId: cipher.id }), - `${cipher.id}-view-cipher-button-click-handler`, - ); + return this.useEventHandlersMemo((event: MouseEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + + this.postMessageToParent({ command: "viewSelectedCipher", inlineMenuCipherId: cipher.id }); + }, `${cipher.id}-view-cipher-button-click-handler`); }; /** @@ -1080,7 +1162,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { */ private handleViewCipherKeyUpEvent = (event: KeyboardEvent) => { const listenedForKeys = new Set(["ArrowDown", "ArrowUp", "ArrowLeft"]); - if (!listenedForKeys.has(event.code) || !(event.target instanceof Element)) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if ( + !EventSecurity.isEventTrusted(event) || + !listenedForKeys.has(event.code) || + !(event.target instanceof Element) + ) { return; } diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts index 5df6e7cd190..e7f99b28ecc 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts @@ -1,6 +1,7 @@ import { EVENTS } from "@bitwarden/common/autofill/constants"; import { RedirectFocusDirection } from "../../../../enums/autofill-overlay.enum"; +import { EventSecurity } from "../../../../utils/event-security"; import { AutofillInlineMenuPageElementWindowMessage, AutofillInlineMenuPageElementWindowMessageHandlers, @@ -163,7 +164,10 @@ export class AutofillInlineMenuPageElement extends HTMLElement { */ private handleDocumentKeyDownEvent = (event: KeyboardEvent) => { const listenedForKeys = new Set(["Tab", "Escape", "ArrowUp", "ArrowDown"]); - if (!listenedForKeys.has(event.code)) { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event) || !listenedForKeys.has(event.code)) { return; } 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 0fb031b52e8..c9a522c6b8c 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 @@ -23,6 +23,7 @@ import { sendMockExtensionMessage, } from "../spec/testing-utils"; import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types"; +import { EventSecurity } from "../utils/event-security"; import { AutoFillConstants } from "./autofill-constants"; import { AutofillOverlayContentService } from "./autofill-overlay-content.service"; @@ -55,6 +56,9 @@ describe("AutofillOverlayContentService", () => { const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall(); beforeEach(async () => { + // Mock EventSecurity to allow synthetic events in tests + jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true); + inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); domQueryService = new DomQueryService(); domElementVisibilityService = new DomElementVisibilityService(); @@ -331,6 +335,8 @@ describe("AutofillOverlayContentService", () => { pageDetailsMock, ); jest.spyOn(globalThis.customElements, "define").mockImplementation(); + // Mock EventSecurity to allow synthetic events in tests + jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true); }); it("closes the autofill inline menu when the `Escape` key is pressed", () => { 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 7ea89e114ab..eb02d05d671 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -45,6 +45,7 @@ import { sendExtensionMessage, throttle, } from "../utils"; +import { EventSecurity } from "../utils/event-security"; import { AutofillOverlayContentExtensionMessageHandlers, @@ -618,6 +619,10 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ */ private handleSubmitButtonInteraction = (event: PointerEvent) => { if ( + /** + * Reject synthetic events (not originating from the user agent) + */ + !EventSecurity.isEventTrusted(event) || !this.submitElements.has(event.target as HTMLElement) || (event.type === "keyup" && !["Enter", "Space"].includes((event as unknown as KeyboardEvent).code)) @@ -703,6 +708,13 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ * @param event - The keyup event. */ private handleFormFieldKeyupEvent = async (event: globalThis.KeyboardEvent) => { + /** + * Reject synthetic events (not originating from the user agent) + */ + if (!EventSecurity.isEventTrusted(event)) { + return; + } + const eventCode = event.code; if (eventCode === "Escape") { void this.sendExtensionMessage("closeAutofillInlineMenu", { diff --git a/apps/browser/src/autofill/utils/event-security.spec.ts b/apps/browser/src/autofill/utils/event-security.spec.ts new file mode 100644 index 00000000000..5cda484d4d2 --- /dev/null +++ b/apps/browser/src/autofill/utils/event-security.spec.ts @@ -0,0 +1,26 @@ +import { EventSecurity } from "./event-security"; + +describe("EventSecurity", () => { + describe("isEventTrusted", () => { + it("should call the event.isTrusted property", () => { + const testEvent = new KeyboardEvent("keyup", { code: "Escape" }); + const result = EventSecurity.isEventTrusted(testEvent); + + // In test environment, events are untrusted by default + expect(result).toBe(false); + expect(result).toBe(testEvent.isTrusted); + }); + + it("should be mockable with jest.spyOn", () => { + const testEvent = new KeyboardEvent("keyup", { code: "Escape" }); + const spy = jest.spyOn(EventSecurity, "isEventTrusted").mockReturnValue(true); + + const result = EventSecurity.isEventTrusted(testEvent); + + expect(result).toBe(true); + expect(spy).toHaveBeenCalledWith(testEvent); + + spy.mockRestore(); + }); + }); +}); diff --git a/apps/browser/src/autofill/utils/event-security.ts b/apps/browser/src/autofill/utils/event-security.ts new file mode 100644 index 00000000000..e53517058df --- /dev/null +++ b/apps/browser/src/autofill/utils/event-security.ts @@ -0,0 +1,13 @@ +/** + * Event security utilities for validating trusted events + */ +export class EventSecurity { + /** + * Validates that an event is trusted (originated from user agent) + * @param event - The event to validate + * @returns true if the event is trusted, false otherwise + */ + static isEventTrusted(event: Event): boolean { + return event.isTrusted; + } +} From 396286ff9a8f225deaa207585b44072a5eb22ec6 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:54:05 -0800 Subject: [PATCH 33/42] [PM-26703] - Update Item Action Behavior for Extension (#18921) * Revert "Revert "[PM-26703]- Browser - Update autofill Behavior (#18467)" (#18723)" This reverts commit 5d17d9ee718aba156b071493e8f57c98eed072cd. * fix title in non-autofill list * add feature flag * add old logic. add specs * revert changes * remove comments * update language in spec * update appearance spec * revert change to security-tasks * fix logic for blocked uri. add deprecated notice. * fix test * fix type error --- .../autofill-vault-list-items.component.html | 5 +- .../item-more-options.component.html | 8 +- .../item-more-options.component.ts | 21 +- .../vault-list-items-container.component.html | 63 ++-- ...ult-list-items-container.component.spec.ts | 332 ++++++++++++++++++ .../vault-list-items-container.component.ts | 164 +++++---- .../popup/settings/appearance.component.html | 16 +- .../settings/appearance.component.spec.ts | 54 ++- .../popup/settings/appearance.component.ts | 11 +- libs/common/src/enums/feature-flag.enum.ts | 2 + 10 files changed, 558 insertions(+), 118 deletions(-) create mode 100644 apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.spec.ts diff --git a/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html b/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html index 47ef0284d6a..38d60233200 100644 --- a/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html +++ b/apps/browser/src/vault/popup/components/vault/autofill-vault-list-items/autofill-vault-list-items.component.html @@ -5,8 +5,9 @@ [showRefresh]="showRefresh" (onRefresh)="refreshCurrentTab()" [description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : undefined" - showAutofillButton + isAutofillList [disableDescriptionMargin]="showEmptyAutofillTip$ | async" - [primaryActionAutofill]="clickItemsToAutofillVaultView$ | async" [groupByType]="groupByType()" + [showAutofillButton]="(clickItemsToAutofillVaultView$ | async) === false" + [primaryActionAutofill]="clickItemsToAutofillVaultView$ | async" ></app-vault-list-items-container> diff --git a/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.html index be67869d3df..4df3c8a5c73 100644 --- a/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.html +++ b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.html @@ -8,18 +8,18 @@ ></button> <bit-menu #moreOptions> @if (!decryptionFailure) { - <ng-container *ngIf="canAutofill && !hideAutofillOptions"> + @if (canAutofill && showAutofill()) { <ng-container *ngIf="autofillAllowed$ | async"> <button type="button" bitMenuItem (click)="doAutofill()"> {{ "autofill" | i18n }} </button> </ng-container> - </ng-container> - <ng-container *ngIf="showViewOption"> + } + @if (showViewOption()) { <button type="button" bitMenuItem (click)="onView()"> {{ "view" | i18n }} </button> - </ng-container> + } <button type="button" bitMenuItem (click)="toggleFavorite()"> {{ favoriteText | i18n }} </button> diff --git a/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts index 8ed2699254e..ef4c4a111b6 100644 --- a/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault/item-more-options/item-more-options.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from "@angular/common"; -import { booleanAttribute, Component, Input } from "@angular/core"; +import { booleanAttribute, Component, input, Input } from "@angular/core"; import { Router, RouterModule } from "@angular/router"; import { BehaviorSubject, combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs"; import { filter } from "rxjs/operators"; @@ -76,22 +76,17 @@ export class ItemMoreOptionsComponent { } /** - * Flag to show view item menu option. Used when something else is - * assigned as the primary action for the item, such as autofill. + * Flag to show the autofill menu option. + * When true, the "Autofill" option appears in the menu. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input({ transform: booleanAttribute }) - showViewOption = false; + readonly showAutofill = input(false, { transform: booleanAttribute }); /** - * Flag to hide the autofill menu options. Used for items that are - * already in the autofill list suggestion. + * Flag to show the view menu option. + * When true, the "View" option appears in the menu. + * Used when the primary action is autofill (so users can view without autofilling). */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input({ transform: booleanAttribute }) - hideAutofillOptions = false; + readonly showViewOption = input(false, { transform: booleanAttribute }); protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$; diff --git a/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.html index 3dac158b8e1..e9e89776dde 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.html @@ -90,11 +90,11 @@ </ng-container> <cdk-virtual-scroll-viewport [itemSize]="itemHeight$ | async" bitScrollLayout> - <bit-item *cdkVirtualFor="let cipher of group.ciphers"> + <bit-item *cdkVirtualFor="let cipher of group.ciphers" class="tw-group/vault-item"> <button bit-item-content type="button" - (click)="primaryActionOnSelect(cipher)" + (click)="onCipherSelect(cipher)" (dblclick)="launchCipher(cipher)" [appA11yTitle]=" cipherItemTitleKey()(cipher) @@ -125,32 +125,45 @@ </button> <ng-container slot="end"> - <bit-item-action *ngIf="!hideAutofillButton()"> - <button - type="button" - bitBadge - variant="primary" - (click)="doAutofill(cipher)" - [title]="autofillShortcutTooltip() ?? ('autofillTitle' | i18n: cipher.name)" - [attr.aria-label]="'autofillTitle' | i18n: cipher.name" - > - {{ "fill" | i18n }} - </button> - </bit-item-action> - <bit-item-action *ngIf="!showAutofillButton() && CipherViewLikeUtils.canLaunch(cipher)"> - <button - type="button" - bitIconButton="bwi-external-link" - size="small" - (click)="launchCipher(cipher)" - [label]="'launchWebsiteName' | i18n: cipher.name" - ></button> - </bit-item-action> + @if (showFillTextOnHover()) { + <bit-item-action> + <span + class="tw-opacity-0 tw-text-sm tw-text-primary-600 tw-px-2 group-hover/vault-item:tw-opacity-100 group-focus-within/vault-item:tw-opacity-100 tw-cursor-pointer" + > + {{ "fill" | i18n }} + </span> + </bit-item-action> + } + @if (showAutofillBadge()) { + <bit-item-action> + <button + type="button" + bitBadge + variant="primary" + (click)="doAutofill(cipher)" + [title]="autofillShortcutTooltip() ?? ('autofillTitle' | i18n: cipher.name)" + [attr.aria-label]="'autofillTitle' | i18n: cipher.name" + > + {{ "fill" | i18n }} + </button> + </bit-item-action> + } + @if (showLaunchButton() && CipherViewLikeUtils.canLaunch(cipher)) { + <bit-item-action> + <button + type="button" + bitIconButton="bwi-external-link" + size="small" + (click)="launchCipher(cipher)" + [label]="'launchWebsiteName' | i18n: cipher.name" + ></button> + </bit-item-action> + } <app-item-copy-actions [cipher]="cipher"></app-item-copy-actions> <app-item-more-options [cipher]="cipher" - [hideAutofillOptions]="hideAutofillMenuOptions()" - [showViewOption]="primaryActionAutofill()" + [showAutofill]="showAutofillInMenu()" + [showViewOption]="showViewInMenu()" ></app-item-more-options> </ng-container> </bit-item> diff --git a/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.spec.ts b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.spec.ts new file mode 100644 index 00000000000..eda84265e90 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.spec.ts @@ -0,0 +1,332 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CompactModeService, DialogService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; +import { VaultPopupSectionService } from "../../../services/vault-popup-section.service"; +import { PopupCipherViewLike } from "../../../views/popup-cipher.view"; + +import { VaultListItemsContainerComponent } from "./vault-list-items-container.component"; + +describe("VaultListItemsContainerComponent", () => { + let fixture: ComponentFixture<VaultListItemsContainerComponent>; + let component: VaultListItemsContainerComponent; + + const featureFlag$ = new BehaviorSubject<boolean>(false); + const currentTabIsOnBlocklist$ = new BehaviorSubject<boolean>(false); + + const mockCipher = { + id: "cipher-1", + name: "Test Login", + type: CipherType.Login, + login: { + username: "user@example.com", + uris: [{ uri: "https://example.com", match: null }], + }, + favorite: false, + reprompt: 0, + organizationId: null, + collectionIds: [], + edit: true, + viewPassword: true, + } as any; + + const configService = { + getFeatureFlag$: jest.fn().mockImplementation((flag: FeatureFlag) => { + if (flag === FeatureFlag.PM31039ItemActionInExtension) { + return featureFlag$.asObservable(); + } + return of(false); + }), + }; + + const vaultPopupAutofillService = { + currentTabIsOnBlocklist$: currentTabIsOnBlocklist$.asObservable(), + doAutofill: jest.fn(), + }; + + const compactModeService = { + enabled$: of(false), + }; + + const vaultPopupSectionService = { + getOpenDisplayStateForSection: jest.fn().mockReturnValue(() => true), + updateSectionOpenStoredState: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + featureFlag$.next(false); + currentTabIsOnBlocklist$.next(false); + + await TestBed.configureTestingModule({ + imports: [VaultListItemsContainerComponent, NoopAnimationsModule], + providers: [ + { provide: ConfigService, useValue: configService }, + { provide: VaultPopupAutofillService, useValue: vaultPopupAutofillService }, + { provide: CompactModeService, useValue: compactModeService }, + { provide: VaultPopupSectionService, useValue: vaultPopupSectionService }, + { provide: I18nService, useValue: { t: (k: string) => k } }, + { provide: AccountService, useValue: { activeAccount$: of({ id: "UserId" }) } }, + { provide: CipherService, useValue: mock<CipherService>() }, + { provide: Router, useValue: { navigate: jest.fn() } }, + { provide: PlatformUtilsService, useValue: { getAutofillKeyboardShortcut: () => "" } }, + { provide: DialogService, useValue: mock<DialogService>() }, + { provide: PasswordRepromptService, useValue: mock<PasswordRepromptService>() }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(VaultListItemsContainerComponent); + component = fixture.componentInstance; + }); + + describe("Updated item action feature flag", () => { + describe("when feature flag is OFF", () => { + beforeEach(() => { + featureFlag$.next(false); + fixture.detectChanges(); + }); + + it("should not show fill text on hover", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.showFillTextOnHover()).toBe(false); + }); + + it("should show autofill badge when showAutofillButton is true and primaryActionAutofill is false", () => { + fixture.componentRef.setInput("showAutofillButton", true); + fixture.componentRef.setInput("primaryActionAutofill", false); + fixture.detectChanges(); + + expect(component.showAutofillBadge()).toBe(true); + }); + + it("should hide autofill badge when primaryActionAutofill is true", () => { + fixture.componentRef.setInput("showAutofillButton", true); + fixture.componentRef.setInput("primaryActionAutofill", true); + fixture.detectChanges(); + + expect(component.showAutofillBadge()).toBe(false); + }); + + it("should show launch button when showAutofillButton is false", () => { + fixture.componentRef.setInput("showAutofillButton", false); + fixture.detectChanges(); + + expect(component.showLaunchButton()).toBe(true); + }); + + it("should hide launch button when showAutofillButton is true", () => { + fixture.componentRef.setInput("showAutofillButton", true); + fixture.detectChanges(); + + expect(component.showLaunchButton()).toBe(false); + }); + + it("should show autofill in menu when showAutofillButton is false", () => { + fixture.componentRef.setInput("showAutofillButton", false); + fixture.detectChanges(); + + expect(component.showAutofillInMenu()).toBe(true); + }); + + it("should hide autofill in menu when showAutofillButton is true", () => { + fixture.componentRef.setInput("showAutofillButton", true); + fixture.detectChanges(); + + expect(component.showAutofillInMenu()).toBe(false); + }); + + it("should show view in menu when primaryActionAutofill is true", () => { + fixture.componentRef.setInput("primaryActionAutofill", true); + fixture.detectChanges(); + + expect(component.showViewInMenu()).toBe(true); + }); + + it("should hide view in menu when primaryActionAutofill is false", () => { + fixture.componentRef.setInput("primaryActionAutofill", false); + fixture.detectChanges(); + + expect(component.showViewInMenu()).toBe(false); + }); + + it("should autofill on select when primaryActionAutofill is true", () => { + fixture.componentRef.setInput("primaryActionAutofill", true); + fixture.detectChanges(); + + expect(component.canAutofill()).toBe(true); + }); + + it("should not autofill on select when primaryActionAutofill is false", () => { + fixture.componentRef.setInput("primaryActionAutofill", false); + fixture.detectChanges(); + + expect(component.canAutofill()).toBe(false); + }); + }); + + describe("when feature flag is ON", () => { + beforeEach(() => { + featureFlag$.next(true); + fixture.detectChanges(); + }); + + it("should show fill text on hover for autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.showFillTextOnHover()).toBe(true); + }); + + it("should not show fill text on hover for non-autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + expect(component.showFillTextOnHover()).toBe(false); + }); + + it("should not show autofill badge", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.componentRef.setInput("showAutofillButton", true); + fixture.detectChanges(); + + expect(component.showAutofillBadge()).toBe(false); + }); + + it("should hide launch button for autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.showLaunchButton()).toBe(false); + }); + + it("should show launch button for non-autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + expect(component.showLaunchButton()).toBe(true); + }); + + it("should show autofill in menu for non-autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + expect(component.showAutofillInMenu()).toBe(true); + }); + + it("should hide autofill in menu for autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.showAutofillInMenu()).toBe(false); + }); + + it("should show view in menu for autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.showViewInMenu()).toBe(true); + }); + + it("should hide view in menu for non-autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + expect(component.showViewInMenu()).toBe(false); + }); + + it("should autofill on select for autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.canAutofill()).toBe(true); + }); + + it("should not autofill on select for non-autofill list items", () => { + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + expect(component.canAutofill()).toBe(false); + }); + }); + + describe("when current URI is blocked", () => { + beforeEach(() => { + currentTabIsOnBlocklist$.next(true); + fixture.detectChanges(); + }); + + it("should not autofill on select even when feature flag is ON and isAutofillList is true", () => { + featureFlag$.next(true); + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + expect(component.canAutofill()).toBe(false); + }); + + it("should not autofill on select even when primaryActionAutofill is true", () => { + featureFlag$.next(false); + fixture.componentRef.setInput("primaryActionAutofill", true); + fixture.detectChanges(); + + expect(component.canAutofill()).toBe(false); + }); + }); + }); + + describe("cipherItemTitleKey", () => { + it("should return autofillTitle when canAutofill is true", () => { + featureFlag$.next(true); + fixture.componentRef.setInput("isAutofillList", true); + fixture.detectChanges(); + + const titleKeyFn = component.cipherItemTitleKey(); + const result = titleKeyFn(mockCipher); + + expect(result).toBe("autofillTitleWithField"); + }); + + it("should return viewItemTitle when canAutofill is false", () => { + featureFlag$.next(true); + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + const titleKeyFn = component.cipherItemTitleKey(); + const result = titleKeyFn(mockCipher); + + expect(result).toBe("viewItemTitleWithField"); + }); + + it("should return title without WithField when cipher has no username", () => { + featureFlag$.next(true); + fixture.componentRef.setInput("isAutofillList", false); + fixture.detectChanges(); + + const cipherWithoutUsername = { + ...mockCipher, + login: { ...mockCipher.login, username: null }, + } as PopupCipherViewLike; + + const titleKeyFn = component.cipherItemTitleKey(); + const result = titleKeyFn(cipherWithoutUsername); + + expect(result).toBe("viewItemTitle"); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.ts index 469247f9692..fb8d20c5cf6 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-list-items-container/vault-list-items-container.component.ts @@ -21,6 +21,8 @@ import { firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; @@ -88,8 +90,15 @@ import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options export class VaultListItemsContainerComponent implements AfterViewInit { private compactModeService = inject(CompactModeService); private vaultPopupSectionService = inject(VaultPopupSectionService); + private configService = inject(ConfigService); protected CipherViewLikeUtils = CipherViewLikeUtils; + /** Signal for the feature flag that controls simplified item action behavior */ + protected readonly simplifiedItemActionEnabled = toSignal( + this.configService.getFeatureFlag$(FeatureFlag.PM31039ItemActionInExtension), + { initialValue: false }, + ); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(CdkVirtualScrollViewport, { static: false }) viewPort!: CdkVirtualScrollViewport; @@ -136,24 +145,18 @@ export class VaultListItemsContainerComponent implements AfterViewInit { */ private viewCipherTimeout?: number; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - ciphers = input<PopupCipherViewLike[]>([]); + readonly ciphers = input<PopupCipherViewLike[]>([]); /** * If true, we will group ciphers by type (Login, Card, Identity) * within subheadings in a single container, converted to a WritableSignal. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - groupByType = input<boolean | undefined>(false); + readonly groupByType = input<boolean | undefined>(false); /** * Computed signal for a grouped list of ciphers with an optional header */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - cipherGroups = computed< + readonly cipherGroups = computed< { subHeaderKey?: string; ciphers: PopupCipherViewLike[]; @@ -195,9 +198,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit { /** * Title for the vault list item section. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - title = input<string | undefined>(undefined); + readonly title = input<string | undefined>(undefined); /** * Optionally allow the items to be collapsed. @@ -205,24 +206,20 @@ export class VaultListItemsContainerComponent implements AfterViewInit { * The key must be added to the state definition in `vault-popup-section.service.ts` since the * collapsed state is stored locally. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - collapsibleKey = input<keyof PopupSectionOpen | undefined>(undefined); + readonly collapsibleKey = input<keyof PopupSectionOpen | undefined>(undefined); /** * Optional description for the vault list item section. Will be shown below the title even when * no ciphers are available. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - description = input<string | undefined>(undefined); + + readonly description = input<string | undefined>(undefined); /** * Option to show a refresh button in the section header. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - showRefresh = input(false, { transform: booleanAttribute }); + + readonly showRefresh = input(false, { transform: booleanAttribute }); /** * Event emitted when the refresh button is clicked. @@ -235,71 +232,124 @@ export class VaultListItemsContainerComponent implements AfterViewInit { /** * Flag indicating that the current tab location is blocked */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - currentURIIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$); + readonly currentUriIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$); /** * Resolved i18n key to use for suggested cipher items */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - cipherItemTitleKey = computed(() => { + readonly cipherItemTitleKey = computed(() => { return (cipher: CipherViewLike) => { const login = CipherViewLikeUtils.getLogin(cipher); const hasUsername = login?.username != null; - const key = - this.primaryActionAutofill() && !this.currentURIIsBlocked() - ? "autofillTitle" - : "viewItemTitle"; + // Use autofill title when autofill is the primary action + const key = this.canAutofill() ? "autofillTitle" : "viewItemTitle"; return hasUsername ? `${key}WithField` : key; }; }); /** + * @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out * Option to show the autofill button for each item. + * Used when feature flag is disabled. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - showAutofillButton = input(false, { transform: booleanAttribute }); + readonly showAutofillButton = input(false, { transform: booleanAttribute }); /** - * Flag indicating whether the suggested cipher item autofill button should be shown or not + * @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out + * Whether to show the autofill badge button (old behavior). + * Only shown when feature flag is disabled AND conditions are met. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - hideAutofillButton = computed( - () => !this.showAutofillButton() || this.currentURIIsBlocked() || this.primaryActionAutofill(), + readonly showAutofillBadge = computed( + () => !this.simplifiedItemActionEnabled() && !this.hideAutofillButton(), ); /** - * Flag indicating whether the cipher item autofill menu options should be shown or not + * @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out + * Flag indicating whether the cipher item autofill menu options should be shown or not. + * Used when feature flag is disabled. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - hideAutofillMenuOptions = computed(() => this.currentURIIsBlocked() || this.showAutofillButton()); + readonly hideAutofillMenuOptions = computed( + () => this.currentUriIsBlocked() || this.showAutofillButton(), + ); /** + * @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out * Option to perform autofill operation as the primary action for autofill suggestions. + * Used when feature flag is disabled. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - primaryActionAutofill = input(false, { transform: booleanAttribute }); + readonly primaryActionAutofill = input(false, { transform: booleanAttribute }); + + /** + * @deprecated - To be removed when PM31039ItemActionInExtension is fully rolled out + * Flag indicating whether the suggested cipher item autofill button should be shown or not. + * Used when feature flag is disabled. + */ + readonly hideAutofillButton = computed( + () => !this.showAutofillButton() || this.currentUriIsBlocked() || this.primaryActionAutofill(), + ); + + /** + * Option to mark this container as an autofill list. + */ + readonly isAutofillList = input(false, { transform: booleanAttribute }); + + /** + * Computed property whether the cipher action may perform autofill. + * When feature flag is enabled, uses isAutofillList. + * When feature flag is disabled, uses primaryActionAutofill. + */ + readonly canAutofill = computed(() => { + if (this.currentUriIsBlocked()) { + return false; + } + return this.isAutofillList() + ? this.simplifiedItemActionEnabled() + : this.primaryActionAutofill(); + }); + + /** + * Whether to show the "Fill" text on hover. + * Only shown when feature flag is enabled AND this is an autofill list. + */ + readonly showFillTextOnHover = computed( + () => this.simplifiedItemActionEnabled() && this.canAutofill(), + ); + + /** + * Whether to show the launch button. + */ + readonly showLaunchButton = computed(() => + this.simplifiedItemActionEnabled() ? !this.isAutofillList() : !this.showAutofillButton(), + ); + + /** + * Whether to show the "Autofill" option in the more options menu. + * New behavior: show for non-autofill list items. + * Old behavior: show when not hidden by hideAutofillMenuOptions. + */ + readonly showAutofillInMenu = computed(() => + this.simplifiedItemActionEnabled() ? !this.canAutofill() : !this.hideAutofillMenuOptions(), + ); + + /** + * Whether to show the "View" option in the more options menu. + * New behavior: show for autofill list items (since click = autofill). + * Old behavior: show when primary action is autofill. + */ + readonly showViewInMenu = computed(() => + this.simplifiedItemActionEnabled() ? this.isAutofillList() : this.primaryActionAutofill(), + ); /** * Remove the bottom margin from the bit-section in this component * (used for containers at the end of the page where bottom margin is not needed) */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - disableSectionMargin = input(false, { transform: booleanAttribute }); + readonly disableSectionMargin = input(false, { transform: booleanAttribute }); /** * Remove the description margin */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - disableDescriptionMargin = input(false, { transform: booleanAttribute }); + readonly disableDescriptionMargin = input(false, { transform: booleanAttribute }); /** * The tooltip text for the organization icon for ciphers that belong to an organization. @@ -313,9 +363,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit { return collections[0]?.name; } - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - protected autofillShortcutTooltip = signal<string | undefined>(undefined); + protected readonly autofillShortcutTooltip = signal<string | undefined>(undefined); constructor( private i18nService: I18nService, @@ -340,10 +388,8 @@ export class VaultListItemsContainerComponent implements AfterViewInit { } } - primaryActionOnSelect(cipher: PopupCipherViewLike) { - return this.primaryActionAutofill() && !this.currentURIIsBlocked() - ? this.doAutofill(cipher) - : this.onViewCipher(cipher); + onCipherSelect(cipher: PopupCipherViewLike) { + return this.canAutofill() ? this.doAutofill(cipher) : this.onViewCipher(cipher); } /** diff --git a/apps/browser/src/vault/popup/settings/appearance.component.html b/apps/browser/src/vault/popup/settings/appearance.component.html index b58316a8d64..d87c0640f52 100644 --- a/apps/browser/src/vault/popup/settings/appearance.component.html +++ b/apps/browser/src/vault/popup/settings/appearance.component.html @@ -50,16 +50,18 @@ <vault-permit-cipher-details-popover></vault-permit-cipher-details-popover> </bit-label> </bit-form-control> - <bit-form-control> + <bit-form-control [disableMargin]="simplifiedItemActionEnabled()"> <input bitCheckbox formControlName="showQuickCopyActions" type="checkbox" /> <bit-label>{{ "showQuickCopyActions" | i18n }}</bit-label> </bit-form-control> - <bit-form-control disableMargin> - <input bitCheckbox formControlName="clickItemsToAutofillVaultView" type="checkbox" /> - <bit-label> - {{ "clickToAutofill" | i18n }} - </bit-label> - </bit-form-control> + @if (!simplifiedItemActionEnabled()) { + <bit-form-control disableMargin> + <input bitCheckbox formControlName="clickItemsToAutofillVaultView" type="checkbox" /> + <bit-label> + {{ "clickToAutofill" | i18n }} + </bit-label> + </bit-form-control> + } </bit-card> </form> </popup-page> diff --git a/apps/browser/src/vault/popup/settings/appearance.component.spec.ts b/apps/browser/src/vault/popup/settings/appearance.component.spec.ts index 41e89ec30e8..465b78e232d 100644 --- a/apps/browser/src/vault/popup/settings/appearance.component.spec.ts +++ b/apps/browser/src/vault/popup/settings/appearance.component.spec.ts @@ -1,10 +1,12 @@ import { Component, Input } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { mock } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -59,7 +61,7 @@ describe("AppearanceComponent", () => { const enableRoutingAnimation$ = new BehaviorSubject<boolean>(true); const enableCompactMode$ = new BehaviorSubject<boolean>(false); const showQuickCopyActions$ = new BehaviorSubject<boolean>(false); - const clickItemsToAutofillVaultView$ = new BehaviorSubject<boolean>(false); + const featureFlag$ = new BehaviorSubject<boolean>(false); const setSelectedTheme = jest.fn().mockResolvedValue(undefined); const setShowFavicons = jest.fn().mockResolvedValue(undefined); const setEnableBadgeCounter = jest.fn().mockResolvedValue(undefined); @@ -78,11 +80,20 @@ describe("AppearanceComponent", () => { setShowFavicons.mockClear(); setEnableBadgeCounter.mockClear(); setEnableRoutingAnimation.mockClear(); + setClickItemsToAutofillVaultView.mockClear(); + + const configService = mock<ConfigService>(); + configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => { + if (flag === FeatureFlag.PM31039ItemActionInExtension) { + return featureFlag$.asObservable(); + } + return of(false); + }); await TestBed.configureTestingModule({ imports: [AppearanceComponent], providers: [ - { provide: ConfigService, useValue: mock<ConfigService>() }, + { provide: ConfigService, useValue: configService }, { provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() }, { provide: MessagingService, useValue: mock<MessagingService>() }, { provide: I18nService, useValue: { t: (key: string) => key } }, @@ -114,7 +125,7 @@ describe("AppearanceComponent", () => { { provide: VaultSettingsService, useValue: { - clickItemsToAutofillVaultView$, + clickItemsToAutofillVaultView$: of(false), setClickItemsToAutofillVaultView, }, }, @@ -193,11 +204,40 @@ describe("AppearanceComponent", () => { expect(mockWidthService.setWidth).toHaveBeenCalledWith("wide"); }); + }); - it("updates the click items to autofill vault view setting", () => { - component.appearanceForm.controls.clickItemsToAutofillVaultView.setValue(true); + describe("PM31039ItemActionInExtension feature flag", () => { + describe("when set to OFF", () => { + it("should show clickItemsToAutofillVaultView checkbox", () => { + featureFlag$.next(false); + fixture.detectChanges(); - expect(setClickItemsToAutofillVaultView).toHaveBeenCalledWith(true); + const checkbox = fixture.debugElement.query( + By.css('input[formControlName="clickItemsToAutofillVaultView"]'), + ); + expect(checkbox).not.toBeNull(); + }); + + it("should update the clickItemsToAutofillVaultView setting when changed", () => { + featureFlag$.next(false); + fixture.detectChanges(); + + component.appearanceForm.controls.clickItemsToAutofillVaultView.setValue(true); + + expect(setClickItemsToAutofillVaultView).toHaveBeenCalledWith(true); + }); + }); + + describe("when set to ON", () => { + it("should hide clickItemsToAutofillVaultView checkbox", () => { + featureFlag$.next(true); + fixture.detectChanges(); + + const checkbox = fixture.debugElement.query( + By.css('input[formControlName="clickItemsToAutofillVaultView"]'), + ); + expect(checkbox).toBeNull(); + }); }); }); }); diff --git a/apps/browser/src/vault/popup/settings/appearance.component.ts b/apps/browser/src/vault/popup/settings/appearance.component.ts index bff51335192..47aa1804efc 100644 --- a/apps/browser/src/vault/popup/settings/appearance.component.ts +++ b/apps/browser/src/vault/popup/settings/appearance.component.ts @@ -2,14 +2,16 @@ // @ts-strict-ignore import { CommonModule } from "@angular/common"; import { Component, DestroyRef, inject, OnInit } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; @@ -57,6 +59,13 @@ export class AppearanceComponent implements OnInit { private copyButtonsService = inject(VaultPopupCopyButtonsService); private popupSizeService = inject(PopupSizeService); private i18nService = inject(I18nService); + private configService = inject(ConfigService); + + /** Signal for the feature flag that controls simplified item action behavior */ + protected readonly simplifiedItemActionEnabled = toSignal( + this.configService.getFeatureFlag$(FeatureFlag.PM31039ItemActionInExtension), + { initialValue: false }, + ); appearanceForm = this.formBuilder.group({ enableFavicon: false, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 1edbcc4e376..4db9ff37d42 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -72,6 +72,7 @@ export enum FeatureFlag { BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight", MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems", PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk", + PM31039ItemActionInExtension = "pm-31039-item-action-in-extension", /* Platform */ ContentScriptIpcChannelFramework = "content-script-ipc-channel-framework", @@ -117,6 +118,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.WindowsDesktopAutotype]: FALSE, [FeatureFlag.WindowsDesktopAutotypeGA]: FALSE, [FeatureFlag.SSHAgentV2]: FALSE, + [FeatureFlag.PM31039ItemActionInExtension]: FALSE, /* Tools */ [FeatureFlag.UseSdkPasswordGenerators]: FALSE, From d06a895c78ab49fe2fdbe36cbc9f20cf645074ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ch=C4=99ci=C5=84ski?= <mchecinski@bitwarden.com> Date: Thu, 12 Feb 2026 09:13:45 +0100 Subject: [PATCH 34/42] [BRE-1561] Fix flatpak install build desktop (#18814) * Remove redundant flatpak installation command in build workflow * Try select one of the packages * Update .github/workflows/build-desktop.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/build-desktop.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index c500e59d536..f3b76ae462d 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -2054,7 +2054,6 @@ jobs: sudo apt-get update sudo apt-get install -y libasound2 flatpak xvfb dbus-x11 flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo - flatpak install -y --user flathub - name: Install flatpak working-directory: apps/desktop/artifacts/linux/flatpak From 9d69b15798986d545695d5d01c2a8a621544ff24 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann <mail@quexten.com> Date: Thu, 12 Feb 2026 10:31:48 +0100 Subject: [PATCH 35/42] [PM-32063] Disable cipher-key-downgrading (#18911) * Proposal: Disable cipher-key-downgrading * Cleanup --- .../src/vault/services/cipher.service.ts | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 6373a511724..06c6628f158 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1191,33 +1191,28 @@ export class CipherService implements CipherServiceAbstraction { userId: UserId, admin = false, ): Promise<Cipher> { - const encKey = await this.getKeyForCipherKeyDecryption(cipher, userId); - const cipherKeyEncryptionEnabled = await this.getCipherKeyEncryptionEnabled(); + // The organization's symmetric key or the user's user key + const vaultKey = await this.getKeyForCipherKeyDecryption(cipher, userId); - const cipherEncKey = - cipherKeyEncryptionEnabled && cipher.key != null - ? ((await this.encryptService.unwrapSymmetricKey(cipher.key, encKey)) as UserKey) - : encKey; + const cipherKeyOrVaultKey = + cipher.key != null + ? ((await this.encryptService.unwrapSymmetricKey(cipher.key, vaultKey)) as UserKey) + : vaultKey; - //if cipher key encryption is disabled but the item has an individual key, - //then we rollback to using the user key as the main key of encryption of the item - //in order to keep item and it's attachments with the same encryption level - if (cipher.key != null && !cipherKeyEncryptionEnabled) { - const model = await this.decrypt(cipher, userId); - await this.updateWithServer(model, userId); - } + const encFileName = await this.encryptService.encryptString(filename, cipherKeyOrVaultKey); - const encFileName = await this.encryptService.encryptString(filename, cipherEncKey); - - const dataEncKey = await this.keyService.makeDataEncKey(cipherEncKey); - const encData = await this.encryptService.encryptFileData(new Uint8Array(data), dataEncKey[0]); + const attachmentKey = await this.keyService.makeDataEncKey(cipherKeyOrVaultKey); + const encData = await this.encryptService.encryptFileData( + new Uint8Array(data), + attachmentKey[0], + ); const response = await this.cipherFileUploadService.upload( cipher, encFileName, encData, admin, - dataEncKey, + attachmentKey, ); const cData = new CipherData(response, cipher.collectionIds); From ad8bde057f797195bbb9fe4b9b3b5cd870a9b54b Mon Sep 17 00:00:00 2001 From: Will Martin <contact@willmartian.com> Date: Thu, 12 Feb 2026 09:50:31 -0500 Subject: [PATCH 36/42] Fix EventListener type errors in inline menu list handlers (#18943) Changed event parameter type from MouseEvent to Event in handleFillCipherClickEvent and handleViewCipherClickEvent to match the EventListener interface expected by useEventHandlersMemo. Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> --- .../inline-menu/pages/list/autofill-inline-menu-list.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts index 39b7fa5c17b..744e3579da1 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts @@ -1012,7 +1012,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { private handleFillCipherClickEvent = (cipher: InlineMenuCipherData) => { const usePasskey = !!cipher.login?.passkey; return this.useEventHandlersMemo( - (event: MouseEvent) => { + (event: Event) => { /** * Reject synthetic events (not originating from the user agent) */ @@ -1140,7 +1140,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * @param cipher - The cipher to view. */ private handleViewCipherClickEvent = (cipher: InlineMenuCipherData) => { - return this.useEventHandlersMemo((event: MouseEvent) => { + return this.useEventHandlersMemo((event: Event) => { /** * Reject synthetic events (not originating from the user agent) */ From 7fcb1a7a7660ed2e1440b1a501c35a7dfc319389 Mon Sep 17 00:00:00 2001 From: blackwood <mrobinson@bitwarden.com> Date: Thu, 12 Feb 2026 10:39:41 -0500 Subject: [PATCH 37/42] Expand generic pattern for notification queue messages. (#18543) --- .../abstractions/notification.background.ts | 99 ++++++++----------- .../notification.background.spec.ts | 96 ++++++++++++++---- .../background/notification.background.ts | 38 ++++--- 3 files changed, 146 insertions(+), 87 deletions(-) diff --git a/apps/browser/src/autofill/background/abstractions/notification.background.ts b/apps/browser/src/autofill/background/abstractions/notification.background.ts index e50a317e8a7..bc416d98634 100644 --- a/apps/browser/src/autofill/background/abstractions/notification.background.ts +++ b/apps/browser/src/autofill/background/abstractions/notification.background.ts @@ -4,70 +4,70 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { CollectionView } from "../../content/components/common-types"; -import { NotificationType, NotificationTypes } from "../../enums/notification-type.enum"; +import { NotificationType } from "../../enums/notification-type.enum"; import AutofillPageDetails from "../../models/autofill-page-details"; /** - * @todo Remove Standard_ label when implemented as standard NotificationQueueMessage. + * Generic notification queue message structure. + * All notification types use this structure with type-specific data. */ -export interface Standard_NotificationQueueMessage<T, D> { - // universal notification properties +export interface NotificationQueueMessage<T, D> { domain: string; tab: chrome.tabs.Tab; launchTimestamp: number; expires: Date; wasVaultLocked: boolean; - - type: T; // NotificationType - data: D; // notification-specific data + type: T; + data: D; } -/** - * @todo Deprecate in favor of Standard_NotificationQueueMessage. - */ -interface NotificationQueueMessage { - type: NotificationTypes; - domain: string; - tab: chrome.tabs.Tab; - launchTimestamp: number; - expires: Date; - wasVaultLocked: boolean; -} +// Notification data type definitions +export type AddLoginNotificationData = { + username: string; + password: string; + uri: string; +}; -type ChangePasswordNotificationData = { +export type ChangePasswordNotificationData = { cipherIds: CipherView["id"][]; newPassword: string; }; -type AddChangePasswordNotificationQueueMessage = Standard_NotificationQueueMessage< +export type UnlockVaultNotificationData = never; + +export type AtRiskPasswordNotificationData = { + organizationName: string; + passwordChangeUri?: string; +}; + +// Notification queue message types using generic pattern +export type AddLoginQueueMessage = NotificationQueueMessage< + typeof NotificationType.AddLogin, + AddLoginNotificationData +>; + +export type AddChangePasswordNotificationQueueMessage = NotificationQueueMessage< typeof NotificationType.ChangePassword, ChangePasswordNotificationData >; -interface AddLoginQueueMessage extends NotificationQueueMessage { - type: "add"; - username: string; - password: string; - uri: string; -} +export type AddUnlockVaultQueueMessage = NotificationQueueMessage< + typeof NotificationType.UnlockVault, + UnlockVaultNotificationData +>; -interface AddUnlockVaultQueueMessage extends NotificationQueueMessage { - type: "unlock"; -} +export type AtRiskPasswordQueueMessage = NotificationQueueMessage< + typeof NotificationType.AtRiskPassword, + AtRiskPasswordNotificationData +>; -interface AtRiskPasswordQueueMessage extends NotificationQueueMessage { - type: "at-risk-password"; - organizationName: string; - passwordChangeUri?: string; -} - -type NotificationQueueMessageItem = +export type NotificationQueueMessageItem = | AddLoginQueueMessage | AddChangePasswordNotificationQueueMessage | AddUnlockVaultQueueMessage | AtRiskPasswordQueueMessage; -type LockedVaultPendingNotificationsData = { +export type LockedVaultPendingNotificationsData = { commandToRetry: { message: { command: string; @@ -80,26 +80,26 @@ type LockedVaultPendingNotificationsData = { target: string; }; -type AdjustNotificationBarMessageData = { +export type AdjustNotificationBarMessageData = { height: number; }; -type AddLoginMessageData = { +export type AddLoginMessageData = { username: string; password: string; url: string; }; -type UnlockVaultMessageData = { +export type UnlockVaultMessageData = { skipNotification?: boolean; }; /** - * @todo Extend generics to this type, see Standard_NotificationQueueMessage + * @todo Extend generics to this type, see NotificationQueueMessage * - use new `data` types as generic * - eliminate optional status of properties as needed per Notification Type */ -type NotificationBackgroundExtensionMessage = { +export type NotificationBackgroundExtensionMessage = { [key: string]: any; command: string; data?: Partial<LockedVaultPendingNotificationsData> & @@ -119,7 +119,7 @@ type BackgroundMessageParam = { message: NotificationBackgroundExtensionMessage type BackgroundSenderParam = { sender: chrome.runtime.MessageSender }; type BackgroundOnMessageHandlerParams = BackgroundMessageParam & BackgroundSenderParam; -type NotificationBackgroundExtensionMessageHandlers = { +export type NotificationBackgroundExtensionMessageHandlers = { [key: string]: CallableFunction; unlockCompleted: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>; bgGetFolderData: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<FolderView[]>; @@ -150,16 +150,3 @@ type NotificationBackgroundExtensionMessageHandlers = { bgGetActiveUserServerConfig: () => Promise<ServerConfig | null>; getWebVaultUrlForNotification: () => Promise<string>; }; - -export { - AddChangePasswordNotificationQueueMessage, - AddLoginQueueMessage, - AddUnlockVaultQueueMessage, - NotificationQueueMessageItem, - LockedVaultPendingNotificationsData, - AdjustNotificationBarMessageData, - UnlockVaultMessageData, - AddLoginMessageData, - NotificationBackgroundExtensionMessage, - NotificationBackgroundExtensionMessageHandlers, -}; diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index 7d33d79a697..95d4111987b 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -126,9 +126,11 @@ describe("NotificationBackground", () => { it("returns a cipher view when passed an `AddLoginQueueMessage`", () => { const message: AddLoginQueueMessage = { type: "add", - username: "test", - password: "password", - uri: "https://example.com", + data: { + username: "test", + password: "password", + uri: "https://example.com", + }, domain: "", tab: createChromeTabMock(), expires: new Date(), @@ -140,13 +142,13 @@ describe("NotificationBackground", () => { expect(cipherView.name).toEqual("example.com"); expect(cipherView.login).toEqual({ fido2Credentials: [], - password: message.password, + password: message.data.password, uris: [ { - _uri: message.uri, + _uri: message.data.uri, }, ], - username: message.username, + username: message.data.username, }); }); @@ -154,9 +156,11 @@ describe("NotificationBackground", () => { const folderId = "folder-id"; const message: AddLoginQueueMessage = { type: "add", - username: "test", - password: "password", - uri: "https://example.com", + data: { + username: "test", + password: "password", + uri: "https://example.com", + }, domain: "example.com", tab: createChromeTabMock(), expires: new Date(), @@ -170,6 +174,44 @@ describe("NotificationBackground", () => { expect(cipherView.folderId).toEqual(folderId); }); + + it("removes 'www.' prefix from hostname when generating cipher name", () => { + const message: AddLoginQueueMessage = { + type: "add", + data: { + username: "test", + password: "password", + uri: "https://www.example.com", + }, + domain: "www.example.com", + tab: createChromeTabMock(), + expires: new Date(), + wasVaultLocked: false, + launchTimestamp: 0, + }; + const cipherView = notificationBackground["convertAddLoginQueueMessageToCipherView"](message); + + expect(cipherView.name).toEqual("example.com"); + }); + + it("uses domain as fallback when hostname cannot be extracted from uri", () => { + const message: AddLoginQueueMessage = { + type: "add", + data: { + username: "test", + password: "password", + uri: "", + }, + domain: "fallback-domain.com", + tab: createChromeTabMock(), + expires: new Date(), + wasVaultLocked: false, + launchTimestamp: 0, + }; + const cipherView = notificationBackground["convertAddLoginQueueMessageToCipherView"](message); + + expect(cipherView.name).toEqual("fallback-domain.com"); + }); }); describe("notification bar extension message handlers and triggers", () => { @@ -2544,8 +2586,11 @@ describe("NotificationBackground", () => { type: NotificationType.AddLogin, tab, domain: "example.com", - username: "test", - password: "updated-password", + data: { + username: "test", + password: "updated-password", + uri: "https://example.com", + }, wasVaultLocked: true, }); notificationBackground["notificationQueue"] = [queueMessage]; @@ -2559,7 +2604,7 @@ describe("NotificationBackground", () => { expect(updatePasswordSpy).toHaveBeenCalledWith( cipherView, - queueMessage.password, + queueMessage.data.password, message.edit, sender.tab, "testId", @@ -2631,9 +2676,14 @@ describe("NotificationBackground", () => { type: NotificationType.AddLogin, tab, domain: "example.com", - username: "test", - password: "password", + data: { + username: "test", + password: "password", + uri: "https://example.com", + }, wasVaultLocked: false, + launchTimestamp: Date.now(), + expires: new Date(Date.now() + 10000), }); notificationBackground["notificationQueue"] = [queueMessage]; const cipherView = mock<CipherView>({ @@ -2670,9 +2720,14 @@ describe("NotificationBackground", () => { type: NotificationType.AddLogin, tab, domain: "example.com", - username: "test", - password: "password", + data: { + username: "test", + password: "password", + uri: "https://example.com", + }, wasVaultLocked: false, + launchTimestamp: Date.now(), + expires: new Date(Date.now() + 10000), }); notificationBackground["notificationQueue"] = [queueMessage]; const cipherView = mock<CipherView>({ @@ -2716,9 +2771,14 @@ describe("NotificationBackground", () => { type: NotificationType.AddLogin, tab, domain: "example.com", - username: "test", - password: "password", + data: { + username: "test", + password: "password", + uri: "https://example.com", + }, wasVaultLocked: false, + launchTimestamp: Date.now(), + expires: new Date(Date.now() + 10000), }); notificationBackground["notificationQueue"] = [queueMessage]; const cipherView = mock<CipherView>({ diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index e97672c1f0d..3713cd7c4c2 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -68,6 +68,7 @@ import { AddChangePasswordNotificationQueueMessage, AddLoginQueueMessage, AddLoginMessageData, + AtRiskPasswordQueueMessage, NotificationQueueMessageItem, LockedVaultPendingNotificationsData, NotificationBackgroundExtensionMessage, @@ -528,12 +529,14 @@ export default class NotificationBackground { this.removeTabFromNotificationQueue(tab); const launchTimestamp = new Date().getTime(); - const queueMessage: NotificationQueueMessageItem = { + const queueMessage: AtRiskPasswordQueueMessage = { domain, wasVaultLocked, type: NotificationType.AtRiskPassword, - passwordChangeUri, - organizationName: organization.name, + data: { + passwordChangeUri, + organizationName: organization.name, + }, tab: tab, launchTimestamp, expires: new Date(launchTimestamp + NOTIFICATION_BAR_LIFESPAN_MS), @@ -612,10 +615,12 @@ export default class NotificationBackground { const launchTimestamp = new Date().getTime(); const message: AddLoginQueueMessage = { type: NotificationType.AddLogin, - username: loginInfo.username, - password: loginInfo.password, + data: { + username: loginInfo.username, + password: loginInfo.password, + uri: loginInfo.url, + }, domain: loginDomain, - uri: loginInfo.url, tab: tab, launchTimestamp, expires: new Date(launchTimestamp + NOTIFICATION_BAR_LIFESPAN_MS), @@ -1291,16 +1296,23 @@ export default class NotificationBackground { // If the vault was locked, check if a cipher needs updating instead of creating a new one if (queueMessage.wasVaultLocked) { const allCiphers = await this.cipherService.getAllDecryptedForUrl( - queueMessage.uri, + queueMessage.data.uri, activeUserId, ); const existingCipher = allCiphers.find( (c) => - c.login.username != null && c.login.username.toLowerCase() === queueMessage.username, + c.login.username != null && + c.login.username.toLowerCase() === queueMessage.data.username, ); if (existingCipher != null) { - await this.updatePassword(existingCipher, queueMessage.password, edit, tab, activeUserId); + await this.updatePassword( + existingCipher, + queueMessage.data.password, + edit, + tab, + activeUserId, + ); return; } } @@ -1721,15 +1733,15 @@ export default class NotificationBackground { folderId?: string, ): CipherView { const uriView = new LoginUriView(); - uriView.uri = message.uri; + uriView.uri = message.data.uri; const loginView = new LoginView(); loginView.uris = [uriView]; - loginView.username = message.username; - loginView.password = message.password; + loginView.username = message.data.username; + loginView.password = message.data.password; const cipherView = new CipherView(); - cipherView.name = (Utils.getHostname(message.uri) || message.domain).replace(/^www\./, ""); + cipherView.name = (Utils.getHostname(message.data.uri) || message.domain).replace(/^www\./, ""); cipherView.folderId = folderId; cipherView.type = CipherType.Login; cipherView.login = loginView; From 5c7ee4e63a3eba07f05b378e3e38c71de024fcfe Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann <mail@quexten.com> Date: Thu, 12 Feb 2026 16:43:54 +0100 Subject: [PATCH 38/42] Add more package types (#18939) --- .../platform/services/electron-platform-utils.service.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/platform/services/electron-platform-utils.service.ts b/apps/desktop/src/platform/services/electron-platform-utils.service.ts index 9377ac567ec..70c28c66353 100644 --- a/apps/desktop/src/platform/services/electron-platform-utils.service.ts +++ b/apps/desktop/src/platform/services/electron-platform-utils.service.ts @@ -163,8 +163,14 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService { return "Snap"; } else if (ipc.platform.isFlatpak) { return "Flatpak"; + } else if (this.getDevice() === DeviceType.WindowsDesktop) { + return "WindowsUnknown"; + } else if (this.getDevice() === DeviceType.MacOsDesktop) { + return "MacOSUnknown"; + } else if (this.getDevice() === DeviceType.LinuxDesktop) { + return "LinuxUnknown"; } else { - return "Unknown"; + return "DesktopUnknown"; } } } From 4d93348a2ed25d04337b6b989f0b68d16550d55d Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Thu, 12 Feb 2026 10:51:31 -0600 Subject: [PATCH 39/42] [PM-30812] Update userKey rotation to use saltForUser (#18697) --- .../user-key-rotation.service.spec.ts | 33 ++++++++++--------- .../key-rotation/user-key-rotation.service.ts | 19 +++++++---- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts index c0b734f17cc..a2330025c92 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts @@ -1,7 +1,8 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/admin-console/common"; +import { LogoutService } from "@bitwarden/auth/common"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -12,6 +13,8 @@ import { EncString, } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; +import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types"; import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; import { SignedPublicKey, @@ -21,7 +24,6 @@ import { WrappedPrivateKey, WrappedSigningKey, } from "@bitwarden/common/key-management/types"; -import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -276,7 +278,7 @@ describe("KeyRotationService", () => { let mockSyncService: MockProxy<SyncService>; let mockWebauthnLoginAdminService: MockProxy<WebauthnLoginAdminService>; let mockLogService: MockProxy<LogService>; - let mockVaultTimeoutService: MockProxy<VaultTimeoutService>; + let mockLogoutService: MockProxy<LogoutService>; let mockDialogService: MockProxy<DialogService>; let mockToastService: MockProxy<ToastService>; let mockI18nService: MockProxy<I18nService>; @@ -284,6 +286,7 @@ describe("KeyRotationService", () => { let mockKdfConfigService: MockProxy<KdfConfigService>; let mockSdkClientFactory: MockProxy<SdkClientFactory>; let mockSecurityStateService: MockProxy<SecurityStateService>; + let mockMasterPasswordService: MockProxy<MasterPasswordServiceAbstraction>; const mockUser = { id: "mockUserId" as UserId, @@ -293,6 +296,8 @@ describe("KeyRotationService", () => { }), }; + const mockUserSalt = "usersalt"; + const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("test-public-key")]; const mockMakeKeysForUserCryptoV2 = jest.fn(); @@ -337,7 +342,7 @@ describe("KeyRotationService", () => { mockSyncService = mock<SyncService>(); mockWebauthnLoginAdminService = mock<WebauthnLoginAdminService>(); mockLogService = mock<LogService>(); - mockVaultTimeoutService = mock<VaultTimeoutService>(); + mockLogoutService = mock<LogoutService>(); mockToastService = mock<ToastService>(); mockI18nService = mock<I18nService>(); mockDialogService = mock<DialogService>(); @@ -354,6 +359,7 @@ describe("KeyRotationService", () => { }, } as BitwardenClient); mockSecurityStateService = mock<SecurityStateService>(); + mockMasterPasswordService = mock<MasterPasswordServiceAbstraction>(); keyRotationService = new TestUserKeyRotationService( mockApiService, @@ -368,7 +374,7 @@ describe("KeyRotationService", () => { mockSyncService, mockWebauthnLoginAdminService, mockLogService, - mockVaultTimeoutService, + mockLogoutService, mockToastService, mockI18nService, mockDialogService, @@ -377,6 +383,7 @@ describe("KeyRotationService", () => { mockKdfConfigService, mockSdkClientFactory, mockSecurityStateService, + mockMasterPasswordService, ); }); @@ -391,10 +398,10 @@ describe("KeyRotationService", () => { value: Promise.resolve(), configurable: true, }); + mockMasterPasswordService.saltForUser$.mockReturnValue(of(mockUserSalt as MasterPasswordSalt)); }); describe("rotateUserKeyMasterPasswordAndEncryptedData", () => { - let privateKey: BehaviorSubject<UserPrivateKey | null>; let keyPair: BehaviorSubject<{ privateKey: UserPrivateKey; publicKey: UserPublicKey }>; beforeEach(() => { @@ -420,10 +427,6 @@ describe("KeyRotationService", () => { mockKeyService.getFingerprint.mockResolvedValue(["a", "b"]); - // Mock private key - privateKey = new BehaviorSubject("mockPrivateKey" as any); - mockKeyService.userPrivateKeyWithLegacySupport$.mockReturnValue(privateKey); - keyPair = new BehaviorSubject({ privateKey: "mockPrivateKey", publicKey: "mockPublicKey", @@ -543,7 +546,7 @@ describe("KeyRotationService", () => { expect(spy).toHaveBeenCalledWith( mockUser.id, expect.any(PBKDF2KdfConfig), - mockUser.email, + mockUserSalt, expect.objectContaining({ version: 1 }), true, ); @@ -683,7 +686,7 @@ describe("KeyRotationService", () => { }, signingKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey, securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState, - }, + } as V2CryptographicStateParameters, ); expect(mockGetV2RotatedAccountKeys).toHaveBeenCalled(); expect(result).toEqual({ @@ -810,7 +813,7 @@ describe("KeyRotationService", () => { masterPasswordHash: "omitted", otp: undefined, authRequestAccessCode: undefined, - }, + } as OrganizationUserResetPasswordWithIdRequest, ]); mockKeyService.makeMasterKey.mockResolvedValue( new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey, @@ -1122,7 +1125,7 @@ describe("KeyRotationService", () => { const cryptographicState = await keyRotationService.getCryptographicStateForUser(mockUser); expect(cryptographicState).toEqual({ masterKeyKdfConfig: new PBKDF2KdfConfig(100000), - masterKeySalt: "mockemail", // the email is lowercased to become the salt + masterKeySalt: mockUserSalt, cryptographicStateParameters: { version: 1, userKey: TEST_VECTOR_USER_KEY_V1, @@ -1138,7 +1141,7 @@ describe("KeyRotationService", () => { const cryptographicState = await keyRotationService.getCryptographicStateForUser(mockUser); expect(cryptographicState).toEqual({ masterKeyKdfConfig: new PBKDF2KdfConfig(100000), - masterKeySalt: "mockemail", // the email is lowercased to become the salt + masterKeySalt: mockUserSalt, cryptographicStateParameters: { version: 2, userKey: TEST_VECTOR_USER_KEY_V2, diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts index b9bd23b12de..68253a4a35d 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts @@ -8,6 +8,7 @@ import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/a import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; +import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; import { SignedPublicKey, @@ -99,6 +100,7 @@ export class UserKeyRotationService { private kdfConfigService: KdfConfigService, private sdkClientFactory: SdkClientFactory, private securityStateService: SecurityStateService, + private masterPasswordService: MasterPasswordServiceAbstraction, ) {} /** @@ -146,7 +148,7 @@ export class UserKeyRotationService { const { userKey: newUserKey, accountKeysRequest } = await this.getRotatedAccountKeysFlagged( user.id, masterKeyKdfConfig, - user.email, + masterKeySalt, currentCryptographicStateParameters, upgradeToV2FeatureFlagEnabled, ); @@ -300,7 +302,7 @@ export class UserKeyRotationService { protected async upgradeV1UserToV2UserAccountKeys( userId: UserId, kdfConfig: KdfConfig, - email: string, + masterKeySalt: string, cryptographicStateParameters: V1CryptographicStateParameters, ): Promise<V2UserCryptographicState> { // Initialize an SDK with the current cryptographic state @@ -308,7 +310,7 @@ export class UserKeyRotationService { await sdk.crypto().initialize_user_crypto({ userId: asUuid(userId), kdfParams: kdfConfig.toSdkConfig(), - email: email, + email: masterKeySalt, accountCryptographicState: { V1: { private_key: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey, @@ -328,7 +330,7 @@ export class UserKeyRotationService { protected async rotateV2UserAccountKeys( userId: UserId, kdfConfig: KdfConfig, - email: string, + masterKeySalt: string, cryptographicStateParameters: V2CryptographicStateParameters, ): Promise<V2UserCryptographicState> { // Initialize an SDK with the current cryptographic state @@ -336,7 +338,7 @@ export class UserKeyRotationService { await sdk.crypto().initialize_user_crypto({ userId: asUuid(userId), kdfParams: kdfConfig.toSdkConfig(), - email: email, + email: masterKeySalt, accountCryptographicState: { V2: { private_key: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey, @@ -598,8 +600,11 @@ export class UserKeyRotationService { this.kdfConfigService.getKdfConfig$(user.id), "KDF config", ))!; - // The master key salt used for deriving the masterkey always needs to be trimmed and lowercased. - const masterKeySalt = user.email.trim().toLowerCase(); + + const masterKeySalt = await this.firstValueFromOrThrow( + this.masterPasswordService.saltForUser$(user.id), + "Master key salt", + ); // V1 and V2 users both have a user key and a private key const currentUserKey: UserKey = (await this.firstValueFromOrThrow( From 7342bf672fe4551b83182ca9af188db58e01ea14 Mon Sep 17 00:00:00 2001 From: Alex <55413326+AlexRubik@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:01:30 -0500 Subject: [PATCH 40/42] [PM-31161] reports scroll bug (#18769) * Fix virtual scroll gap in exposed-passwords-report by setting rowSize to 54px * Fix virtual scroll gap in weak-passwords-report by setting rowSize to 54px --- .../dirt/reports/pages/exposed-passwords-report.component.html | 2 +- .../app/dirt/reports/pages/weak-passwords-report.component.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html index ba118ea6663..144396d6772 100644 --- a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html @@ -43,7 +43,7 @@ ></bit-chip-select> } } - <bit-table-scroll [dataSource]="dataSource" [rowSize]="75"> + <bit-table-scroll [dataSource]="dataSource" [rowSize]="54"> <ng-container header> <th bitCell></th> <th bitCell bitSortable="name">{{ "name" | i18n }}</th> diff --git a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html index 5f047316a29..5a187427b5e 100644 --- a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html @@ -45,7 +45,7 @@ ></bit-chip-select> } } - <bit-table-scroll [dataSource]="dataSource" [rowSize]="75"> + <bit-table-scroll [dataSource]="dataSource" [rowSize]="54"> <ng-container header> <th bitCell></th> <th bitCell bitSortable="name">{{ "name" | i18n }}</th> From fe15b44ccc026092d1f111b54869fbd8a9674cb3 Mon Sep 17 00:00:00 2001 From: Will Martin <contact@willmartian.com> Date: Thu, 12 Feb 2026 12:26:25 -0500 Subject: [PATCH 41/42] [CL-1046] Update dialog components to support attribute selector usage for form integration (#18929) - Add [bit-dialog] and [bit-simple-dialog] attribute selectors - Update documentation with recommended form usage pattern - Add Storybook examples demonstrating <form bit-dialog> pattern - Migrate simple-configurable-dialog template to new pattern Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> --- .../src/dialog/dialog/dialog.component.ts | 2 +- libs/components/src/dialog/dialog/dialog.mdx | 9 ++++ .../src/dialog/dialog/dialog.stories.ts | 8 ++-- libs/components/src/dialog/dialogs.mdx | 9 ++++ .../simple-configurable-dialog.component.html | 42 +++++++++---------- .../simple-dialog/simple-dialog.component.ts | 2 +- .../dialog/simple-dialog/simple-dialog.mdx | 9 ++++ .../simple-dialog/simple-dialog.stories.ts | 18 ++++++++ 8 files changed, 70 insertions(+), 29 deletions(-) diff --git a/libs/components/src/dialog/dialog/dialog.component.ts b/libs/components/src/dialog/dialog/dialog.component.ts index 63fbb69399d..c32ce176d27 100644 --- a/libs/components/src/dialog/dialog/dialog.component.ts +++ b/libs/components/src/dialog/dialog/dialog.component.ts @@ -45,7 +45,7 @@ const drawerSizeToWidth = { // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - selector: "bit-dialog", + selector: "bit-dialog, [bit-dialog]", templateUrl: "./dialog.component.html", host: { "[class]": "classes()", diff --git a/libs/components/src/dialog/dialog/dialog.mdx b/libs/components/src/dialog/dialog/dialog.mdx index 056e4ac79bc..33dce6a53e0 100644 --- a/libs/components/src/dialog/dialog/dialog.mdx +++ b/libs/components/src/dialog/dialog/dialog.mdx @@ -82,3 +82,12 @@ The `background` input can be set to `alt` to change the background color. This dialogs that contain multiple card sections. <Canvas of={stories.WithCards} /> + +## Using Forms with Dialogs + +When using forms with dialogs, apply the `bit-dialog` attribute directly to the `<form>` element +instead of wrapping the dialog in a form. This ensures proper styling. + +```html +<form bit-dialog>...</form> +``` diff --git a/libs/components/src/dialog/dialog/dialog.stories.ts b/libs/components/src/dialog/dialog/dialog.stories.ts index 012bb77f2ac..9b96e529789 100644 --- a/libs/components/src/dialog/dialog/dialog.stories.ts +++ b/libs/components/src/dialog/dialog/dialog.stories.ts @@ -225,8 +225,7 @@ export const WithCards: Story = { ...args, }, template: /*html*/ ` - <form [formGroup]="formObj"> - <bit-dialog [dialogSize]="dialogSize" [background]="background" [title]="title" [subtitle]="subtitle" [loading]="loading" [disablePadding]="disablePadding" [disableAnimations]="disableAnimations"> + <form [formGroup]="formObj" bit-dialog [dialogSize]="dialogSize" [background]="background" [title]="title" [subtitle]="subtitle" [loading]="loading" [disablePadding]="disablePadding" [disableAnimations]="disableAnimations"> <ng-container bitDialogContent> <bit-section> <bit-section-header> @@ -270,7 +269,7 @@ export const WithCards: Story = { </bit-section> </ng-container> <ng-container bitDialogFooter> - <button type="button" bitButton buttonType="primary" [disabled]="loading">Save</button> + <button type="submit" bitButton buttonType="primary" [disabled]="loading">Save</button> <button type="button" bitButton buttonType="secondary" [disabled]="loading">Cancel</button> <button type="button" @@ -281,8 +280,7 @@ export const WithCards: Story = { size="default" label="Delete"></button> </ng-container> - </bit-dialog> - </form> + </form> `, }), args: { diff --git a/libs/components/src/dialog/dialogs.mdx b/libs/components/src/dialog/dialogs.mdx index 4a49804484b..2b8afb06783 100644 --- a/libs/components/src/dialog/dialogs.mdx +++ b/libs/components/src/dialog/dialogs.mdx @@ -92,3 +92,12 @@ Once closed, focus should remain on the element which triggered the Dialog. **Note:** If a Simple Dialog is triggered from a main Dialog, be sure to make sure focus is moved to the Simple Dialog. + +## Using Forms with Dialogs + +When using forms with dialogs, apply the `bit-dialog` attribute directly to the `<form>` element +instead of wrapping the dialog in a form. This ensures proper styling. + +```html +<form bit-dialog>...</form> +``` diff --git a/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.html b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.html index 470f4846785..2e285495934 100644 --- a/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.html +++ b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.html @@ -1,27 +1,25 @@ -<form [formGroup]="formGroup" [bitSubmit]="accept"> - <bit-simple-dialog> - <i bitDialogIcon class="bwi tw-text-3xl" [class]="iconClasses" aria-hidden="true"></i> +<form [formGroup]="formGroup" [bitSubmit]="accept" bit-simple-dialog> + <i bitDialogIcon class="bwi tw-text-3xl" [class]="iconClasses" aria-hidden="true"></i> - <span bitDialogTitle>{{ title }}</span> + <span bitDialogTitle>{{ title }}</span> - <div bitDialogContent>{{ content }}</div> + <div bitDialogContent>{{ content }}</div> - <ng-container bitDialogFooter> - <button type="submit" bitButton bitFormButton buttonType="primary"> - {{ acceptButtonText }} + <ng-container bitDialogFooter> + <button type="submit" bitButton bitFormButton buttonType="primary"> + {{ acceptButtonText }} + </button> + + @if (showCancelButton) { + <button + type="button" + bitButton + bitFormButton + buttonType="secondary" + (click)="dialogRef.close(false)" + > + {{ cancelButtonText }} </button> - - @if (showCancelButton) { - <button - type="button" - bitButton - bitFormButton - buttonType="secondary" - (click)="dialogRef.close(false)" - > - {{ cancelButtonText }} - </button> - } - </ng-container> - </bit-simple-dialog> + } + </ng-container> </form> diff --git a/libs/components/src/dialog/simple-dialog/simple-dialog.component.ts b/libs/components/src/dialog/simple-dialog/simple-dialog.component.ts index cd44a79c271..804c654186c 100644 --- a/libs/components/src/dialog/simple-dialog/simple-dialog.component.ts +++ b/libs/components/src/dialog/simple-dialog/simple-dialog.component.ts @@ -12,7 +12,7 @@ export class IconDirective {} // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - selector: "bit-simple-dialog", + selector: "bit-simple-dialog, [bit-simple-dialog]", templateUrl: "./simple-dialog.component.html", animations: [fadeIn], imports: [DialogTitleContainerDirective, TypographyDirective], diff --git a/libs/components/src/dialog/simple-dialog/simple-dialog.mdx b/libs/components/src/dialog/simple-dialog/simple-dialog.mdx index 1d7a3668719..0720715478b 100644 --- a/libs/components/src/dialog/simple-dialog/simple-dialog.mdx +++ b/libs/components/src/dialog/simple-dialog/simple-dialog.mdx @@ -49,3 +49,12 @@ Simple dialogs can support scrolling content if necessary, but typically with la content a [Dialog component](?path=/docs/component-library-dialogs-dialog--docs). <Canvas of={stories.ScrollingContent} /> + +## Using Forms with Dialogs + +When using forms with dialogs, apply the `bit-simple-dialog` attribute directly to the `<form>` +element instead of wrapping the dialog in a form. This ensures proper styling. + +```html +<form bit-simple-dialog>...</form> +``` diff --git a/libs/components/src/dialog/simple-dialog/simple-dialog.stories.ts b/libs/components/src/dialog/simple-dialog/simple-dialog.stories.ts index 3a178892908..c67d52280b0 100644 --- a/libs/components/src/dialog/simple-dialog/simple-dialog.stories.ts +++ b/libs/components/src/dialog/simple-dialog/simple-dialog.stories.ts @@ -126,3 +126,21 @@ export const TextOverflow: Story = { `, }), }; + +export const WithForm: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + <form bit-simple-dialog> + <span bitDialogTitle>Confirm Action</span> + <span bitDialogContent> + Are you sure you want to proceed with this action? This cannot be undone. + </span> + <ng-container bitDialogFooter> + <button type="submit" bitButton buttonType="primary">Confirm</button> + <button type="button" bitButton buttonType="secondary">Cancel</button> + </ng-container> + </form> + `, + }), +}; From bfc1833139687bb2b539e377be6fa87aad070049 Mon Sep 17 00:00:00 2001 From: Alex <55413326+AlexRubik@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:29:18 -0500 Subject: [PATCH 42/42] [PM-32088] Switch phishing data source to GitHub (#18890) * Switch phishing data source to GitHub and remove fallback mechanism The phish.co.za mirror is down, causing every update cycle to timeout on the primary fetch before falling back to the GitHub raw URL. This removes phish.co.za entirely and uses GitHub as the sole data source, which was the original source before the mirror was introduced. - Rename `remoteUrl`/`fallbackUrl` to `ghSourceUrl` on PhishingResource type - Remove phish.co.za URLs from both Domains and Links resources - Remove catchError fallback block in `_updateFullDataSet()` - Errors now propagate to `_backgroundUpdate()` which already handles retries (3 attempts with 5-minute delays) and graceful degradation * revert the fallback logic removal, change prop name, add use fallback flag * Update Links primaryUrl to Bitwarden-hosted blocklist * remove all fallback logic --- .../phishing-detection/phishing-resources.ts | 11 ++----- .../services/phishing-data.service.ts | 33 ++----------------- 2 files changed, 6 insertions(+), 38 deletions(-) diff --git a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts index 88068987dd7..1c6421912ab 100644 --- a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts +++ b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts @@ -1,8 +1,6 @@ export type PhishingResource = { name?: string; - remoteUrl: string; - /** Fallback URL to use if remoteUrl fails (e.g., due to SSL interception/cert issues) */ - fallbackUrl: string; + primaryUrl: string; checksumUrl: string; todayUrl: string; /** Matcher used to decide whether a given URL matches an entry from this resource */ @@ -20,8 +18,7 @@ export const PHISHING_RESOURCES: Record<PhishingResourceType, PhishingResource[] [PhishingResourceType.Domains]: [ { name: "Phishing.Database Domains", - remoteUrl: "https://phish.co.za/latest/phishing-domains-ACTIVE.txt", - fallbackUrl: + primaryUrl: "https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/refs/heads/master/phishing-domains-ACTIVE.txt", checksumUrl: "https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.md5", @@ -49,9 +46,7 @@ export const PHISHING_RESOURCES: Record<PhishingResourceType, PhishingResource[] [PhishingResourceType.Links]: [ { name: "Phishing.Database Links", - remoteUrl: "https://phish.co.za/latest/phishing-links-ACTIVE.txt", - fallbackUrl: - "https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/refs/heads/master/phishing-links-ACTIVE.txt", + primaryUrl: "https://assets.bitwarden.com/security/v1/link-blocklist.txt", checksumUrl: "https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-links-ACTIVE.txt.md5", todayUrl: diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts index 03759ba14bc..72415dbbdbe 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts @@ -304,12 +304,12 @@ export class PhishingDataService { private _updateFullDataSet() { const resource = getPhishingResources(this.resourceType); - if (!resource?.remoteUrl) { + if (!resource?.primaryUrl) { return throwError(() => new Error("Invalid resource URL")); } - this.logService.info(`[PhishingDataService] Starting FULL update using ${resource.remoteUrl}`); - return from(this.apiService.nativeFetch(new Request(resource.remoteUrl))).pipe( + this.logService.info(`[PhishingDataService] Starting FULL update using ${resource.primaryUrl}`); + return from(this.apiService.nativeFetch(new Request(resource.primaryUrl))).pipe( switchMap((response) => { if (!response.ok || !response.body) { return throwError( @@ -322,33 +322,6 @@ export class PhishingDataService { return from(this.indexedDbService.saveUrlsFromStream(response.body)); }), - catchError((err: unknown) => { - this.logService.error( - `[PhishingDataService] Full dataset update failed using primary source ${err}`, - ); - this.logService.warning( - `[PhishingDataService] Falling back to: ${resource.fallbackUrl} (Note: Fallback data may be less up-to-date)`, - ); - // Try fallback URL - return from(this.apiService.nativeFetch(new Request(resource.fallbackUrl))).pipe( - switchMap((fallbackResponse) => { - if (!fallbackResponse.ok || !fallbackResponse.body) { - return throwError( - () => - new Error( - `[PhishingDataService] Fallback fetch failed: ${fallbackResponse.status}, ${fallbackResponse.statusText}`, - ), - ); - } - - return from(this.indexedDbService.saveUrlsFromStream(fallbackResponse.body)); - }), - catchError((fallbackError: unknown) => { - this.logService.error(`[PhishingDataService] Fallback source failed`); - return throwError(() => fallbackError); - }), - ); - }), ); }