diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index ef2c91f0a7d..6a334e31a18 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -565,7 +565,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Upload Sources - uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 + uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 6818064a808..c500e59d536 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -1007,7 +1007,7 @@ jobs: node-version: ${{ env._NODE_VERSION }} - name: Set up Python - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.14.2' @@ -1247,7 +1247,7 @@ jobs: node-version: ${{ env._NODE_VERSION }} - name: Set up Python - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.14.2' @@ -1522,7 +1522,7 @@ jobs: node-version: ${{ env._NODE_VERSION }} - name: Set up Python - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.14.2' @@ -1873,7 +1873,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Upload Sources - uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 + uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 71a2c62ec1a..688bd30bfe5 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -352,7 +352,7 @@ jobs: - name: Scan Docker image if: ${{ needs.setup.outputs.has_secrets == 'true' }} id: container-scan - uses: anchore/scan-action@62b74fb7bb810d2c45b1865f47a77655621862a5 # v7.2.3 + uses: anchore/scan-action@0d444ed77d83ee2ba7f5ced0d90d640a1281d762 # v7.3.0 with: image: ${{ steps.image-name.outputs.name }} fail-build: false @@ -408,7 +408,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Upload Sources - uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 + uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} diff --git a/.github/workflows/lint-crowdin-config.yml b/.github/workflows/lint-crowdin-config.yml index 61e2b3631e6..e1e620c864d 100644 --- a/.github/workflows/lint-crowdin-config.yml +++ b/.github/workflows/lint-crowdin-config.yml @@ -45,7 +45,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Lint ${{ matrix.app.name }} config - uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 + uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_PROJECT_ID: ${{ matrix.app.project_id }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7862c14c186..efc8c25fc5e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -142,7 +142,7 @@ jobs: run: cargo +nightly udeps --workspace --all-features --all-targets - name: Install cargo-deny - uses: taiki-e/install-action@542cebaaed782771e619bd5609d97659d109c492 # v2.66.7 + uses: taiki-e/install-action@887bc4e03483810873d617344dd5189cd82e7b8b # v2.67.11 with: tool: cargo-deny@0.18.6 diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index c5db7ea9295..45665f459e8 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -331,7 +331,7 @@ jobs: run: wget "https://github.com/bitwarden/clients/releases/download/${_RELEASE_TAG}/macos-build-number.json" - name: Setup Ruby and Install Fastlane - uses: ruby/setup-ruby@708024e6c902387ab41de36e1669e43b5ee7085e # v1.283.0 + uses: ruby/setup-ruby@90be1154f987f4dc0fe0dd0feedac9e473aa4ba8 # v1.286.0 with: ruby-version: '3.4.7' bundler-cache: false diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index c6d9d325e00..5768d336115 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5966,6 +5966,9 @@ "cardNumberLabel": { "message": "Card number" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, @@ -6127,6 +6130,10 @@ "emailPlaceholder": { "message": "user@bitwarden.com , user@acme.com" }, + + "downloadBitwardenApps": { + "message": "Download Bitwarden apps" + }, "emailProtected": { "message": "Email protected" }, @@ -6134,4 +6141,4 @@ "message": "Individuals will need to enter the password to view this Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." } -} \ No newline at end of file +} 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; } | { diff --git a/apps/browser/src/platform/badge/badge.service.ts b/apps/browser/src/platform/badge/badge.service.ts index f6d799b2a80..0ecb8210dd0 100644 --- a/apps/browser/src/platform/badge/badge.service.ts +++ b/apps/browser/src/platform/badge/badge.service.ts @@ -1,5 +1,6 @@ import { BehaviorSubject, + catchError, combineLatest, combineLatestWith, concatMap, @@ -73,9 +74,25 @@ export class BadgeService { map((evt) => evt.tab), combineLatestWith(this.stateFunctions), switchMap(([tab, dynamicStateFunctions]) => { - const functions = [...Object.values(dynamicStateFunctions), defaultTabStateFunction]; + const functions = [ + ...Object.entries(dynamicStateFunctions), + ["default" as string, defaultTabStateFunction] as const, + ]; - return combineLatest(functions.map((f) => f(tab).pipe(startWith(undefined)))).pipe( + return combineLatest( + functions.map(([name, f]) => + f(tab).pipe( + startWith(undefined), + catchError((error: unknown) => { + this.logService.error( + `BadgeService: State function "${name}" threw an error`, + error, + ); + return of(undefined); + }), + ), + ), + ).pipe( map((states) => ({ tab, states: states.filter((s): s is BadgeStateSetting => s !== undefined), 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/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/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 }} 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 74% 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 index f8238a188e0..d4495cf4c92 100644 --- 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 @@ -41,24 +41,6 @@ - @if (isEditMode) { - @if ((archiveFlagEnabled$ | async) && isCipherArchived) { - - } - @if ((userCanArchive$ | async) && canCipherBeArchived) { - - } - } @if (canDeleteCipher$ | async) {
- + 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..70affd73ef3 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 } 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/services/vault-popup-items.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts index 093fdbfb66d..845dfd6f4b1 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts @@ -69,7 +69,7 @@ describe("VaultPopupItemsService", () => { const accountServiceMock = mockAccountServiceWith(userId); const configServiceMock = mock(); const cipherArchiveServiceMock = mock(); - cipherArchiveServiceMock.userCanArchive$.mockReturnValue(of(true)); + cipherArchiveServiceMock.hasArchiveFlagEnabled$ = of(true); const restrictedItemTypesService = { restricted$: new BehaviorSubject([]), diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index 7ccfc834c87..93f2734e6b8 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -135,24 +135,23 @@ export class VaultPopupItemsService { shareReplay({ refCount: true, bufferSize: 1 }), ); - private userCanArchive$ = this.activeUserId$.pipe( - switchMap((userId) => { - return this.cipherArchiveService.userCanArchive$(userId); - }), - ); - private _activeCipherList$: Observable = this._allDecryptedCiphers$.pipe( switchMap((ciphers) => - combineLatest([this.organizations$, this.decryptedCollections$, this.userCanArchive$]).pipe( - map(([organizations, collections, canArchive]) => { + combineLatest([ + this.organizations$, + this.decryptedCollections$, + this.cipherArchiveService.hasArchiveFlagEnabled$, + ]).pipe( + map(([organizations, collections, archiveFlag]) => { const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org])); const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col])); return ciphers .filter( (c) => !CipherViewLikeUtils.isDeleted(c) && - (!canArchive || !CipherViewLikeUtils.isArchived(c)), + (!archiveFlag || !CipherViewLikeUtils.isArchived(c)), ) + .map((cipher) => { (cipher as PopupCipherViewLike).collections = cipher.collectionIds?.map( (colId) => collectionMap[colId as CollectionId], 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 = "--"; 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/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 }; } diff --git a/apps/desktop/custom-appx-manifest.xml b/apps/desktop/custom-appx-manifest.xml index 2f7796c97cf..166b852588b 100644 --- a/apps/desktop/custom-appx-manifest.xml +++ b/apps/desktop/custom-appx-manifest.xml @@ -13,7 +13,7 @@ xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/re IgnorableNamespaces="uap rescap com uap10 build" xmlns:build="http://schemas.microsoft.com/developer/appx/2015/build"> - diff --git a/apps/desktop/electron-builder.beta.json b/apps/desktop/electron-builder.beta.json index 3e1ca673c3c..9c66b17aa1f 100644 --- a/apps/desktop/electron-builder.beta.json +++ b/apps/desktop/electron-builder.beta.json @@ -64,7 +64,7 @@ "customManifestPath": "./custom-appx-manifest.xml", "applicationId": "BitwardenBeta", "identityName": "8bitSolutionsLLC.BitwardenBeta", - "publisher": "CN=Bitwarden Inc., O=Bitwarden Inc., L=Santa Barbara, S=California, C=US, SERIALNUMBER=7654941, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US", + "publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418", "publisherDisplayName": "Bitwarden Inc", "languages": [ "en-US", diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index 481d12f02b4..151ce72182d 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -179,7 +179,7 @@ "customManifestPath": "./custom-appx-manifest.xml", "applicationId": "bitwardendesktop", "identityName": "8bitSolutionsLLC.bitwardendesktop", - "publisher": "CN=Bitwarden Inc., O=Bitwarden Inc., L=Santa Barbara, S=California, C=US, SERIALNUMBER=7654941, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US", + "publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418", "publisherDisplayName": "Bitwarden Inc", "languages": [ "en-US", diff --git a/apps/desktop/scripts/appx-cross-build.ps1 b/apps/desktop/scripts/appx-cross-build.ps1 index 62619d5ea37..ef2ab09104c 100755 --- a/apps/desktop/scripts/appx-cross-build.ps1 +++ b/apps/desktop/scripts/appx-cross-build.ps1 @@ -176,6 +176,7 @@ $translationMap = @{ 'applicationId' = $builderConfig.appx.applicationId 'displayName' = $productName 'executable' = "app\${productName}.exe" + 'identityName' = $builderConfig.appx.identityName 'publisher' = $builderConfig.appx.publisher 'publisherDisplayName' = $builderConfig.appx.publisherDisplayName 'version' = $version diff --git a/apps/desktop/sign.js b/apps/desktop/sign.js index f115e9b8097..a01388c703c 100644 --- a/apps/desktop/sign.js +++ b/apps/desktop/sign.js @@ -3,7 +3,7 @@ const child_process = require("child_process"); exports.default = async function (configuration) { const ext = configuration.path.split(".").at(-1); - if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && ["exe", "appx"].includes(ext)) { + if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && ["exe"].includes(ext)) { console.log(`[*] Signing file: ${configuration.path}`); child_process.execFileSync( "azuresigntool", 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(); diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 550bbcad81e..85742db94ab 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4486,6 +4486,9 @@ "sessionTimeoutSettingsAction": { "message": "Timeout action" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "sessionTimeoutHeader": { "message": "Session timeout" }, 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()); diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.ts b/apps/desktop/src/vault/app/vault/item-footer.component.ts index 02c9873c295..133a9777fab 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.ts +++ b/apps/desktop/src/vault/app/vault/item-footer.component.ts @@ -263,15 +263,12 @@ export class ItemFooterComponent implements OnInit, OnChanges { this.userCanArchive = userCanArchive; this.showArchiveButton = - cipherCanBeArchived && - userCanArchive && - (this.action === "view" || this.action === "edit") && - !this.cipher.isArchived; + cipherCanBeArchived && userCanArchive && this.action === "view" && !this.cipher.isArchived; // A user should always be able to unarchive an archived item this.showUnarchiveButton = hasArchiveFlagEnabled && - (this.action === "view" || this.action === "edit") && + this.action === "view" && this.cipher.isArchived && !this.cipher.isDeleted; } 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/app/admin-console/organizations/policies/policies.component.ts b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts index d13a2097628..0dd0b67c189 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, DestroyRef } from "@angular/core"; +import { ChangeDetectionStrategy, Component, DestroyRef, OnDestroy } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; import { combineLatest, Observable, of, switchMap, first, map, shareReplay } from "rxjs"; @@ -14,7 +14,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { getById } from "@bitwarden/common/platform/misc"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { DialogService } from "@bitwarden/components"; +import { DialogRef, DialogService } from "@bitwarden/components"; import { safeProvider } from "@bitwarden/ui-common"; import { HeaderModule } from "../../../layouts/header/header.module"; @@ -37,7 +37,8 @@ import { POLICY_EDIT_REGISTER } from "./policy-register-token"; ], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class PoliciesComponent { +export class PoliciesComponent implements OnDestroy { + private myDialogRef?: DialogRef; private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); protected organizationId$: Observable = this.route.params.pipe( @@ -98,6 +99,10 @@ export class PoliciesComponent { this.handleLaunchEvent(); } + ngOnDestroy() { + this.myDialogRef?.close(); + } + // Handle policies component launch from Event message private handleLaunchEvent() { combineLatest([ @@ -131,7 +136,7 @@ export class PoliciesComponent { edit(policy: BasePolicyEditDefinition, organizationId: OrganizationId) { const dialogComponent: PolicyDialogComponent = policy.editDialogComponent ?? PolicyEditDialogComponent; - dialogComponent.open(this.dialogService, { + this.myDialogRef = dialogComponent.open(this.dialogService, { data: { policy: policy, organizationId: organizationId, 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 4a24bbcf3fd..d9eb03ea1ca 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 @@ -524,11 +524,12 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { const dialogRef = this.dialogService.open< AttachmentDialogCloseResult, - { cipherId: CipherId; organizationId?: OrganizationId } + { cipherId: CipherId; organizationId?: OrganizationId; canEditCipher?: boolean } >(AttachmentsV2Component, { data: { cipherId: this.formConfig.originalCipher?.id as CipherId, organizationId: this.formConfig.originalCipher?.organizationId as OrganizationId, + canEditCipher: this.formConfig.originalCipher?.edit, }, }); 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 @@
implements OnInit, OnDestr await this.cipherArchiveService.archiveWithServer(cipherIds as CipherId[], activeUserId); this.toastService.showToast({ variant: "success", - message: this.i18nService.t("itemsWereSentToArchive"), + message: this.i18nService.t("bulkArchiveItems"), }); this.refresh(); } catch (e) { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index fc2f463d9e6..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" @@ -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" }, @@ -6637,6 +6673,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 +6702,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" }, @@ -10092,6 +10184,9 @@ "assignTasks": { "message": "Assign tasks" }, + "allTasksAssigned": { + "message": "All tasks have been assigned" + }, "assignSecurityTasksToMembers": { "message": "Send notifications to change passwords" }, @@ -11804,9 +11899,6 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemsWereSentToArchive": { - "message": "Items were sent to archive" - }, "itemWasUnarchived": { "message": "Item was unarchived" }, @@ -12490,6 +12582,9 @@ "confirmNoSelectedCriticalApplicationsDesc": { "message": "Are you sure you want to continue?" }, + "errorCannotDecrypt": { + "message": "Error: Cannot decrypt" + }, "userVerificationFailed": { "message": "User verification failed." }, @@ -12808,7 +12903,7 @@ "message": "Prices exclude tax and are billed annually." }, "invoicePreviewErrorMessage": { - "message": "Encountered an error while generating the invoice preview." + "message": "Encountered an error while generating the invoice preview." }, "planProratedMembershipInMonths": { "message": "Prorated $PLAN$ membership ($NUMOFMONTHS$)", 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). 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/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/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 a3d29c521c5..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 { + + } + } - + + + +
@if (emptyTableExplanation()) { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.spec.ts index b600d6086f6..b4cbbc5c436 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.spec.ts @@ -19,6 +19,7 @@ import { CipherId } from "@bitwarden/common/types/guid"; import { TableDataSource, ToastService } from "@bitwarden/components"; import { ApplicationTableDataSource } from "../shared/app-table-row-scrollable.component"; +import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service"; import { ApplicationsComponent } from "./applications.component"; @@ -35,6 +36,7 @@ describe("ApplicationsComponent", () => { 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/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 - }} - - -
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 c23202b6832..29da8a7a818 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 @@ -25,6 +25,7 @@ @if (row.iconCipher) { @@ -57,12 +58,15 @@ (keydown.space)="showAppAtRiskMembers()(row.applicationName)" role="button" tabindex="0" - [attr.aria-label]="'viewItem' | i18n" + [attr.aria-label]=" + row.applicationName + ' ' + (row.isMarkedAsCritical ? ('criticalBadge' | i18n) : '') + " >
{{ row.applicationName }}
+ @if (row.isMarkedAsCritical) { {{ "criticalBadge" | i18n @@ -79,7 +83,7 @@ (keydown.space)="showAppAtRiskMembers()(row.applicationName)" role="button" tabindex="0" - [attr.aria-label]="'viewItem' | i18n" + [attr.aria-label]="('atRiskPasswords' | i18n) + ' ' + row.atRiskPasswordCount" > {{ row.atRiskPasswordCount }} @@ -94,7 +98,7 @@ (keydown.space)="showAppAtRiskMembers()(row.applicationName)" role="button" tabindex="0" - [attr.aria-label]="'viewItem' | i18n" + [attr.aria-label]="('totalPasswords' | i18n) + ' ' + row.passwordCount" > {{ row.passwordCount }} @@ -109,7 +113,7 @@ (keydown.space)="showAppAtRiskMembers()(row.applicationName)" role="button" tabindex="0" - [attr.aria-label]="'viewItem' | i18n" + [attr.aria-label]="('atRiskMembers' | i18n) + ' ' + row.atRiskMemberCount" > {{ row.atRiskMemberCount }} @@ -125,7 +129,7 @@ (keydown.space)="showAppAtRiskMembers()(row.applicationName)" role="button" tabindex="0" - [attr.aria-label]="'viewItem' | i18n" + [attr.aria-label]="('totalMembers' | i18n) + ' ' + row.memberCount" > {{ row.memberCount }} 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 diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 9941e7671f4..40e22cfbb5a 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", @@ -75,6 +76,7 @@ export enum FeatureFlag { /* Platform */ ContentScriptIpcChannelFramework = "content-script-ipc-channel-framework", + WebAuthnRelatedOrigins = "pm-30529-webauthn-related-origins", /* Innovation */ PM19148_InnovationArchive = "pm-19148-innovation-archive", @@ -109,6 +111,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE, [FeatureFlag.DefaultUserCollectionRestore]: FALSE, [FeatureFlag.MembersComponentRefactor]: FALSE, + [FeatureFlag.BulkReinviteUI]: FALSE, /* Autofill */ [FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic]: FALSE, @@ -169,6 +172,7 @@ export const DefaultFeatureFlagValue = { /* Platform */ [FeatureFlag.ContentScriptIpcChannelFramework]: FALSE, + [FeatureFlag.WebAuthnRelatedOrigins]: FALSE, /* Innovation */ [FeatureFlag.PM19148_InnovationArchive]: FALSE, diff --git a/libs/common/src/platform/services/fido2/domain-utils.spec.ts b/libs/common/src/platform/services/fido2/domain-utils.spec.ts index 284555052dd..df6132bac20 100644 --- a/libs/common/src/platform/services/fido2/domain-utils.spec.ts +++ b/libs/common/src/platform/services/fido2/domain-utils.spec.ts @@ -2,101 +2,377 @@ import { isValidRpId } from "./domain-utils"; // Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm. describe("validateRpId", () => { - it("should not be valid when rpId is null", () => { - const origin = "example.com"; + let mockFetch: jest.Mock; + let webAuthnRelatedOriginsFeatureFlag = false; - expect(isValidRpId(null, origin)).toBe(false); + beforeEach(() => { + mockFetch = jest.fn(); + // Default: ROR requests fail (no .well-known/webauthn endpoint) + mockFetch.mockRejectedValue(new Error("Network error")); }); - it("should not be valid when origin is null", () => { - const rpId = "example.com"; + describe("classic domain validation", () => { + it("should not be valid when rpId is null", async () => { + const origin = "example.com"; - expect(isValidRpId(rpId, null)).toBe(false); + expect(await isValidRpId(null, origin, webAuthnRelatedOriginsFeatureFlag)).toBe(false); + }); + + it("should not be valid when origin is null", async () => { + const rpId = "example.com"; + + expect(await isValidRpId(rpId, null, webAuthnRelatedOriginsFeatureFlag)).toBe(false); + }); + + it("should not be valid when rpId is more specific than origin", async () => { + const rpId = "sub.login.bitwarden.com"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when effective domains of rpId and origin do not match", async () => { + const rpId = "passwordless.dev"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when subdomains are the same but effective domains of rpId and origin do not match", async () => { + const rpId = "login.passwordless.dev"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when rpId and origin are both different TLD", async () => { + const rpId = "bitwarden"; + const origin = "localhost"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + // Only allow localhost for rpId, need to properly investigate the implications of + // adding support for ip-addresses and other TLDs + it("should not be valid when rpId and origin are both the same TLD", async () => { + const rpId = "bitwarden"; + const origin = "bitwarden"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when rpId and origin are ip-addresses", async () => { + const rpId = "127.0.0.1"; + const origin = "127.0.0.1"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should be valid when domains of rpId and origin are localhost", async () => { + const rpId = "localhost"; + const origin = "https://localhost:8080"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should be valid when domains of rpId and origin are the same", async () => { + const rpId = "bitwarden.com"; + const origin = "https://bitwarden.com"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should be valid when origin is a subdomain of rpId", async () => { + const rpId = "bitwarden.com"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should be valid when domains of rpId and origin are the same and they are both subdomains", async () => { + const rpId = "login.bitwarden.com"; + const origin = "https://login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should be valid when origin is a subdomain of rpId and they are both subdomains", async () => { + const rpId = "login.bitwarden.com"; + const origin = "https://sub.login.bitwarden.com:1337"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should not be valid for a partial match of a subdomain", async () => { + const rpId = "accounts.example.com"; + const origin = "https://evilaccounts.example.com"; + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag)).toBe(false); + }); }); - it("should not be valid when rpId is more specific than origin", () => { - const rpId = "sub.login.bitwarden.com"; - const origin = "https://login.bitwarden.com:1337"; + describe("Related Origin Requests (ROR)", () => { + // Helper to create a mock fetch response + function mockRorResponse(origins: string[], status = 200, contentType = "application/json") { + mockFetch.mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + headers: new Headers({ "content-type": contentType }), + json: async () => ({ origins }), + }); + } - expect(isValidRpId(rpId, origin)).toBe(false); - }); + it("should not proceed with ROR check when valid when feature flag disabled", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - it("should not be valid when effective domains of rpId and origin do not match", () => { - const rpId = "passwordless.dev"; - const origin = "https://login.bitwarden.com:1337"; + mockRorResponse([origin, "https://www.facebook.com", "https://www.instagram.com"]); - expect(isValidRpId(rpId, origin)).toBe(false); - }); + expect(await isValidRpId(rpId, origin, false, mockFetch)).toBe(false); + expect(mockFetch).not.toHaveBeenCalledWith( + `https://${rpId}/.well-known/webauthn`, + expect.objectContaining({ + credentials: "omit", + referrerPolicy: "no-referrer", + }), + ); + }); - it("should not be valid when subdomains are the same but effective domains of rpId and origin do not match", () => { - const rpId = "login.passwordless.dev"; - const origin = "https://login.bitwarden.com:1337"; + webAuthnRelatedOriginsFeatureFlag = true; - expect(isValidRpId(rpId, origin)).toBe(false); - }); + it("should be valid when origin is listed in .well-known/webauthn", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - it("should not be valid when rpId and origin are both different TLD", () => { - const rpId = "bitwarden"; - const origin = "https://localhost"; + mockRorResponse([origin, "https://www.facebook.com", "https://www.instagram.com"]); - expect(isValidRpId(rpId, origin)).toBe(false); - }); + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + expect(mockFetch).toHaveBeenCalledWith( + `https://${rpId}/.well-known/webauthn`, + expect.objectContaining({ + credentials: "omit", + referrerPolicy: "no-referrer", + }), + ); + }); - // Only allow localhost for rpId, need to properly investigate the implications of - // adding support for ip-addresses and other TLDs - it("should not be valid when rpId and origin are both the same TLD", () => { - const rpId = "bitwarden"; - const origin = "https://bitwarden"; + it("should not be valid when origin is not listed in .well-known/webauthn", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://evil.com"; - expect(isValidRpId(rpId, origin)).toBe(false); - }); + mockRorResponse(["https://www.facebook.com", "https://www.instagram.com"]); - it("should not be valid when rpId and origin are ip-addresses", () => { - const rpId = "127.0.0.1"; - const origin = "https://127.0.0.1"; + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); - expect(isValidRpId(rpId, origin)).toBe(false); - }); + it("should not be valid when .well-known/webauthn returns non-200 status", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - it("should be valid when domains of rpId and origin are localhost", () => { - const rpId = "localhost"; - const origin = "https://localhost:8080"; + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + headers: new Headers({ "content-type": "application/json" }), + }); - expect(isValidRpId(rpId, origin)).toBe(true); - }); + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); - it("should be valid when domains of rpId and origin are the same", () => { - const rpId = "bitwarden.com"; - const origin = "https://bitwarden.com"; + it("should not be valid when .well-known/webauthn returns non-JSON content-type", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - expect(isValidRpId(rpId, origin)).toBe(true); - }); + mockRorResponse([origin], 200, "text/html"); - it("should be valid when origin is a subdomain of rpId", () => { - const rpId = "bitwarden.com"; - const origin = "https://login.bitwarden.com:1337"; + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); - expect(isValidRpId(rpId, origin)).toBe(true); - }); + it("should not be valid when .well-known/webauthn response has no origins array", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - it("should be valid when domains of rpId and origin are the same and they are both subdomains", () => { - const rpId = "login.bitwarden.com"; - const origin = "https://login.bitwarden.com:1337"; + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ notOrigins: "invalid" }), + }); - expect(isValidRpId(rpId, origin)).toBe(true); - }); + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); - it("should be valid when origin is a subdomain of rpId and they are both subdomains", () => { - const rpId = "login.bitwarden.com"; - const origin = "https://sub.login.bitwarden.com:1337"; + it("should not be valid when .well-known/webauthn response has empty origins array", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; - expect(isValidRpId(rpId, origin)).toBe(true); - }); + mockRorResponse([]); - it("should not be valid for a partial match of a subdomain", () => { - const rpId = "accounts.example.com"; - const origin = "https://evilaccounts.example.com"; + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); - expect(isValidRpId(rpId, origin)).toBe(false); + it("should not be valid when .well-known/webauthn response has non-string origins", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ origins: [123, { url: origin }] }), + }); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when fetch throws an error", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockFetch.mockRejectedValue(new Error("Network error")); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should not be valid when fetch times out", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockFetch.mockRejectedValue(new DOMException("The operation was aborted.", "AbortError")); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should skip classic validation and use ROR when domains do not match", async () => { + // This is the Facebook/Meta use case + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockRorResponse([origin]); + + // Classic validation would fail (different domains), but ROR should succeed + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should not call ROR endpoint when classic validation succeeds", async () => { + const rpId = "bitwarden.com"; + const origin = "https://bitwarden.com"; + + // Classic validation succeeds, so ROR should not be called + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("should require exact origin match (including port)", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com:8443"; + + // Only the non-port version is listed + mockRorResponse(["https://accountscenter.facebook.com"]); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should handle invalid URLs in origins array gracefully", async () => { + const rpId = "accounts.meta.com"; + const origin = "https://accountscenter.facebook.com"; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ + origins: ["not-a-valid-url", "://also-invalid", origin], + }), + }); + + // Should still find the valid origin despite invalid entries + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); + + it("should enforce max labels limit", async () => { + const rpId = "example.com"; + const origin = "https://site6.com"; + + // Create origins from 6 different eTLD+1 labels + // Only the first 5 should be processed + mockRorResponse([ + "https://site1.com", + "https://site2.com", + "https://site3.com", + "https://site4.com", + "https://site5.com", + "https://site6.com", // This is the 6th label, should be skipped + ]); + + // The origin is in the list but should be skipped due to max labels limit + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + false, + ); + }); + + it("should allow multiple origins from the same eTLD+1", async () => { + const rpId = "example.com"; + const origin = "https://sub2.facebook.com"; + + // All these are from facebook.com (same eTLD+1), so they count as 1 label + mockRorResponse([ + "https://www.facebook.com", + "https://sub1.facebook.com", + "https://sub2.facebook.com", + "https://sub3.facebook.com", + ]); + + expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe( + true, + ); + }); }); }); diff --git a/libs/common/src/platform/services/fido2/domain-utils.ts b/libs/common/src/platform/services/fido2/domain-utils.ts index 542beae3435..dafc270ea9a 100644 --- a/libs/common/src/platform/services/fido2/domain-utils.ts +++ b/libs/common/src/platform/services/fido2/domain-utils.ts @@ -1,22 +1,39 @@ import { parse } from "tldts"; /** - * Validates whether a Relying Party ID (rpId) is valid for a given origin according to WebAuthn specifications. + * Maximum number of unique eTLD+1 labels to process when checking Related Origin Requests. + * This limit prevents malicious servers from causing excessive processing. + * Per WebAuthn spec recommendation. + */ +const ROR_MAX_LABELS = 5; + +/** + * Timeout in milliseconds for fetching the .well-known/webauthn endpoint. + */ +const ROR_FETCH_TIMEOUT_MS = 5000; + +/** + * Validates whether a Relying Party ID (rpId) is valid for a given origin according to classic + * WebAuthn specifications (before Related Origin Requests extension). * - * The validation enforces the following rules: - * - The origin must use the HTTPS scheme + * This implements the core WebAuthn RP ID validation logic: + * - The origin must use the HTTPS scheme (except localhost) * - Both rpId and origin must be valid domain names (not IP addresses) - * - Both must have the same registrable domain (e.g., example.com) + * - Both must have the same registrable domain (eTLD+1) * - The origin must either exactly match the rpId or be a subdomain of it * - Single-label domains are rejected unless they are 'localhost' * - Localhost is always valid when both rpId and origin are localhost * + * This is used internally as the first validation step before falling back to + * Related Origin Requests (ROR) validation. + * + * @see https://www.w3.org/TR/webauthn-2/#rp-id + * * @param rpId - The Relying Party identifier to validate * @param origin - The origin URL to validate against (must start with https://) * @returns `true` if the rpId is valid for the given origin, `false` otherwise - * */ -export function isValidRpId(rpId: string, origin: string) { +function isValidRpIdInternal(rpId: string, origin: string) { if (!rpId || !origin) { return false; } @@ -73,6 +90,148 @@ export function isValidRpId(rpId: string, origin: string) { if (parsedOrigin.hostname != null && parsedOrigin.hostname.endsWith("." + rpId)) { return true; } - - return false; +} + +/** + * Checks if the origin is allowed to use the given rpId via Related Origin Requests (ROR). + * This implements the WebAuthn Related Origin Requests spec which allows an RP to + * authorize origins from different domains to use its rpId. + * + * @see https://w3c.github.io/webauthn/#sctn-related-origins + * + * @param rpId - The relying party ID being requested + * @param origin - The origin making the WebAuthn request + * @param fetchFn - Optional fetch function for testing, defaults to global fetch + * @returns Promise that resolves to true if the origin is allowed via ROR, false otherwise + */ +async function isAllowedByRor( + rpId: string, + origin: string, + fetchFn?: typeof fetch, +): Promise { + try { + const fetchImpl = fetchFn ?? globalThis.fetch; + + // Create abort signal with timeout - use AbortSignal.timeout if available, otherwise use AbortController + let signal: AbortSignal; + if (typeof AbortSignal.timeout === "function") { + signal = AbortSignal.timeout(ROR_FETCH_TIMEOUT_MS); + } else { + const controller = new AbortController(); + setTimeout(() => controller.abort(), ROR_FETCH_TIMEOUT_MS); + signal = controller.signal; + } + + const response = await fetchImpl(`https://${rpId}/.well-known/webauthn`, { + credentials: "omit", + referrerPolicy: "no-referrer", + signal, + }); + + if (!response.ok) { + return false; + } + + const contentType = response.headers.get("content-type"); + if (!contentType || !contentType.includes("application/json")) { + return false; + } + + const data = (await response.json()) as { origins?: unknown }; + + if ( + !data || + !Array.isArray(data.origins) || + !data.origins.every((o) => typeof o === "string") || + data.origins.length === 0 + ) { + return false; + } + + // Track unique labels (eTLD+1) to enforce the max labels limit + const labelsSeen = new Set(); + + for (const allowedOrigin of data.origins as string[]) { + try { + const url = new URL(allowedOrigin); + const hostname = url.hostname; + if (!hostname) { + continue; + } + + const parsed = parse(hostname, { allowPrivateDomains: true }); + if (!parsed.domain || !parsed.publicSuffix) { + continue; + } + + // Extract the label (the part before the public suffix) + const label = parsed.domain.slice(0, parsed.domain.length - parsed.publicSuffix.length - 1); + + if (!label) { + continue; + } + + // Skip if we've already seen max labels and this is a new one + if (labelsSeen.size >= ROR_MAX_LABELS && !labelsSeen.has(label)) { + continue; + } + + // Check for exact origin match + if (origin === allowedOrigin) { + return true; + } + + // Track the label if we haven't hit the limit + if (labelsSeen.size < ROR_MAX_LABELS) { + labelsSeen.add(label); + } + } catch { + // Invalid URL, skip this entry + continue; + } + } + + return false; + } catch { + // Network error, timeout, or other failure - fail closed + return false; + } +} + +/* Validates whether a Relying Party ID (rpId) is valid for a given origin according to WebAuthn specifications. + * If that fails, checks if the origin is authorized via Related Origin Requests (ROR). + * + * The validation enforces the following rules: + * - The origin must use the HTTPS scheme + * - Both rpId and origin must be valid domain names (not IP addresses) + * - Both must have the same registrable domain (e.g., example.com) + * - The origin must either exactly match the rpId or be a subdomain of it + * - Single-label domains are rejected unless they are 'localhost' + * - Localhost is always valid when both rpId and origin are localhost + * + * @param rpId - The Relying Party identifier to validate + * @param origin - The origin URL to validate against (must start with https://) + * @param fetchFn - Optional fetch function for testing, defaults to global fetch + * @returns `true` if the rpId is valid for the given origin, `false` otherwise + * + */ +export async function isValidRpId( + rpId: string, + origin: string, + relatedOriginChecksEnabled: boolean, + fetchFn?: typeof fetch, +): Promise { + // Classic WebAuthn validation: rpId must be a registrable domain suffix of the origin + const classicMatch = isValidRpIdInternal(rpId, origin); + + if (classicMatch) { + return true; + } + + if (!relatedOriginChecksEnabled) { + return false; + } + + // Fall back to Related Origin Requests (ROR) validation + return await isAllowedByRor(rpId, origin, fetchFn); } diff --git a/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts index 4fd91fb19e6..7b298110040 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.spec.ts @@ -71,6 +71,8 @@ describe("FidoAuthenticatorService", () => { isValidRpId = jest.spyOn(DomainUtils, "isValidRpId"); + configService.getFeatureFlag$.mockReturnValue(of(false)); + client = new Fido2ClientService( authenticator, configService, @@ -186,7 +188,7 @@ describe("FidoAuthenticatorService", () => { const params = createParams(); authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult()); // `params` actually has a valid rp.id, but we're mocking the function to return false - isValidRpId.mockReturnValue(false); + isValidRpId.mockResolvedValue(false); const result = async () => await client.createCredential(params, windowReference); @@ -459,7 +461,7 @@ describe("FidoAuthenticatorService", () => { const params = createParams(); authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult()); // `params` actually has a valid rp.id, but we're mocking the function to return false - isValidRpId.mockReturnValue(false); + isValidRpId.mockResolvedValue(false); const result = async () => await client.assertCredential(params, windowReference); diff --git a/libs/common/src/platform/services/fido2/fido2-client.service.ts b/libs/common/src/platform/services/fido2/fido2-client.service.ts index 2aa618e974d..8fabed450f8 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.ts @@ -3,6 +3,8 @@ import { firstValueFrom, Subscription } from "rxjs"; import { parse } from "tldts"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; + import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; @@ -62,6 +64,9 @@ export class Fido2ClientService< MAX: 600000, }, }; + protected readonly relatedOriginChecksEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.WebAuthnRelatedOrigins, + ); constructor( private authenticator: Fido2AuthenticatorService, @@ -142,7 +147,13 @@ export class Fido2ClientService< throw new DOMException("'origin' is not a valid https origin", "SecurityError"); } - if (!isValidRpId(params.rp.id, params.origin)) { + if ( + !(await isValidRpId( + params.rp.id, + params.origin, + await firstValueFrom(this.relatedOriginChecksEnabled$), + )) + ) { this.logService?.warning( `[Fido2Client] 'rp.id' cannot be used with the current origin: rp.id = ${params.rp.id}; origin = ${params.origin}`, ); @@ -281,7 +292,13 @@ export class Fido2ClientService< throw new DOMException("'origin' is not a valid https origin", "SecurityError"); } - if (!isValidRpId(params.rpId, params.origin)) { + if ( + !(await isValidRpId( + params.rpId, + params.origin, + await firstValueFrom(this.relatedOriginChecksEnabled$), + )) + ) { this.logService?.warning( `[Fido2Client] 'rp.id' cannot be used with the current origin: rp.id = ${params.rpId}; origin = ${params.origin}`, ); diff --git a/libs/common/src/vault/models/domain/attachment.ts b/libs/common/src/vault/models/domain/attachment.ts index d9dfa128028..b9bcaad8cea 100644 --- a/libs/common/src/vault/models/domain/attachment.ts +++ b/libs/common/src/vault/models/domain/attachment.ts @@ -47,6 +47,12 @@ export class Attachment extends Domain { if (this.key != null) { view.key = await this.decryptAttachmentKey(decryptionKey); view.encryptedKey = this.key; // Keep the encrypted key for the view + + // When the attachment key couldn't be decrypted, mark a decryption error + // The file won't be able to be downloaded in these cases + if (!view.key) { + view.hasDecryptionError = true; + } } return view; diff --git a/libs/common/src/vault/models/view/attachment.view.ts b/libs/common/src/vault/models/view/attachment.view.ts index ef4a9ed8b27..6eaa943fba0 100644 --- a/libs/common/src/vault/models/view/attachment.view.ts +++ b/libs/common/src/vault/models/view/attachment.view.ts @@ -2,7 +2,7 @@ import { Jsonify } from "type-fest"; import { AttachmentView as SdkAttachmentView } from "@bitwarden/sdk-internal"; -import { EncString } from "../../../key-management/crypto/models/enc-string"; +import { DECRYPT_ERROR, EncString } from "../../../key-management/crypto/models/enc-string"; import { View } from "../../../models/view/view"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { Attachment } from "../domain/attachment"; @@ -18,6 +18,7 @@ export class AttachmentView implements View { * The SDK returns an encrypted key for the attachment. */ encryptedKey: EncString | undefined; + private _hasDecryptionError?: boolean; constructor(a?: Attachment) { if (!a) { @@ -41,6 +42,14 @@ export class AttachmentView implements View { return 0; } + get hasDecryptionError(): boolean { + return this._hasDecryptionError || this.fileName === DECRYPT_ERROR; + } + + set hasDecryptionError(value: boolean) { + this._hasDecryptionError = value; + } + static fromJSON(obj: Partial>): AttachmentView { const key = obj.key == null ? null : SymmetricCryptoKey.fromJSON(obj.key); @@ -76,7 +85,10 @@ export class AttachmentView implements View { /** * Converts the SDK AttachmentView to a AttachmentView. */ - static fromSdkAttachmentView(obj: SdkAttachmentView): AttachmentView | undefined { + static fromSdkAttachmentView( + obj: SdkAttachmentView, + failure = false, + ): AttachmentView | undefined { if (!obj) { return undefined; } @@ -90,6 +102,7 @@ export class AttachmentView implements View { // TODO: PM-23005 - Temporary field, should be removed when encrypted migration is complete view.key = obj.decryptedKey ? SymmetricCryptoKey.fromString(obj.decryptedKey) : undefined; view.encryptedKey = obj.key ? new EncString(obj.key) : undefined; + view._hasDecryptionError = failure; return view; } diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 0909d0bda80..1e0cce8d72e 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -280,6 +280,17 @@ export class CipherView implements View, InitializerMetadata { return undefined; } + const attachments = obj.attachments?.map((a) => AttachmentView.fromSdkAttachmentView(a)!) ?? []; + + if (obj.attachmentDecryptionFailures?.length) { + obj.attachmentDecryptionFailures.forEach((attachment) => { + const attachmentView = AttachmentView.fromSdkAttachmentView(attachment, true); + if (attachmentView) { + attachments.push(attachmentView); + } + }); + } + const cipherView = new CipherView(); cipherView.id = uuidAsString(obj.id); cipherView.organizationId = uuidAsString(obj.organizationId); @@ -295,8 +306,7 @@ export class CipherView implements View, InitializerMetadata { cipherView.edit = obj.edit; cipherView.viewPassword = obj.viewPassword; cipherView.localData = fromSdkLocalData(obj.localData); - cipherView.attachments = - obj.attachments?.map((a) => AttachmentView.fromSdkAttachmentView(a)!) ?? []; + cipherView.attachments = attachments; cipherView.fields = obj.fields?.map((f) => FieldView.fromSdkFieldView(f)!) ?? []; cipherView.passwordHistory = obj.passwordHistory?.map((ph) => PasswordHistoryView.fromSdkPasswordHistoryView(ph)!) ?? []; 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/dialog/dialogs.mdx b/libs/components/src/dialog/dialogs.mdx index ee03dda6f57..4a49804484b 100644 --- a/libs/components/src/dialog/dialogs.mdx +++ b/libs/components/src/dialog/dialogs.mdx @@ -25,6 +25,35 @@ interruptive if overused. For non-blocking, supplementary content, open dialogs as a [Drawer](?path=/story/component-library-dialogs-service--drawer) (requires `bit-layout`). +### Closing Drawers on Navigation + +When using drawers, you may want to close them automatically when the user navigates to another page +to prevent the drawer from persisting across route changes. To implement this functionality: + +1. Store a reference to the dialog when opening it +2. Implement `OnDestroy` and close the dialog in `ngOnDestroy` + +```ts +import { Component, OnDestroy } from "@angular/core"; +import { DialogRef } from "@bitwarden/components"; + +export class MyComponent implements OnDestroy { + private myDialogRef: DialogRef; + + ngOnDestroy() { + this.myDialogRef?.close(); + } + + openDrawer() { + this.myDialogRef = this.dialogService.open(MyDialogComponent, { + // dialog options + }); + } +} +``` + +This ensures drawers are closed when the component is destroyed during navigation. + ## Placement Dialogs should be centered vertically and horizontally on screen. Dialogs height should expand to 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", diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html index 6aaaf033e0d..0e214abd72d 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html @@ -4,52 +4,76 @@ <ul aria-labelledby="attachments" class="tw-list-none tw-pl-0"> @for (attachment of attachments; track attachment.id) { <li> - <bit-item> - <bit-item-content> - <span data-testid="file-name" [title]="attachment.fileName">{{ - attachment.fileName - }}</span> - <span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span> - <i - *ngIf="attachment.key == null" - slot="default-trailing" - class="bwi bwi-exclamation-triangle bwi-sm tw-text-muted" - [appA11yTitle]="'fixEncryptionTooltip' | i18n" - ></i> - </bit-item-content> - - <ng-container slot="end"> - <bit-item-action> - @if (attachment.key != null) { - <app-download-attachment - [admin]="admin() && organization()?.canEditAllCiphers" - [cipher]="cipher()" - [attachment]="attachment" - ></app-download-attachment> - } @else { - <button - [bitAction]="fixOldAttachment(attachment)" - bitButton - buttonType="primary" - size="small" - type="button" - > - {{ "fixEncryption" | i18n }} - </button> + @if (!attachment.hasDecryptionError) { + <bit-item> + <bit-item-content> + <span data-testid="file-name" [title]="attachment.fileName"> + {{ attachment.fileName }} + </span> + <span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span> + @if (attachment.key == null) { + <i + slot="default-trailing" + class="bwi bwi-exclamation-triangle bwi-sm tw-text-muted" + [appA11yTitle]="'fixEncryptionTooltip' | i18n" + ></i> } - </bit-item-action> - @if (cipher().edit) { + </bit-item-content> + + <ng-container slot="end"> <bit-item-action> - <app-delete-attachment - [admin]="admin() && organization()?.canEditAllCiphers" - [cipherId]="cipher().id" - [attachment]="attachment" - (onDeletionSuccess)="removeAttachment(attachment)" - ></app-delete-attachment> + @if (attachment.key != null) { + <app-download-attachment + [admin]="admin() && organization()?.canEditAllCiphers" + [cipher]="cipher()" + [attachment]="attachment" + ></app-download-attachment> + } @else { + <button + [bitAction]="fixOldAttachment(attachment)" + bitButton + buttonType="primary" + size="small" + type="button" + > + {{ "fixEncryption" | i18n }} + </button> + } </bit-item-action> - } - </ng-container> - </bit-item> + @if (cipher().edit) { + <bit-item-action> + <app-delete-attachment + [admin]="admin() && organization()?.canEditAllCiphers" + [cipherId]="cipher().id" + [attachment]="attachment" + (onDeletionSuccess)="removeAttachment(attachment)" + ></app-delete-attachment> + </bit-item-action> + } + </ng-container> + </bit-item> + } @else { + <bit-item> + <bit-item-content> + <span data-testid="file-name" [title]="'errorCannotDecrypt' | i18n"> + {{ "errorCannotDecrypt" | i18n }} + </span> + </bit-item-content> + + <ng-container slot="end"> + @if (cipher().edit) { + <bit-item-action> + <app-delete-attachment + [admin]="admin() && organization()?.canEditAllCiphers" + [cipherId]="cipher().id" + [attachment]="attachment" + (onDeletionSuccess)="removeAttachment(attachment)" + ></app-delete-attachment> + </bit-item-action> + } + </ng-container> + </bit-item> + } </li> } </ul> 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 @@ <bit-item-group> <bit-item *ngFor="let attachment of cipher.attachments"> <bit-item-content> - <span data-testid="file-name" [title]="attachment.fileName">{{ attachment.fileName }}</span> - <span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span> + <span data-testid="file-name" [title]="getAttachmentFileName(attachment)"> + {{ getAttachmentFileName(attachment) }} + </span> + @if (!attachment.hasDecryptionError) { + <span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span> + } </bit-item-content> <ng-container slot="end"> <bit-item-action class="tw-pr-4 [@media(min-width:650px)]:tw-pr-6"> 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 () => { diff --git a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html index 268f5b912d1..d816b69bc58 100644 --- a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html +++ b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html @@ -4,12 +4,13 @@ bitButton buttonType="primary" type="button" - [bitMenuTriggerFor]="addOptions" + [bitMenuTriggerFor]="isOnlyCollectionCreation() ? null : addOptions" + (click)="handleButtonClick()" id="newItemDropdown" - [appA11yTitle]="'new' | i18n" + [appA11yTitle]="getButtonLabel() | i18n" > <i class="bwi bwi-plus tw-me-2" aria-hidden="true"></i> - {{ "new" | i18n }} + {{ getButtonLabel() | i18n }} </button> <bit-menu #addOptions aria-labelledby="newItemDropdown"> @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(); + } + } } diff --git a/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.html b/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.html index 6d1045e1a86..ac55c3cebd1 100644 --- a/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.html +++ b/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.html @@ -1,9 +1,9 @@ <bit-simple-dialog> - <i + <bit-icon bitDialogIcon - class="bwi bwi-exclamation-triangle tw-text-warning tw-text-3xl" - aria-hidden="true" - ></i> + name="bwi-exclamation-triangle" + class="tw-text-warning tw-text-3xl" + ></bit-icon> <span bitDialogTitle>{{ "leaveConfirmationDialogTitle" | i18n }}</span> @@ -25,9 +25,9 @@ {{ "goBack" | i18n }} </button> - <a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center tw-text-sm"> + <a bitLink href="#" (click)="openLearnMore($event)" class="tw-self-center tw-text-sm"> {{ "howToManageMyVault" | i18n }} - <i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i> + <bit-icon name="bwi-external-link" class="tw-ml-1"></bit-icon> </a> </ng-container> </bit-simple-dialog> diff --git a/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.ts b/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.ts index af106376a79..44788a8234a 100644 --- a/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.ts +++ b/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.ts @@ -10,6 +10,7 @@ import { DialogService, ButtonModule, DialogModule, + IconModule, LinkModule, TypographyModule, CenterPositionStrategy, @@ -35,7 +36,7 @@ export type LeaveConfirmationDialogResultType = UnionOfValues<typeof LeaveConfir @Component({ templateUrl: "./leave-confirmation-dialog.component.html", changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ButtonModule, DialogModule, LinkModule, TypographyModule, JslibModule], + imports: [ButtonModule, DialogModule, IconModule, LinkModule, TypographyModule, JslibModule], }) export class LeaveConfirmationDialogComponent { private readonly params = inject<LeaveConfirmationDialogParams>(DIALOG_DATA); diff --git a/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.html b/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.html index 3cf626baaf7..5d1c3ba9aed 100644 --- a/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.html +++ b/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.html @@ -14,9 +14,9 @@ {{ "declineAndLeave" | i18n }} </button> - <a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center tw-text-sm"> + <a bitLink href="#" (click)="openLearnMore($event)" class="tw-self-center tw-text-sm"> {{ "whyAmISeeingThis" | i18n }} - <i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i> + <bit-icon name="bwi-external-link" class="tw-ml-1"></bit-icon> </a> </ng-container> </bit-simple-dialog> diff --git a/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.ts b/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.ts index 619181f37fc..45f6305b5b3 100644 --- a/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.ts +++ b/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.ts @@ -10,6 +10,7 @@ import { DialogService, ButtonModule, DialogModule, + IconModule, LinkModule, TypographyModule, CenterPositionStrategy, @@ -35,7 +36,7 @@ export type TransferItemsDialogResultType = UnionOfValues<typeof TransferItemsDi @Component({ templateUrl: "./transfer-items-dialog.component.html", changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ButtonModule, DialogModule, LinkModule, TypographyModule, JslibModule], + imports: [ButtonModule, DialogModule, IconModule, LinkModule, TypographyModule, JslibModule], }) export class TransferItemsDialogComponent { private readonly params = inject<TransferItemsDialogParams>(DIALOG_DATA); diff --git a/libs/vault/src/services/default-vault-items-transfer.service.spec.ts b/libs/vault/src/services/default-vault-items-transfer.service.spec.ts index f5da99cae61..b7d24681e64 100644 --- a/libs/vault/src/services/default-vault-items-transfer.service.spec.ts +++ b/libs/vault/src/services/default-vault-items-transfer.service.spec.ts @@ -938,4 +938,142 @@ describe("DefaultVaultItemsTransferService", () => { expect(transferInProgressValues).toEqual([false, true, false]); }); }); + + describe("enforcementInFlight", () => { + const policy = { + organizationId: organizationId, + revisionDate: new Date("2024-01-01"), + } as Policy; + const organization = { + id: organizationId, + name: "Test Org", + } as Organization; + const personalCiphers = [{ id: "cipher-1" } as CipherView]; + const defaultCollection = { + id: collectionId, + organizationId: organizationId, + isDefaultCollection: true, + } as CollectionView; + + beforeEach(() => { + mockConfigService.getFeatureFlag.mockResolvedValue(true); + mockPolicyService.policiesByType$.mockReturnValue(of([policy])); + mockOrganizationService.organizations$.mockReturnValue(of([organization])); + mockCipherService.cipherViews$.mockReturnValue(of(personalCiphers)); + mockCollectionService.defaultUserCollection$.mockReturnValue(of(defaultCollection)); + mockSyncService.fullSync.mockResolvedValue(true); + mockOrganizationUserApiService.revokeSelf.mockResolvedValue(undefined); + }); + + it("prevents re-entry when enforcement is already in flight", async () => { + // Create a dialog that resolves after a delay + const delayedSubject = new Subject<any>(); + const delayedDialog = { + closed: delayedSubject.asObservable(), + close: jest.fn(), + } as unknown as DialogRef<any>; + + mockDialogService.open.mockReturnValue(delayedDialog); + + // Start first call (won't complete immediately) + const firstCall = service.enforceOrganizationDataOwnership(userId); + + // Flush microtasks to allow first call to set enforcementInFlight + await Promise.resolve(); + + // Second call should return immediately without opening dialog + await service.enforceOrganizationDataOwnership(userId); + + // Verify re-entry was prevented - only the first call should proceed + expect(mockDialogService.open).toHaveBeenCalledTimes(1); + expect(mockPolicyService.policiesByType$).toHaveBeenCalledTimes(1); + + // Clean up - resolve the first call's dialog + delayedSubject.next(TransferItemsDialogResult.Declined); + delayedSubject.complete(); + + // Mock the leave dialog + mockDialogService.open.mockReturnValueOnce( + createMockDialogRef(LeaveConfirmationDialogResult.Confirmed), + ); + + await firstCall; + }); + + it("allows subsequent calls after user declines and leaves", async () => { + // First call: user declines and confirms leaving + mockDialogService.open + .mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined)) + .mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed)); + + await service.enforceOrganizationDataOwnership(userId); + + // Reset mocks for second call + mockDialogService.open.mockClear(); + + // Second call: user accepts transfer + mockDialogService.open.mockReturnValueOnce( + createMockDialogRef(TransferItemsDialogResult.Accepted), + ); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.enforceOrganizationDataOwnership(userId); + + // Second call should proceed (dialog opened again) + expect(mockDialogService.open).toHaveBeenCalledTimes(1); + }); + + it("allows subsequent calls after successful transfer", async () => { + // First call: user accepts transfer + mockDialogService.open.mockReturnValueOnce( + createMockDialogRef(TransferItemsDialogResult.Accepted), + ); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.enforceOrganizationDataOwnership(userId); + + // Reset mocks for second call + mockDialogService.open.mockClear(); + mockCipherService.shareManyWithServer.mockClear(); + + // Second call should be allowed (though no migration needed after first transfer) + // Set up scenario where migration is needed again + mockCipherService.cipherViews$.mockReturnValue(of([{ id: "cipher-2" } as CipherView])); + + mockDialogService.open.mockReturnValueOnce( + createMockDialogRef(TransferItemsDialogResult.Accepted), + ); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.enforceOrganizationDataOwnership(userId); + + // Second call should proceed (dialog opened again) + expect(mockDialogService.open).toHaveBeenCalledTimes(1); + }); + + it("allows subsequent calls after transfer fails with error", async () => { + // First call: transfer fails + mockDialogService.open.mockReturnValueOnce( + createMockDialogRef(TransferItemsDialogResult.Accepted), + ); + mockCipherService.shareManyWithServer.mockRejectedValue(new Error("Transfer failed")); + + await service.enforceOrganizationDataOwnership(userId); + + // Reset mocks for second call + mockDialogService.open.mockClear(); + mockCipherService.shareManyWithServer.mockClear(); + + // Second call: user accepts transfer successfully + mockDialogService.open.mockReturnValueOnce( + createMockDialogRef(TransferItemsDialogResult.Accepted), + ); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.enforceOrganizationDataOwnership(userId); + + // Second call should proceed (dialog opened again) + expect(mockDialogService.open).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/libs/vault/src/services/default-vault-items-transfer.service.ts b/libs/vault/src/services/default-vault-items-transfer.service.ts index 3e65d3157f5..8b1c24c8ca2 100644 --- a/libs/vault/src/services/default-vault-items-transfer.service.ts +++ b/libs/vault/src/services/default-vault-items-transfer.service.ts @@ -62,6 +62,12 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi transferInProgress$ = this._transferInProgressSubject.asObservable(); + /** + * Only a single enforcement should be allowed to run at a time to prevent multiple dialogs + * or multiple simultaneous transfers. + */ + private enforcementInFlight: boolean = false; + private enforcingOrganization$(userId: UserId): Observable<Organization | undefined> { return this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, userId).pipe( map( @@ -142,7 +148,7 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi FeatureFlag.MigrateMyVaultToMyItems, ); - if (!featureEnabled) { + if (!featureEnabled || this.enforcementInFlight) { return; } @@ -160,6 +166,8 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi return; } + this.enforcementInFlight = true; + const userAcceptedTransfer = await this.promptUserForTransfer( migrationInfo.enforcingOrganization.name, ); @@ -179,6 +187,7 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi ); // Sync to reflect organization removal await this.syncService.fullSync(true); + this.enforcementInFlight = false; return; } @@ -208,6 +217,8 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi variant: "error", message: this.i18nService.t("errorOccurred"), }); + } finally { + this.enforcementInFlight = false; } } diff --git a/package-lock.json b/package-lock.json index 8f6a527627f..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", @@ -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" @@ -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" @@ -40910,9 +40752,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 +43176,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 +43190,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 +43204,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 +43996,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..c95d6af7437 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" @@ -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",