diff --git a/.github/ISSUE_TEMPLATE/browser.yml b/.github/ISSUE_TEMPLATE/browser.yml index 23a0e4276bf..6f5c9dd0051 100644 --- a/.github/ISSUE_TEMPLATE/browser.yml +++ b/.github/ISSUE_TEMPLATE/browser.yml @@ -1,4 +1,4 @@ -name: Browser Bug Report +name: Browser Extension Bug Report description: File a bug report labels: [bug, browser] body: diff --git a/.github/ISSUE_TEMPLATE/web.yml b/.github/ISSUE_TEMPLATE/web.yml index 80429112fbd..d7989e40af1 100644 --- a/.github/ISSUE_TEMPLATE/web.yml +++ b/.github/ISSUE_TEMPLATE/web.yml @@ -1,4 +1,4 @@ -name: Web Bug Report +name: Web App Bug Report description: File a bug report labels: [bug, web] body: @@ -77,6 +77,7 @@ body: - Opera - Brave - Vivaldi + - DuckDuckGo validations: required: true - type: input diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 99d85c6cdb6..a022fe7fd0f 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -502,7 +502,7 @@ jobs: run: | npm run pack:win - - name: Pack & Sign (dev) + - name: Pack & Sign if: ${{ needs.setup.outputs.has_secrets == 'true' }} env: ELECTRON_BUILDER_SIGN: 1 diff --git a/apps/browser/package.json b/apps/browser/package.json index c44743add7c..9b6d0174b0f 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2025.5.1", + "version": "2025.6.0", "scripts": { "build": "npm run build:chrome", "build:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack", diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 3a8c7f14bc0..e8834b3ffdb 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5403,5 +5403,9 @@ }, "noPermissionsViewPage": { "message": "You do not have permissions to view this page. Try logging in with a different account." + }, + "wasmNotSupported": { + "message": "WebAssembly is not supported on your browser or is not enabled. WebAssembly is required to use the Bitwarden app.", + "description": "'WebAssembly' is a technical term and should not be translated." } } diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.html b/apps/browser/src/autofill/popup/settings/autofill.component.html index 264b04b039b..aa9c8648885 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.html +++ b/apps/browser/src/autofill/popup/settings/autofill.component.html @@ -65,7 +65,10 @@ {{ "showInlineMenuIdentitiesLabel" | i18n }} - + - + = + this.restrictedItemTypesService.restricted$.pipe( + map((restrictedTypes) => restrictedTypes.some((type) => type.cipherType === CipherType.Card)), + shareReplay({ bufferSize: 1, refCount: true }), + ); protected autofillOnPageLoadForm = new FormGroup({ autofillOnPageLoad: new FormControl(), @@ -156,6 +163,7 @@ export class AutofillComponent implements OnInit { private nudgesService: NudgesService, private accountService: AccountService, private autofillBrowserSettingsService: AutofillBrowserSettingsService, + private restrictedItemTypesService: RestrictedItemTypesService, ) { this.autofillOnPageLoadOptions = [ { name: this.i18nService.t("autoFillOnPageLoadYes"), value: true }, diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 1c89916c6f7..9f6529643c4 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "__MSG_extName__", "short_name": "Bitwarden", - "version": "2025.5.1", + "version": "2025.6.0", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index 834ac8d8b7d..bf5c4e439b9 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -3,7 +3,7 @@ "minimum_chrome_version": "102.0", "name": "__MSG_extName__", "short_name": "Bitwarden", - "version": "2025.5.1", + "version": "2025.6.0", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/platform/services/sdk/browser-sdk-load.service.ts b/apps/browser/src/platform/services/sdk/browser-sdk-load.service.ts index 409ff0dea06..3ad6dc2583d 100644 --- a/apps/browser/src/platform/services/sdk/browser-sdk-load.service.ts +++ b/apps/browser/src/platform/services/sdk/browser-sdk-load.service.ts @@ -35,9 +35,9 @@ if (BrowserApi.isManifestVersion(3)) { console.info("WebAssembly is supported in this environment"); loadingPromise = import("./wasm"); } else { - // eslint-disable-next-line no-console - console.info("WebAssembly is not supported in this environment"); - loadingPromise = import("./fallback"); + loadingPromise = new Promise((_, reject) => { + reject(new Error("WebAssembly is not supported in this environment")); + }); } } @@ -51,9 +51,7 @@ async function importModule(): Promise { console.info("WebAssembly is supported in this environment"); await import("./wasm"); } else { - // eslint-disable-next-line no-console - console.info("WebAssembly is not supported in this environment"); - await import("./fallback"); + throw new Error("WebAssembly is not supported in this environment"); } // the wasm and fallback imports mutate globalThis to add the initSdk function diff --git a/apps/browser/src/platform/services/sdk/fallback.ts b/apps/browser/src/platform/services/sdk/fallback.ts deleted file mode 100644 index cee3598feda..00000000000 --- a/apps/browser/src/platform/services/sdk/fallback.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as sdk from "@bitwarden/sdk-internal"; -import * as wasm from "@bitwarden/sdk-internal/bitwarden_wasm_internal_bg.wasm.js"; - -import { GlobalWithWasmInit } from "./browser-sdk-load.service"; - -(globalThis as GlobalWithWasmInit).initSdk = () => { - (sdk as any).init(wasm); -}; diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index b6d3615af94..6a26476de43 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -11,7 +11,17 @@ import { } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; -import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap, map } from "rxjs"; +import { + Subject, + takeUntil, + firstValueFrom, + concatMap, + filter, + tap, + catchError, + of, + map, +} from "rxjs"; import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction"; import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n"; @@ -23,6 +33,7 @@ import { AnimationControlService } from "@bitwarden/common/platform/abstractions import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; import { UserId } from "@bitwarden/common/types/guid"; @@ -48,23 +59,45 @@ import { DesktopSyncVerificationDialogComponent } from "./components/desktop-syn styles: [], animations: [routerTransition], template: ` -
- -
- + @if (showSdkWarning | async) { +
+ + {{ "wasmNotSupported" | i18n }} + + {{ "learnMore" | i18n }} + + +
+ } @else { +
+ +
+ + } `, standalone: false, }) export class AppComponent implements OnInit, OnDestroy { private compactModeService = inject(PopupCompactModeService); + private sdkService = inject(SdkService); private lastActivity: Date; private activeUserId: UserId; - private recordActivitySubject = new Subject(); private routerAnimations = false; private destroy$ = new Subject(); + // Show a warning if the SDK is not available. + protected showSdkWarning = this.sdkService.client$.pipe( + map(() => false), + catchError(() => of(true)), + ); + constructor( private authService: AuthService, private i18nService: I18nService, diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index 8bea41da4d6..b400cb5eec8 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -20,6 +20,8 @@ import { ButtonModule, FormFieldModule, ToastModule, + CalloutModule, + LinkModule, } from "@bitwarden/components"; import { AccountComponent } from "../auth/popup/account-switching/account.component"; @@ -87,6 +89,8 @@ import "../platform/popup/locales"; CurrentAccountComponent, FormFieldModule, ExtensionAnonLayoutWrapperComponent, + CalloutModule, + LinkModule, ], declarations: [ AppComponent, diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html index 6b6e8728f19..7dd0a5a3bc7 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html @@ -3,34 +3,12 @@ {{ "new" | i18n }} - - - {{ "typeLogin" | i18n }} - - - - {{ "typeCard" | i18n }} - - - - {{ "typeIdentity" | i18n }} - - - - {{ "note" | i18n }} - - - - {{ "typeSshKey" | i18n }} - + @for (menuItem of cipherMenuItems$ | async; track menuItem.type) { + + + {{ menuItem.labelKey | i18n }} + + } - - diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html index 4b266ac5525..992c9c26bf3 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html @@ -1,4 +1,4 @@ - + diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.module.ts b/apps/web/src/app/vault/components/vault-items/vault-items.module.ts index e54a9c1141f..ab4f8bddb16 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.module.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.module.ts @@ -3,7 +3,7 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; import { RouterModule } from "@angular/router"; -import { TableModule } from "@bitwarden/components"; +import { ScrollLayoutDirective, TableModule } from "@bitwarden/components"; import { CollectionNameBadgeComponent } from "../../../admin-console/organizations/collections"; import { GroupBadgeModule } from "../../../admin-console/organizations/collections/group-badge/group-badge.module"; @@ -26,6 +26,7 @@ import { VaultItemsComponent } from "./vault-items.component"; CollectionNameBadgeComponent, GroupBadgeModule, PipesModule, + ScrollLayoutDirective, ], declarations: [VaultItemsComponent, VaultCipherRowComponent, VaultCollectionRowComponent], exports: [VaultItemsComponent], diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts index e2c6f204d72..62b53d71e84 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -2,7 +2,13 @@ // @ts-strict-ignore import { importProvidersFrom } from "@angular/core"; import { RouterModule } from "@angular/router"; -import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { + applicationConfig, + componentWrapperDecorator, + Meta, + moduleMetadata, + StoryObj, +} from "@storybook/angular"; import { BehaviorSubject, of } from "rxjs"; import { @@ -29,6 +35,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { LayoutComponent } from "@bitwarden/components"; import { RestrictedItemTypesService } from "@bitwarden/vault"; import { GroupView } from "../../../admin-console/organizations/core"; @@ -49,8 +56,9 @@ export default { title: "Web/Vault/Items", component: VaultItemsComponent, decorators: [ + componentWrapperDecorator((story) => `${story}`), moduleMetadata({ - imports: [VaultItemsModule, RouterModule], + imports: [VaultItemsModule, RouterModule, LayoutComponent], providers: [ { provide: EnvironmentService, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html index 66c42678442..f203b7a934a 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html @@ -55,7 +55,7 @@ > {{ "providerUsersNeedConfirmed" | i18n }} - + diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts index 597acb0d4f0..01f1facfc15 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts @@ -4,7 +4,7 @@ import { NgModule } from "@angular/core"; import { FormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { CardComponent, SearchModule } from "@bitwarden/components"; +import { CardComponent, ScrollLayoutDirective, SearchModule } from "@bitwarden/components"; import { DangerZoneComponent } from "@bitwarden/web-vault/app/auth/settings/account/danger-zone.component"; import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing"; import { PaymentComponent } from "@bitwarden/web-vault/app/billing/shared/payment/payment.component"; @@ -53,6 +53,7 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr ScrollingModule, VerifyBankAccountComponent, CardComponent, + ScrollLayoutDirective, PaymentComponent, ], declarations: [ diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts index e74682f64fe..c7d82c3ec09 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/create-client-dialog.component.ts @@ -1,4 +1,3 @@ -import { BasePortalOutlet } from "@angular/cdk/portal"; import { Component, Inject, OnInit } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; @@ -33,8 +32,7 @@ export const openCreateClientDialog = ( dialogService: DialogService, dialogConfig: DialogConfig< CreateClientDialogParams, - DialogRef, - BasePortalOutlet + DialogRef >, ) => dialogService.open( diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html index 31eb54d6110..0200e206327 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html @@ -36,7 +36,7 @@ - {{ "members" | i18n }} + {{ "members" | i18n }} {{ "groups" | i18n }} {{ "collections" | i18n }} {{ "items" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/response/member-access-report.response.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/response/member-access-report.response.ts index 959b70b9729..c500c6c0aec 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/response/member-access-report.response.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/response/member-access-report.response.ts @@ -2,7 +2,15 @@ import { BaseResponse } from "@bitwarden/common/models/response/base.response"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { Guid } from "@bitwarden/common/types/guid"; -export class MemberAccessDetails extends BaseResponse { +export class MemberAccessResponse extends BaseResponse { + userName: string; + email: string; + twoFactorEnabled: boolean; + accountRecoveryEnabled: boolean; + userGuid: Guid; + usesKeyConnector: boolean; + + cipherIds: Guid[] = []; collectionId: string; groupId: string; groupName: string; @@ -14,6 +22,14 @@ export class MemberAccessDetails extends BaseResponse { constructor(response: any) { super(response); + this.userName = this.getResponseProperty("UserName"); + this.email = this.getResponseProperty("Email"); + this.twoFactorEnabled = this.getResponseProperty("TwoFactorEnabled"); + this.accountRecoveryEnabled = this.getResponseProperty("AccountRecoveryEnabled"); + this.userGuid = this.getResponseProperty("UserGuid"); + this.usesKeyConnector = this.getResponseProperty("UsesKeyConnector"); + + this.cipherIds = this.getResponseProperty("CipherIds") || []; this.groupId = this.getResponseProperty("GroupId"); this.collectionId = this.getResponseProperty("CollectionId"); this.groupName = this.getResponseProperty("GroupName"); @@ -24,34 +40,3 @@ export class MemberAccessDetails extends BaseResponse { this.manage = this.getResponseProperty("Manage"); } } - -export class MemberAccessResponse extends BaseResponse { - userName: string; - email: string; - twoFactorEnabled: boolean; - accountRecoveryEnabled: boolean; - collectionsCount: number; - groupsCount: number; - totalItemCount: number; - accessDetails: MemberAccessDetails[] = []; - userGuid: Guid; - usesKeyConnector: boolean; - - constructor(response: any) { - super(response); - this.userName = this.getResponseProperty("UserName"); - this.email = this.getResponseProperty("Email"); - this.twoFactorEnabled = this.getResponseProperty("TwoFactorEnabled"); - this.accountRecoveryEnabled = this.getResponseProperty("AccountRecoveryEnabled"); - this.collectionsCount = this.getResponseProperty("CollectionsCount"); - this.groupsCount = this.getResponseProperty("GroupsCount"); - this.totalItemCount = this.getResponseProperty("TotalItemCount"); - this.userGuid = this.getResponseProperty("UserGuid"); - this.usesKeyConnector = this.getResponseProperty("UsesKeyConnector"); - - const details = this.getResponseProperty("AccessDetails"); - if (details != null) { - this.accessDetails = details.map((o: any) => new MemberAccessDetails(o)); - } - } -} diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.mock.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.mock.ts index b07e4946ca7..ebf2b9abfc8 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.mock.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.mock.ts @@ -1,9 +1,7 @@ import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { Guid } from "@bitwarden/common/types/guid"; -import { - MemberAccessDetails, - MemberAccessResponse, -} from "../response/member-access-report.response"; +import { MemberAccessResponse } from "../response/member-access-report.response"; export const memberAccessReportsMock: MemberAccessResponse[] = [ { @@ -11,223 +9,290 @@ export const memberAccessReportsMock: MemberAccessResponse[] = [ email: "sjohnson@email.com", twoFactorEnabled: true, accountRecoveryEnabled: true, - groupsCount: 2, - collectionsCount: 4, - totalItemCount: 20, - userGuid: "1234", + userGuid: "1001" as Guid, usesKeyConnector: false, - accessDetails: [ - { - groupId: "", - collectionId: "c1", - collectionName: new EncString("Collection 1"), - groupName: "", - itemCount: 10, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "", - collectionId: "c2", - collectionName: new EncString("Collection 2"), - groupName: "", - itemCount: 20, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "", - collectionId: "c3", - collectionName: new EncString("Collection 3"), - groupName: "", - itemCount: 30, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "g1", - collectionId: "c1", - collectionName: new EncString("Collection 1"), - groupName: "Group 1", - itemCount: 30, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "g1", - collectionId: "c2", - collectionName: new EncString("Collection 2"), - groupName: "Group 1", - itemCount: 20, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - ], - } as MemberAccessResponse, + groupId: "", + collectionId: "c1", + collectionName: new EncString("Collection 1"), + groupName: "", + itemCount: 10, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Sarah Johnson", + email: "sjohnson@email.com", + twoFactorEnabled: true, + accountRecoveryEnabled: true, + userGuid: "1001" as Guid, + usesKeyConnector: false, + groupId: "", + collectionId: "c2", + collectionName: new EncString("Collection 2"), + groupName: "", + itemCount: 20, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Sarah Johnson", + email: "sjohnson@email.com", + twoFactorEnabled: true, + accountRecoveryEnabled: true, + userGuid: "1001" as Guid, + usesKeyConnector: false, + groupId: "", + collectionId: "c3", + collectionName: new EncString("Collection 3"), + groupName: "", + itemCount: 30, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Sarah Johnson", + email: "sjohnson@email.com", + twoFactorEnabled: true, + accountRecoveryEnabled: true, + userGuid: "1001", + usesKeyConnector: false, + groupId: "g1", + collectionId: "c1", + collectionName: new EncString("Collection 1"), + groupName: "Group 1", + itemCount: 30, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Sarah Johnson", + email: "sjohnson@email.com", + twoFactorEnabled: true, + accountRecoveryEnabled: true, + userGuid: "1001", + usesKeyConnector: false, + groupId: "g1", + collectionId: "c2", + collectionName: new EncString("Collection 2"), + groupName: "Group 1", + itemCount: 20, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, { userName: "James Lull", email: "jlull@email.com", twoFactorEnabled: false, accountRecoveryEnabled: false, - groupsCount: 2, - collectionsCount: 4, - totalItemCount: 20, - userGuid: "1234", + userGuid: "2001", usesKeyConnector: false, - accessDetails: [ - { - groupId: "g4", - collectionId: "c4", - groupName: "Group 4", - collectionName: new EncString("Collection 4"), - itemCount: 5, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "g4", - collectionId: "c5", - groupName: "Group 4", - collectionName: new EncString("Collection 5"), - itemCount: 15, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "", - collectionId: "c4", - groupName: "", - collectionName: new EncString("Collection 4"), - itemCount: 5, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "", - collectionId: "c5", - groupName: "", - collectionName: new EncString("Collection 5"), - itemCount: 15, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - ], - } as MemberAccessResponse, + groupId: "g4", + collectionId: "c4", + groupName: "Group 4", + collectionName: new EncString("Collection 4"), + itemCount: 5, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "James Lull", + email: "jlull@email.com", + twoFactorEnabled: false, + accountRecoveryEnabled: false, + userGuid: "2001", + usesKeyConnector: false, + groupId: "g4", + collectionId: "c5", + groupName: "Group 4", + collectionName: new EncString("Collection 5"), + itemCount: 15, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "James Lull", + email: "jlull@email.com", + twoFactorEnabled: false, + accountRecoveryEnabled: false, + userGuid: "2001", + usesKeyConnector: false, + groupId: "", + collectionId: "c4", + groupName: "", + collectionName: new EncString("Collection 4"), + itemCount: 5, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "James Lull", + email: "jlull@email.com", + twoFactorEnabled: false, + accountRecoveryEnabled: false, + userGuid: "2001", + usesKeyConnector: false, + groupId: "", + collectionId: "c5", + groupName: "", + collectionName: new EncString("Collection 5"), + itemCount: 15, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, { userName: "Beth Williams", email: "bwilliams@email.com", twoFactorEnabled: true, accountRecoveryEnabled: true, - groupsCount: 2, - collectionsCount: 4, - totalItemCount: 20, - userGuid: "1234", + userGuid: "3001", usesKeyConnector: false, - accessDetails: [ - { - groupId: "", - collectionId: "c6", - groupName: "", - collectionName: new EncString("Collection 6"), - itemCount: 25, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "g6", - collectionId: "c4", - groupName: "Group 6", - collectionName: new EncString("Collection 4"), - itemCount: 35, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - ], - } as MemberAccessResponse, + groupId: "", + collectionId: "c6", + groupName: "", + collectionName: new EncString("Collection 6"), + itemCount: 25, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Beth Williams", + email: "bwilliams@email.com", + twoFactorEnabled: true, + accountRecoveryEnabled: true, + userGuid: "3001", + usesKeyConnector: false, + groupId: "g6", + collectionId: "c4", + groupName: "Group 6", + collectionName: new EncString("Collection 4"), + itemCount: 35, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, { userName: "Ray Williams", email: "rwilliams@email.com", twoFactorEnabled: false, accountRecoveryEnabled: false, - groupsCount: 2, - collectionsCount: 4, - totalItemCount: 20, - userGuid: "1234", + userGuid: "4000", usesKeyConnector: false, - accessDetails: [ - { - groupId: "", - collectionId: "c7", - groupName: "", - collectionName: new EncString("Collection 7"), - itemCount: 8, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "", - collectionId: "c8", - groupName: "", - collectionName: new EncString("Collection 8"), - itemCount: 12, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "", - collectionId: "c9", - groupName: "", - collectionName: new EncString("Collection 9"), - itemCount: 16, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "g9", - collectionId: "c7", - groupName: "Group 9", - collectionName: new EncString("Collection 7"), - itemCount: 8, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "g10", - collectionId: "c8", - groupName: "Group 10", - collectionName: new EncString("Collection 8"), - itemCount: 12, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - { - groupId: "g11", - collectionId: "c9", - groupName: "Group 11", - collectionName: new EncString("Collection 9"), - itemCount: 16, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - ], - } as MemberAccessResponse, + groupId: "", + collectionId: "c7", + groupName: "", + collectionName: new EncString("Collection 7"), + itemCount: 8, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Ray Williams", + email: "rwilliams@email.com", + twoFactorEnabled: false, + accountRecoveryEnabled: false, + userGuid: "4000", + usesKeyConnector: false, + groupId: "", + collectionId: "c8", + groupName: "", + collectionName: new EncString("Collection 8"), + itemCount: 12, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Ray Williams", + email: "rwilliams@email.com", + twoFactorEnabled: false, + accountRecoveryEnabled: false, + userGuid: "4000", + usesKeyConnector: false, + groupId: "", + collectionId: "c9", + groupName: "", + collectionName: new EncString("Collection 9"), + itemCount: 16, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Ray Williams", + email: "rwilliams@email.com", + twoFactorEnabled: false, + accountRecoveryEnabled: false, + userGuid: "4000", + usesKeyConnector: false, + groupId: "g9", + collectionId: "c7", + groupName: "Group 9", + collectionName: new EncString("Collection 7"), + itemCount: 8, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Ray Williams", + email: "rwilliams@email.com", + twoFactorEnabled: false, + accountRecoveryEnabled: false, + userGuid: "4000", + usesKeyConnector: false, + groupId: "g10", + collectionId: "c8", + groupName: "Group 10", + collectionName: new EncString("Collection 8"), + itemCount: 12, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, + { + userName: "Ray Williams", + email: "rwilliams@email.com", + twoFactorEnabled: false, + accountRecoveryEnabled: false, + userGuid: "4000", + usesKeyConnector: false, + groupId: "g11", + collectionId: "c9", + groupName: "Group 11", + collectionName: new EncString("Collection 9"), + itemCount: 16, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, ]; export const memberAccessWithoutAccessDetailsReportsMock: MemberAccessResponse[] = [ @@ -236,34 +301,33 @@ export const memberAccessWithoutAccessDetailsReportsMock: MemberAccessResponse[] email: "asmith@email.com", twoFactorEnabled: true, accountRecoveryEnabled: true, - groupsCount: 2, - collectionsCount: 4, - totalItemCount: 20, - userGuid: "1234", + userGuid: "1234" as Guid, usesKeyConnector: false, - accessDetails: [ - { - groupId: "", - collectionId: "c1", - collectionName: new EncString("Collection 1"), - groupName: "Alice Group 1", - itemCount: 10, - readOnly: false, - hidePasswords: false, - manage: false, - } as MemberAccessDetails, - ], - } as MemberAccessResponse, + groupId: "", + collectionId: "c1", + collectionName: new EncString("Collection 1"), + groupName: "Alice Group 1", + itemCount: 10, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, { userName: "Robert Brown", email: "rbrown@email.com", twoFactorEnabled: false, accountRecoveryEnabled: false, - groupsCount: 2, - collectionsCount: 4, - totalItemCount: 20, - userGuid: "5678", + userGuid: "5678" as Guid, usesKeyConnector: false, - accessDetails: [] as MemberAccessDetails[], - } as MemberAccessResponse, + groupId: "", + collectionId: "c1", + collectionName: new EncString("Collection 1"), + groupName: "", + itemCount: 10, + readOnly: false, + hidePasswords: false, + manage: false, + cipherIds: [], + } as unknown as MemberAccessResponse, ]; diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts index e6efac83616..ad388cfed04 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.spec.ts @@ -35,36 +35,36 @@ describe("ImportService", () => { { name: "Sarah Johnson", email: "sjohnson@email.com", - collectionsCount: 4, - groupsCount: 2, - itemsCount: 20, + collectionsCount: 3, + groupsCount: 1, + itemsCount: 0, userGuid: expect.any(String), usesKeyConnector: expect.any(Boolean), }, { name: "James Lull", email: "jlull@email.com", - collectionsCount: 4, - groupsCount: 2, - itemsCount: 20, + collectionsCount: 2, + groupsCount: 1, + itemsCount: 0, userGuid: expect.any(String), usesKeyConnector: expect.any(Boolean), }, { name: "Beth Williams", email: "bwilliams@email.com", - collectionsCount: 4, - groupsCount: 2, - itemsCount: 20, + collectionsCount: 2, + groupsCount: 1, + itemsCount: 0, userGuid: expect.any(String), usesKeyConnector: expect.any(Boolean), }, { name: "Ray Williams", email: "rwilliams@email.com", - collectionsCount: 4, - groupsCount: 2, - itemsCount: 20, + collectionsCount: 3, + groupsCount: 3, + itemsCount: 0, userGuid: expect.any(String), usesKeyConnector: expect.any(Boolean), }, @@ -82,8 +82,8 @@ describe("ImportService", () => { (item) => (item.name === "Sarah Johnson" && item.group === "Group 1" && - item.totalItems === "20") || - (item.name === "James Lull" && item.group === "Group 4" && item.totalItems === "5"), + item.totalItems === "0") || + (item.name === "James Lull" && item.group === "Group 4" && item.totalItems === "0"), ) .map((item) => ({ name: item.name, @@ -102,7 +102,7 @@ describe("ImportService", () => { twoStepLogin: "memberAccessReportTwoFactorEnabledTrue", accountRecovery: "memberAccessReportAuthenticationEnabledTrue", group: "Group 1", - totalItems: "20", + totalItems: "0", }), expect.objectContaining({ email: "jlull@email.com", @@ -110,7 +110,7 @@ describe("ImportService", () => { twoStepLogin: "memberAccessReportTwoFactorEnabledFalse", accountRecovery: "memberAccessReportAuthenticationEnabledFalse", group: "Group 4", - totalItems: "5", + totalItems: "0", }), ]), ); @@ -131,7 +131,7 @@ describe("ImportService", () => { twoStepLogin: "memberAccessReportTwoFactorEnabledTrue", accountRecovery: "memberAccessReportAuthenticationEnabledTrue", group: "Alice Group 1", - totalItems: "10", + totalItems: "0", }), expect.objectContaining({ email: "rbrown@email.com", diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts index 029dce8a404..0039788709e 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts @@ -5,13 +5,13 @@ import { Injectable } from "@angular/core"; import { CollectionAccessSelectionView } from "@bitwarden/admin-console/common"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { Guid, OrganizationId } from "@bitwarden/common/types/guid"; import { getPermissionList, convertToPermission, } from "@bitwarden/web-vault/app/admin-console/organizations/shared/components/access-selector"; -import { MemberAccessDetails } from "../response/member-access-report.response"; +import { MemberAccessResponse } from "../response/member-access-report.response"; import { MemberAccessExportItem } from "../view/member-access-export.view"; import { MemberAccessReportView } from "../view/member-access-report.view"; @@ -34,15 +34,44 @@ export class MemberAccessReportService { organizationId: OrganizationId, ): Promise { const memberAccessData = await this.reportApiService.getMemberAccessData(organizationId); - const memberAccessReportViewCollection = memberAccessData.map((userData) => ({ - name: userData.userName, - email: userData.email, - collectionsCount: userData.collectionsCount, - groupsCount: userData.groupsCount, - itemsCount: userData.totalItemCount, - userGuid: userData.userGuid, - usesKeyConnector: userData.usesKeyConnector, - })); + + // group member access data by userGuid + const userMap = new Map(); + memberAccessData.forEach((userData) => { + const userGuid = userData.userGuid; + if (!userMap.has(userGuid)) { + userMap.set(userGuid, []); + } + userMap.get(userGuid)?.push(userData); + }); + + // aggregate user data + const memberAccessReportViewCollection: MemberAccessReportView[] = []; + userMap.forEach((userDataArray, userGuid) => { + const collectionCount = this.getDistinctCount( + userDataArray.map((data) => data.collectionId).filter((id) => !!id), + ); + const groupCount = this.getDistinctCount( + userDataArray.map((data) => data.groupId).filter((id) => !!id), + ); + const itemsCount = this.getDistinctCount( + userDataArray + .flatMap((data) => data.cipherIds) + .filter((id) => id !== "00000000-0000-0000-0000-000000000000"), + ); + const aggregatedData = { + userGuid: userGuid, + name: userDataArray[0].userName, + email: userDataArray[0].email, + collectionsCount: collectionCount, + groupsCount: groupCount, + itemsCount: itemsCount, + usesKeyConnector: userDataArray.some((data) => data.usesKeyConnector), + }; + + memberAccessReportViewCollection.push(aggregatedData); + }); + return memberAccessReportViewCollection; } @@ -50,13 +79,8 @@ export class MemberAccessReportService { organizationId: OrganizationId, ): Promise { const memberAccessReports = await this.reportApiService.getMemberAccessData(organizationId); - const collectionNames = memberAccessReports.flatMap((item) => - item.accessDetails.map((dtl) => { - if (dtl.collectionName) { - return dtl.collectionName.encryptedString; - } - }), - ); + const collectionNames = memberAccessReports.map((item) => item.collectionName.encryptedString); + const collectionNameMap = new Map(collectionNames.map((col) => [col, ""])); for await (const key of collectionNameMap.keys()) { const decrypted = new EncString(key); @@ -64,56 +88,35 @@ export class MemberAccessReportService { collectionNameMap.set(key, decrypted.decryptedValue); } - const exportItems = memberAccessReports.flatMap((report) => { - // to include users without access details - // which means a user has no groups, collections or items - if (report.accessDetails.length === 0) { - return [ - { - email: report.email, - name: report.userName, - twoStepLogin: report.twoFactorEnabled - ? this.i18nService.t("memberAccessReportTwoFactorEnabledTrue") - : this.i18nService.t("memberAccessReportTwoFactorEnabledFalse"), - accountRecovery: report.accountRecoveryEnabled - ? this.i18nService.t("memberAccessReportAuthenticationEnabledTrue") - : this.i18nService.t("memberAccessReportAuthenticationEnabledFalse"), - group: this.i18nService.t("memberAccessReportNoGroup"), - collection: this.i18nService.t("memberAccessReportNoCollection"), - collectionPermission: this.i18nService.t("memberAccessReportNoCollectionPermission"), - totalItems: "0", - }, - ]; - } - const userDetails = report.accessDetails.map((detail) => { - const collectionName = collectionNameMap.get(detail.collectionName.encryptedString); - return { - email: report.email, - name: report.userName, - twoStepLogin: report.twoFactorEnabled - ? this.i18nService.t("memberAccessReportTwoFactorEnabledTrue") - : this.i18nService.t("memberAccessReportTwoFactorEnabledFalse"), - accountRecovery: report.accountRecoveryEnabled - ? this.i18nService.t("memberAccessReportAuthenticationEnabledTrue") - : this.i18nService.t("memberAccessReportAuthenticationEnabledFalse"), - group: detail.groupName - ? detail.groupName - : this.i18nService.t("memberAccessReportNoGroup"), - collection: collectionName - ? collectionName - : this.i18nService.t("memberAccessReportNoCollection"), - collectionPermission: detail.collectionId - ? this.getPermissionText(detail) - : this.i18nService.t("memberAccessReportNoCollectionPermission"), - totalItems: detail.itemCount.toString(), - }; - }); - return userDetails; + const exportItems = memberAccessReports.map((report) => { + const collectionName = collectionNameMap.get(report.collectionName.encryptedString); + return { + email: report.email, + name: report.userName, + twoStepLogin: report.twoFactorEnabled + ? this.i18nService.t("memberAccessReportTwoFactorEnabledTrue") + : this.i18nService.t("memberAccessReportTwoFactorEnabledFalse"), + accountRecovery: report.accountRecoveryEnabled + ? this.i18nService.t("memberAccessReportAuthenticationEnabledTrue") + : this.i18nService.t("memberAccessReportAuthenticationEnabledFalse"), + group: report.groupName + ? report.groupName + : this.i18nService.t("memberAccessReportNoGroup"), + collection: collectionName + ? collectionName + : this.i18nService.t("memberAccessReportNoCollection"), + collectionPermission: report.collectionId + ? this.getPermissionText(report) + : this.i18nService.t("memberAccessReportNoCollectionPermission"), + totalItems: report.cipherIds + .filter((_) => _ != "00000000-0000-0000-0000-000000000000") + .length.toString(), + }; }); return exportItems.flat(); } - private getPermissionText(accessDetails: MemberAccessDetails): string { + private getPermissionText(accessDetails: MemberAccessResponse): string { const permissionList = getPermissionList(); const collectionSelectionView = new CollectionAccessSelectionView({ id: accessDetails.groupId ?? accessDetails.collectionId, @@ -125,4 +128,9 @@ export class MemberAccessReportService { permissionList.find((p) => p.perm === convertToPermission(collectionSelectionView))?.labelId, ); } + + private getDistinctCount(items: T[]): number { + const uniqueItems = new Set(items); + return uniqueItems.size; + } } diff --git a/libs/angular/src/vault/services/nudges.service.ts b/libs/angular/src/vault/services/nudges.service.ts index 6e8c996c066..6cb7ae4abf1 100644 --- a/libs/angular/src/vault/services/nudges.service.ts +++ b/libs/angular/src/vault/services/nudges.service.ts @@ -159,7 +159,11 @@ export class NudgesService { */ hasActiveBadges$(userId: UserId): Observable { // Add more nudge types here if they have the settings badge feature - const nudgeTypes = [NudgeType.EmptyVaultNudge, NudgeType.DownloadBitwarden]; + const nudgeTypes = [ + NudgeType.EmptyVaultNudge, + NudgeType.DownloadBitwarden, + NudgeType.AutofillNudge, + ]; const nudgeTypesWithBadge$ = nudgeTypes.map((nudge) => { return this.getNudgeService(nudge) diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.ts b/libs/common/src/platform/services/sdk/default-sdk.service.ts index d9f7ba19a6f..e874dae3461 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts @@ -212,6 +212,7 @@ export class DefaultSdkService implements SdkService { }, }, privateKey, + signingKey: undefined, }); // We initialize the org crypto even if the org_keys are diff --git a/libs/common/src/vault/types/cipher-menu-items.ts b/libs/common/src/vault/types/cipher-menu-items.ts new file mode 100644 index 00000000000..e88c0457081 --- /dev/null +++ b/libs/common/src/vault/types/cipher-menu-items.ts @@ -0,0 +1,24 @@ +import { CipherType } from "../enums"; + +/** + * Represents a menu item for creating a new cipher of a specific type + */ +export type CipherMenuItem = { + /** The cipher type this menu item represents */ + type: CipherType; + /** The icon class name (e.g., "bwi-globe") */ + icon: string; + /** The i18n key for the label text */ + labelKey: string; +}; + +/** + * All available cipher menu items with their associated icons and labels + */ +export const CIPHER_MENU_ITEMS = Object.freeze([ + { type: CipherType.Login, icon: "bwi-globe", labelKey: "typeLogin" }, + { type: CipherType.Card, icon: "bwi-credit-card", labelKey: "typeCard" }, + { type: CipherType.Identity, icon: "bwi-id-card", labelKey: "typeIdentity" }, + { type: CipherType.SecureNote, icon: "bwi-sticky-note", labelKey: "note" }, + { type: CipherType.SshKey, icon: "bwi-key", labelKey: "typeSshKey" }, +] as const) satisfies readonly CipherMenuItem[]; diff --git a/libs/components/src/dialog/dialog.service.stories.ts b/libs/components/src/dialog/dialog.service.stories.ts index a9fe92ea4bf..7e2d8c62bb6 100644 --- a/libs/components/src/dialog/dialog.service.stories.ts +++ b/libs/components/src/dialog/dialog.service.stories.ts @@ -1,11 +1,17 @@ import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; import { Component, Inject } from "@angular/core"; -import { provideAnimations } from "@angular/platform-browser/animations"; -import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { NoopAnimationsModule, provideAnimations } from "@angular/platform-browser/animations"; +import { RouterTestingModule } from "@angular/router/testing"; +import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; +import { getAllByRole, userEvent } from "@storybook/test"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ButtonModule } from "../button"; +import { IconButtonModule } from "../icon-button"; +import { LayoutComponent } from "../layout"; +import { SharedModule } from "../shared"; +import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; import { I18nMockService } from "../utils/i18n-mock.service"; import { DialogModule } from "./dialog.module"; @@ -16,7 +22,12 @@ interface Animal { } @Component({ - template: ``, + template: ` + + + + + `, imports: [ButtonModule], }) class StoryDialogComponent { @@ -29,6 +40,14 @@ class StoryDialogComponent { }, }); } + + openDrawer() { + this.dialogService.openDrawer(StoryDialogContentComponent, { + data: { + animal: "panda", + }, + }); + } } @Component({ @@ -64,7 +83,21 @@ export default { title: "Component Library/Dialogs/Service", component: StoryDialogComponent, decorators: [ + positionFixedWrapperDecorator(), moduleMetadata({ + declarations: [StoryDialogContentComponent], + imports: [ + SharedModule, + ButtonModule, + NoopAnimationsModule, + DialogModule, + IconButtonModule, + RouterTestingModule, + LayoutComponent, + ], + providers: [DialogService], + }), + applicationConfig({ providers: [ provideAnimations(), DialogService, @@ -73,7 +106,13 @@ export default { useFactory: () => { return new I18nMockService({ close: "Close", - loading: "Loading", + search: "Search", + skipToContent: "Skip to content", + submenu: "submenu", + toggleCollapse: "toggle collapse", + toggleSideNavigation: "Toggle side navigation", + yes: "Yes", + no: "No", }); }, }, @@ -90,4 +129,21 @@ export default { type Story = StoryObj; -export const Default: Story = {}; +export const Default: Story = { + play: async (context) => { + const canvas = context.canvasElement; + + const button = getAllByRole(canvas, "button")[0]; + await userEvent.click(button); + }, +}; + +/** Drawers must be a descendant of `bit-layout`. */ +export const Drawer: Story = { + play: async (context) => { + const canvas = context.canvasElement; + + const button = getAllByRole(canvas, "button")[1]; + await userEvent.click(button); + }, +}; diff --git a/libs/components/src/dialog/dialog.service.ts b/libs/components/src/dialog/dialog.service.ts index 83aaaff470e..409bf0a5b55 100644 --- a/libs/components/src/dialog/dialog.service.ts +++ b/libs/components/src/dialog/dialog.service.ts @@ -1,31 +1,25 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { - DEFAULT_DIALOG_CONFIG, - Dialog, - DialogConfig, - DialogRef, - DIALOG_SCROLL_STRATEGY, + Dialog as CdkDialog, + DialogConfig as CdkDialogConfig, + DialogRef as CdkDialogRefBase, + DIALOG_DATA, + DialogCloseOptions, } from "@angular/cdk/dialog"; -import { ComponentType, Overlay, OverlayContainer, ScrollStrategy } from "@angular/cdk/overlay"; -import { - Inject, - Injectable, - Injector, - OnDestroy, - Optional, - SkipSelf, - TemplateRef, -} from "@angular/core"; +import { ComponentType, ScrollStrategy } from "@angular/cdk/overlay"; +import { ComponentPortal, Portal } from "@angular/cdk/portal"; +import { Injectable, Injector, TemplateRef, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router } from "@angular/router"; -import { filter, firstValueFrom, Subject, switchMap, takeUntil } from "rxjs"; +import { filter, firstValueFrom, map, Observable, Subject, switchMap } from "rxjs"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DrawerService } from "../drawer/drawer.service"; + import { SimpleConfigurableDialogComponent } from "./simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component"; -import { SimpleDialogOptions, Translation } from "./simple-dialog/types"; +import { SimpleDialogOptions } from "./simple-dialog/types"; /** * The default `BlockScrollStrategy` does not work well with virtual scrolling. @@ -48,61 +42,163 @@ class CustomBlockScrollStrategy implements ScrollStrategy { detach() {} } +export abstract class DialogRef + implements Pick, "close" | "closed" | "disableClose" | "componentInstance"> +{ + abstract readonly isDrawer?: boolean; + + // --- From CdkDialogRef --- + abstract close(result?: R, options?: DialogCloseOptions): void; + abstract readonly closed: Observable; + abstract disableClose: boolean | undefined; + /** + * @deprecated + * Does not work with drawer dialogs. + **/ + abstract componentInstance: C | null; +} + +export type DialogConfig = Pick< + CdkDialogConfig, + "data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width" +>; + +class DrawerDialogRef implements DialogRef { + readonly isDrawer = true; + + private _closed = new Subject(); + closed = this._closed.asObservable(); + disableClose = false; + + /** The portal containing the drawer */ + portal?: Portal; + + constructor(private drawerService: DrawerService) {} + + close(result?: R, _options?: DialogCloseOptions): void { + if (this.disableClose) { + return; + } + this.drawerService.close(this.portal!); + this._closed.next(result); + this._closed.complete(); + } + + componentInstance: C | null = null; +} + +/** + * DialogRef that delegates functionality to the CDK implementation + **/ +export class CdkDialogRef implements DialogRef { + readonly isDrawer = false; + + /** This is not available until after construction, @see DialogService.open. */ + cdkDialogRefBase!: CdkDialogRefBase; + + // --- Delegated to CdkDialogRefBase --- + + close(result?: R, options?: DialogCloseOptions): void { + this.cdkDialogRefBase.close(result, options); + } + + get closed(): Observable { + return this.cdkDialogRefBase.closed; + } + + get disableClose(): boolean | undefined { + return this.cdkDialogRefBase.disableClose; + } + set disableClose(value: boolean | undefined) { + this.cdkDialogRefBase.disableClose = value; + } + + // Delegate the `componentInstance` property to the CDK DialogRef + get componentInstance(): C | null { + return this.cdkDialogRefBase.componentInstance; + } +} + @Injectable() -export class DialogService extends Dialog implements OnDestroy { - private _destroy$ = new Subject(); +export class DialogService { + private dialog = inject(CdkDialog); + private drawerService = inject(DrawerService); + private injector = inject(Injector); + private router = inject(Router, { optional: true }); + private authService = inject(AuthService, { optional: true }); + private i18nService = inject(I18nService); private backDropClasses = ["tw-fixed", "tw-bg-black", "tw-bg-opacity-30", "tw-inset-0"]; - private defaultScrollStrategy = new CustomBlockScrollStrategy(); + private activeDrawer: DrawerDialogRef | null = null; - constructor( - /** Parent class constructor */ - _overlay: Overlay, - _injector: Injector, - @Optional() @Inject(DEFAULT_DIALOG_CONFIG) _defaultOptions: DialogConfig, - @Optional() @SkipSelf() _parentDialog: Dialog, - _overlayContainer: OverlayContainer, - @Inject(DIALOG_SCROLL_STRATEGY) scrollStrategy: any, - - /** Not in parent class */ - @Optional() router: Router, - @Optional() authService: AuthService, - - protected i18nService: I18nService, - ) { - super(_overlay, _injector, _defaultOptions, _parentDialog, _overlayContainer, scrollStrategy); - + constructor() { + /** + * TODO: This logic should exist outside of `libs/components`. + * @see https://bitwarden.atlassian.net/browse/CL-657 + **/ /** Close all open dialogs if the vault locks */ - if (router && authService) { - router.events + if (this.router && this.authService) { + this.router.events .pipe( filter((event) => event instanceof NavigationEnd), - switchMap(() => authService.getAuthStatus()), + switchMap(() => this.authService!.getAuthStatus()), filter((v) => v !== AuthenticationStatus.Unlocked), - takeUntil(this._destroy$), + takeUntilDestroyed(), ) .subscribe(() => this.closeAll()); } } - override ngOnDestroy(): void { - this._destroy$.next(); - this._destroy$.complete(); - super.ngOnDestroy(); - } - - override open( + open( componentOrTemplateRef: ComponentType | TemplateRef, config?: DialogConfig>, ): DialogRef { - config = { + /** + * This is a bit circular in nature: + * We need the DialogRef instance for the DI injector that is passed *to* `Dialog.open`, + * but we get the base CDK DialogRef instance *from* `Dialog.open`. + * + * To break the circle, we define CDKDialogRef as a wrapper for the CDKDialogRefBase. + * This allows us to create the class instance and provide the base instance later, almost like "deferred inheritance". + **/ + const ref = new CdkDialogRef(); + const injector = this.createInjector({ + data: config?.data, + dialogRef: ref, + }); + + // Merge the custom config with the default config + const _config = { backdropClass: this.backDropClasses, scrollStrategy: this.defaultScrollStrategy, + injector, ...config, }; - return super.open(componentOrTemplateRef, config); + ref.cdkDialogRefBase = this.dialog.open(componentOrTemplateRef, _config); + return ref; + } + + /** Opens a dialog in the side drawer */ + openDrawer( + component: ComponentType, + config?: DialogConfig>, + ): DialogRef { + this.activeDrawer?.close(); + /** + * This is also circular. When creating the DrawerDialogRef, we do not yet have a portal instance to provide to the injector. + * Similar to `this.open`, we get around this with mutability. + */ + this.activeDrawer = new DrawerDialogRef(this.drawerService); + const portal = new ComponentPortal( + component, + null, + this.createInjector({ data: config?.data, dialogRef: this.activeDrawer }), + ); + this.activeDrawer.portal = portal; + this.drawerService.open(portal); + return this.activeDrawer; } /** @@ -113,8 +209,7 @@ export class DialogService extends Dialog implements OnDestroy { */ async openSimpleDialog(simpleDialogOptions: SimpleDialogOptions): Promise { const dialogRef = this.openSimpleDialogRef(simpleDialogOptions); - - return firstValueFrom(dialogRef.closed); + return firstValueFrom(dialogRef.closed.pipe(map((v: boolean | undefined) => !!v))); } /** @@ -134,20 +229,29 @@ export class DialogService extends Dialog implements OnDestroy { }); } - protected translate(translation: string | Translation, defaultKey?: string): string { - if (translation == null && defaultKey == null) { - return null; - } + /** Close all open dialogs */ + closeAll(): void { + return this.dialog.closeAll(); + } - if (translation == null) { - return this.i18nService.t(defaultKey); - } - - // Translation interface use implies we must localize. - if (typeof translation === "object") { - return this.i18nService.t(translation.key, ...(translation.placeholders ?? [])); - } - - return translation; + /** The injector that is passed to the opened dialog */ + private createInjector(opts: { data: unknown; dialogRef: DialogRef }): Injector { + return Injector.create({ + providers: [ + { + provide: DIALOG_DATA, + useValue: opts.data, + }, + { + provide: DialogRef, + useValue: opts.dialogRef, + }, + { + provide: CdkDialogRefBase, + useValue: opts.dialogRef, + }, + ], + parent: this.injector, + }); } } diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index 01f05985127..eaf7fc2beec 100644 --- a/libs/components/src/dialog/dialog/dialog.component.html +++ b/libs/components/src/dialog/dialog/dialog.component.html @@ -1,12 +1,22 @@ +@let isDrawer = dialogRef?.isDrawer;
+ @let showHeaderBorder = !isDrawer || background === "alt" || bodyHasScrolledFrom().top;
-

} -

+ @@ -88,72 +149,6 @@ class KitchenSinkDialog { - - - - -

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

- - What did foo say to bar? - - -

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt - ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation - ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur - sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id - est laborum. -

-
-
`, }) export class KitchenSinkMainComponent { @@ -166,7 +161,7 @@ export class KitchenSinkMainComponent { } openDrawer() { - this.drawerOpen.set(true); + this.dialogService.openDrawer(KitchenSinkDialog); } navItems = [ diff --git a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts index f57a9de4e68..d318e1b5f0e 100644 --- a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts +++ b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts @@ -14,7 +14,6 @@ import { import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { DialogService } from "../../dialog"; import { LayoutComponent } from "../../layout"; import { I18nMockService } from "../../utils/i18n-mock.service"; import { positionFixedWrapperDecorator } from "../storybook-decorators"; @@ -39,8 +38,20 @@ export default { KitchenSinkTable, KitchenSinkToggleList, ], + }), + applicationConfig({ providers: [ - DialogService, + provideNoopAnimations(), + importProvidersFrom( + RouterModule.forRoot( + [ + { path: "", redirectTo: "bitwarden", pathMatch: "full" }, + { path: "bitwarden", component: KitchenSinkMainComponent }, + { path: "virtual-scroll", component: DialogVirtualScrollBlockComponent }, + ], + { useHash: true }, + ), + ), { provide: I18nService, useFactory: () => { @@ -58,21 +69,6 @@ export default { }, ], }), - applicationConfig({ - providers: [ - provideNoopAnimations(), - importProvidersFrom( - RouterModule.forRoot( - [ - { path: "", redirectTo: "bitwarden", pathMatch: "full" }, - { path: "bitwarden", component: KitchenSinkMainComponent }, - { path: "virtual-scroll", component: DialogVirtualScrollBlockComponent }, - ], - { useHash: true }, - ), - ), - ], - }), ], } as Meta; diff --git a/libs/components/src/stories/virtual-scrolling.mdx b/libs/components/src/stories/virtual-scrolling.mdx new file mode 100644 index 00000000000..94a86090dce --- /dev/null +++ b/libs/components/src/stories/virtual-scrolling.mdx @@ -0,0 +1,60 @@ +import { Meta } from "@storybook/addon-docs"; + + + +# Virtual Scrolling + +Virtual scrolling is a technique that improves the rendering performance of very large lists by only +rendering whatever is currently visible within the viewport. We build on top of +[Angular CDK's `ScrollingModule`](https://material.angular.dev/cdk/scrolling/overview). + +## Scrolling the entire layout + +Often, a design calls for the scroll container to envelop the entire page. To support this, +AngularCDK provides a `scrollWindow` directive that sets the window to be virtual scroll viewport. +We export a similar directive, `bitScrollLayout`, that integrates with `bit-layout` and `popup-page` +and should be used instead of `scrollWindow`. + +```html + + + + +``` + +### Known footgun + +Due to the initialization order of Angular components and their templates, `bitScrollLayout` will +error if it is used _in the same template_ as the layout component: + +```html + + + + + +``` + +In this particular composition, the child content gets constructed before the template of +`bit-layout` and thus has no scroll container to reference. Workarounds include: + +1. Wrap the child in another component. (This tends to happen by default when the layout is + integrated with a `router-outlet`.) + +```html + + + +``` + +2. Use a `defer` block. + +```html + + @defer (on immediate) { + + + + } + +``` diff --git a/libs/components/src/table/table-scroll.component.html b/libs/components/src/table/table-scroll.component.html index 8f2c88ba3ad..523912cd7ac 100644 --- a/libs/components/src/table/table-scroll.component.html +++ b/libs/components/src/table/table-scroll.component.html @@ -1,5 +1,5 @@ diff --git a/libs/components/src/table/table-scroll.component.ts b/libs/components/src/table/table-scroll.component.ts index b463b12f6ce..193d790e416 100644 --- a/libs/components/src/table/table-scroll.component.ts +++ b/libs/components/src/table/table-scroll.component.ts @@ -4,7 +4,6 @@ import { CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf, - CdkVirtualScrollableWindow, } from "@angular/cdk/scrolling"; import { CommonModule } from "@angular/common"; import { @@ -21,6 +20,8 @@ import { TrackByFunction, } from "@angular/core"; +import { ScrollLayoutDirective } from "../layout"; + import { RowDirective } from "./row.directive"; import { TableComponent } from "./table.component"; @@ -52,10 +53,10 @@ export class BitRowDef { imports: [ CommonModule, CdkVirtualScrollViewport, - CdkVirtualScrollableWindow, CdkFixedSizeVirtualScroll, CdkVirtualForOf, RowDirective, + ScrollLayoutDirective, ], }) export class TableScrollComponent diff --git a/libs/components/src/table/table.mdx b/libs/components/src/table/table.mdx index 8d784190ed9..59bf5b773a3 100644 --- a/libs/components/src/table/table.mdx +++ b/libs/components/src/table/table.mdx @@ -142,7 +142,7 @@ dataSource.filter = (data) => data.orgType === "family"; Rudimentary string filtering is supported out of the box with `TableDataSource.simpleStringFilter`. It works by converting each entry into a string of it's properties. The provided string is then -compared against the filter value using a simple `indexOf` check. For convienence, you can also just +compared against the filter value using a simple `indexOf` check. For convenience, you can also just pass a string directly. ```ts @@ -153,7 +153,7 @@ dataSource.filter = "search value"; ### Virtual Scrolling -It's heavily adviced to use virtual scrolling if you expect the table to have any significant amount +It's heavily advised to use virtual scrolling if you expect the table to have any significant amount of data. This is done by using the `bit-table-scroll` component instead of the `bit-table` component. This component behaves slightly different from the `bit-table` component. Instead of using the `*ngFor` directive to render the rows, you provide a `bitRowDef` template that will be @@ -178,6 +178,14 @@ height and align vertically. ``` +#### Deprecated approach + +Before `bit-table-scroll` was introduced, virtual scroll in tables was implemented manually via +constructs from Angular CDK. This included wrapping the table with a `cdk-virtual-scroll-viewport` +and targeting with `bit-layout`'s scroll container with the `bitScrollLayout` directive. + +This pattern is deprecated in favor of `bit-table-scroll`. + ## Accessibility - Always include a row or column header with your table; this allows assistive technology to better diff --git a/libs/components/src/table/table.stories.ts b/libs/components/src/table/table.stories.ts index e8ab24ee8b7..d696e6077dd 100644 --- a/libs/components/src/table/table.stories.ts +++ b/libs/components/src/table/table.stories.ts @@ -1,6 +1,13 @@ +import { RouterTestingModule } from "@angular/router/testing"; import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + import { countries } from "../form/countries"; +import { LayoutComponent } from "../layout"; +import { mockLayoutI18n } from "../layout/mocks"; +import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; +import { I18nMockService } from "../utils"; import { TableDataSource } from "./table-data-source"; import { TableModule } from "./table.module"; @@ -8,8 +15,17 @@ import { TableModule } from "./table.module"; export default { title: "Component Library/Table", decorators: [ + positionFixedWrapperDecorator(), moduleMetadata({ - imports: [TableModule], + imports: [TableModule, LayoutComponent, RouterTestingModule], + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService(mockLayoutI18n); + }, + }, + ], }), ], argTypes: { @@ -116,18 +132,20 @@ export const Scrollable: Story = { trackBy: (index: number, item: any) => item.id, }, template: ` - - - Id - Name - Other - - - {{ row.id }} - {{ row.name }} - {{ row.other }} - - + + + + Id + Name + Other + + + {{ row.id }} + {{ row.name }} + {{ row.other }} + + + `, }), }; @@ -144,17 +162,19 @@ export const Filterable: Story = { sortFn: (a: any, b: any) => a.id - b.id, }, template: ` - - - - Name - Value - - - {{ row.name }} - {{ row.value }} - - + + + + + Name + Value + + + {{ row.name }} + {{ row.value }} + + + `, }), }; diff --git a/libs/components/src/utils/has-scrolled-from.ts b/libs/components/src/utils/has-scrolled-from.ts new file mode 100644 index 00000000000..44c73465bdd --- /dev/null +++ b/libs/components/src/utils/has-scrolled-from.ts @@ -0,0 +1,41 @@ +import { CdkScrollable } from "@angular/cdk/scrolling"; +import { Signal, inject, signal } from "@angular/core"; +import { toObservable, toSignal } from "@angular/core/rxjs-interop"; +import { map, startWith, switchMap } from "rxjs"; + +export type ScrollState = { + /** `true` when the scrollbar is not at the top-most position */ + top: boolean; + + /** `true` when the scrollbar is not at the bottom-most position */ + bottom: boolean; +}; + +/** + * Check if a `CdkScrollable` instance has been scrolled + * @param scrollable The instance to check, defaults to the one provided by the current injector + * @returns {Signal} + */ +export const hasScrolledFrom = (scrollable?: Signal): Signal => { + const _scrollable = scrollable ?? signal(inject(CdkScrollable)); + const scrollable$ = toObservable(_scrollable); + + const scrollState$ = scrollable$.pipe( + switchMap((_scrollable) => + _scrollable.elementScrolled().pipe( + startWith(null), + map(() => ({ + top: _scrollable.measureScrollOffset("top") > 0, + bottom: _scrollable.measureScrollOffset("bottom") > 0, + })), + ), + ), + ); + + return toSignal(scrollState$, { + initialValue: { + top: false, + bottom: false, + }, + }); +}; diff --git a/package-lock.json b/package-lock.json index ee6c3fcb848..0b91c139a55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/sdk-internal": "0.2.0-main.198", + "@bitwarden/sdk-internal": "0.2.0-main.203", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "3.1.0", @@ -197,11 +197,11 @@ }, "apps/browser": { "name": "@bitwarden/browser", - "version": "2025.5.1" + "version": "2025.6.0" }, "apps/cli": { "name": "@bitwarden/cli", - "version": "2025.5.0", + "version": "2025.6.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@koa/multer": "3.1.0", @@ -256,7 +256,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2025.6.0", + "version": "2025.6.1", "hasInstallScript": true, "license": "GPL-3.0" }, @@ -270,7 +270,7 @@ }, "apps/web": { "name": "@bitwarden/web-vault", - "version": "2025.6.0" + "version": "2025.6.1" }, "libs/admin-console": { "name": "@bitwarden/admin-console", @@ -4378,9 +4378,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.198", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.198.tgz", - "integrity": "sha512-/MRdlcBqGxFEK/p6bU4hu5ZRoa+PqU88S+xnQaFrCXsWCTXrC8Nvm46iiz6gAqdbfFQWFNLCtmoNx6LFUdRuNg==", + "version": "0.2.0-main.203", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.203.tgz", + "integrity": "sha512-AcRX2odnabnx16VF+K7naEZ3R4Tv/o8mVsVhrvwOTG+TEBUxR1BzCoE2r+l0+iz1zV32UV2YHeLZvyCB2/KftA==", "license": "GPL-3.0", "dependencies": { "type-fest": "^4.41.0" diff --git a/package.json b/package.json index 3304d168259..d2e480f6762 100644 --- a/package.json +++ b/package.json @@ -160,7 +160,7 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/sdk-internal": "0.2.0-main.198", + "@bitwarden/sdk-internal": "0.2.0-main.203", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "3.1.0",