From 62d3fe21a6f4a2826636e3936d532fa61ef7300d Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Mon, 24 Feb 2025 15:48:09 -0500 Subject: [PATCH 01/41] feat(2FA-Setup/Recovery-Dialogs): [Auth/PM-11701] Refactor dialogs to properly use title & subtitle inputs for consistent styling (#13493) --- .../two-factor/two-factor-recovery.component.html | 10 +++++----- .../two-factor-setup-authenticator.component.html | 10 +++++----- .../two-factor/two-factor-setup-duo.component.html | 6 +----- .../two-factor/two-factor-setup-email.component.html | 6 +----- .../two-factor-setup-webauthn.component.html | 10 +++++----- .../two-factor/two-factor-setup-yubikey.component.html | 6 +----- .../two-factor/two-factor-verify.component.html | 6 +----- 7 files changed, 19 insertions(+), 35 deletions(-) diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-recovery.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-recovery.component.html index 98676509078..413432e5a02 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-recovery.component.html +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-recovery.component.html @@ -1,8 +1,8 @@ - - - {{ "twoStepLogin" | i18n }} - {{ "recoveryCodeTitle" | i18n }} - +

{{ "twoFactorRecoveryYourCode" | i18n }}:

diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.html index c9214d59caa..a31d4c33458 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.html +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.html @@ -1,9 +1,9 @@
- - - {{ "twoStepLogin" | i18n }} - {{ "authenticatorAppTitle" | i18n }} - + diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.html index f20bd4f5f70..3a62ec91a8e 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.html +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.html @@ -1,9 +1,5 @@ - - - {{ "twoStepLogin" | i18n }} - Duo - + diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.html index f79a3bc7b0a..5861d1fba4f 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.html +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.html @@ -1,9 +1,5 @@ - - - {{ "twoStepLogin" | i18n }} - {{ "emailTitle" | i18n }} - + diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.html index c9e2e111481..8e7a789d9d0 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.html +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.html @@ -1,9 +1,9 @@ - - - {{ "twoStepLogin" | i18n }} - {{ "webAuthnTitle" | i18n }} - + - - - {{ "twoStepLogin" | i18n }} - YubiKey - + - - - {{ "twoStepLogin" | i18n }} - {{ dialogTitle }} - + Date: Mon, 24 Feb 2025 15:51:56 -0500 Subject: [PATCH 02/41] feat(LoginViaAuthRequestComponent): [Auth/PM-18506] fix missing spaces (#13543) --- .../login-via-auth-request.component.html | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.html b/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.html index ba26ba77cb0..22cf8320036 100644 --- a/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.html +++ b/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.html @@ -32,7 +32,8 @@
- {{ "needAnotherOptionV1" | i18n }} + {{ "needAnotherOptionV1" | i18n }}  {{ "viewAllLogInOptions" | i18n }} @@ -46,7 +47,8 @@ {{ fingerprintPhrase }}
- {{ "troubleLoggingIn" | i18n }} + {{ "troubleLoggingIn" | i18n }}  {{ "viewAllLogInOptions" | i18n }} From 8552578fbb581f4091f000c1ce1a557951ed9533 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 24 Feb 2025 13:53:02 -0800 Subject: [PATCH 03/41] [PM-12720] - sort organizations in vault item owner select (#13419) * sort owner select options and filters by name * don't sort filters * fix tests * fix tests * set organizations in ngOninit * move assignment up * Revert change to add-edit.component.ts. Move assignment up. * fix tests --- .../item-details-section.component.html | 2 +- .../item-details-section.component.spec.ts | 38 ++++++++++++++++--- .../item-details-section.component.ts | 11 ++++-- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.html b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.html index c68df5bbfac..40a8954b05a 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.html +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.html @@ -27,7 +27,7 @@ [label]="userEmail$ | async" > diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts index 3995422944c..aa68770774a 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -59,6 +59,9 @@ describe("ItemDetailsSectionComponent", () => { initializedWithCachedCipher, }); i18nService = mock(); + i18nService.collator = { + compare: (a: string, b: string) => a.localeCompare(b), + } as Intl.Collator; await TestBed.configureTestingModule({ imports: [ItemDetailsSectionComponent, CommonModule, ReactiveFormsModule], @@ -184,16 +187,18 @@ describe("ItemDetailsSectionComponent", () => { it("should allow ownership change if personal ownership is allowed and there is at least one organization", () => { component.config.allowPersonalOwnership = true; - component.config.organizations = [{ id: "org1" } as Organization]; + component.config.organizations = [{ id: "org1", name: "org1" } as Organization]; + fixture.detectChanges(); expect(component.allowOwnershipChange).toBe(true); }); it("should allow ownership change if personal ownership is not allowed but there is more than one organization", () => { component.config.allowPersonalOwnership = false; component.config.organizations = [ - { id: "org1" } as Organization, - { id: "org2" } as Organization, + { id: "org1", name: "org1" } as Organization, + { id: "org2", name: "org2" } as Organization, ]; + fixture.detectChanges(); expect(component.allowOwnershipChange).toBe(true); }); }); @@ -206,7 +211,8 @@ describe("ItemDetailsSectionComponent", () => { it("should return the first organization id if personal ownership is not allowed", () => { component.config.allowPersonalOwnership = false; - component.config.organizations = [{ id: "org1" } as Organization]; + component.config.organizations = [{ id: "org1", name: "Organization 1" } as Organization]; + fixture.detectChanges(); expect(component.defaultOwner).toBe("org1"); }); }); @@ -250,6 +256,7 @@ describe("ItemDetailsSectionComponent", () => { jest.spyOn(component, "allowOwnershipChange", "get").mockReturnValue(false); component.config.mode = "edit"; component.config.organizations = [{ id: "org1" } as Organization]; + fixture.detectChanges(); expect(component.showOwnership).toBe(true); }); @@ -322,8 +329,8 @@ describe("ItemDetailsSectionComponent", () => { it("should select the first organization if personal ownership is not allowed", async () => { component.config.allowPersonalOwnership = false; component.config.organizations = [ - { id: "org1" } as Organization, - { id: "org2" } as Organization, + { id: "org1", name: "org1" } as Organization, + { id: "org2", name: "org2" } as Organization, ]; component.originalCipherView = { name: "cipher1", @@ -517,4 +524,23 @@ describe("ItemDetailsSectionComponent", () => { expect(component["readOnlyCollectionsNames"]).toEqual(["Collection 1", "Collection 3"]); }); }); + + describe("organizationOptions", () => { + it("should sort the organizations by name", async () => { + component.config.mode = "edit"; + component.config.organizations = [ + { id: "org2", name: "org2" } as Organization, + { id: "org1", name: "org1" } as Organization, + ]; + component.originalCipherView = {} as CipherView; + + await component.ngOnInit(); + fixture.detectChanges(); + + const select = fixture.debugElement.query(By.directive(SelectComponent)); + const { label } = select.componentInstance.items[0]; + + expect(label).toBe("org1"); + }); + }); }); diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts index 50bafd48b41..dcbc4e8c92f 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -12,6 +12,7 @@ import { OrganizationUserType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { @@ -74,6 +75,8 @@ export class ItemDetailsSectionComponent implements OnInit { /** The email address associated with the active account */ protected userEmail$ = this.accountService.activeAccount$.pipe(map((account) => account.email)); + protected organizations: Organization[] = []; + @Input({ required: true }) config: CipherFormConfig; @@ -90,10 +93,6 @@ export class ItemDetailsSectionComponent implements OnInit { return this.config.mode === "partial-edit"; } - get organizations(): Organization[] { - return this.config.organizations; - } - get allowPersonalOwnership() { return this.config.allowPersonalOwnership; } @@ -186,6 +185,10 @@ export class ItemDetailsSectionComponent implements OnInit { } async ngOnInit() { + this.organizations = this.config.organizations.sort( + Utils.getSortFunction(this.i18nService, "name"), + ); + if (!this.allowPersonalOwnership && this.organizations.length === 0) { throw new Error("No organizations available for ownership."); } From e06a482d6e3d6474927d52e92ddc6e9fb3824dbb Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Mon, 24 Feb 2025 17:27:15 -0500 Subject: [PATCH 04/41] fix(NewDeviceVerification): [Auth/PM-18580] Fix loading state on submit (#13545) --- .../new-device-verification.component.html | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/auth/src/angular/new-device-verification/new-device-verification.component.html b/libs/auth/src/angular/new-device-verification/new-device-verification.component.html index 2f807d32993..e731f3afcb6 100644 --- a/libs/auth/src/angular/new-device-verification/new-device-verification.component.html +++ b/libs/auth/src/angular/new-device-verification/new-device-verification.component.html @@ -25,6 +25,7 @@
+ +
+ {{ "newCustomizationOptionsCalloutContent" | i18n }} + + {{ "newCustomizationOptionsCalloutLink" | i18n }} + +
+
+ diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-settings-callout/new-settings-callout.component.ts b/apps/browser/src/vault/popup/components/vault-v2/new-settings-callout/new-settings-callout.component.ts new file mode 100644 index 00000000000..71549906474 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/new-settings-callout/new-settings-callout.component.ts @@ -0,0 +1,69 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { ButtonModule, PopoverModule } from "@bitwarden/components"; + +import { VaultPageService } from "../vault-page.service"; + +@Component({ + selector: "new-settings-callout", + templateUrl: "new-settings-callout.component.html", + standalone: true, + imports: [PopoverModule, JslibModule, CommonModule, ButtonModule], + providers: [VaultPageService], +}) +export class NewSettingsCalloutComponent implements OnInit, OnDestroy { + protected showNewCustomizationSettingsCallout = false; + protected activeUserId: UserId | null = null; + + constructor( + private accountService: AccountService, + private vaultProfileService: VaultProfileService, + private vaultPageService: VaultPageService, + private router: Router, + private logService: LogService, + ) {} + + async ngOnInit() { + this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + + let profileCreatedDate: Date; + + try { + profileCreatedDate = await this.vaultProfileService.getProfileCreationDate(this.activeUserId); + } catch (e) { + this.logService.error("Error getting profile creation date", e); + // Default to before the cutoff date to ensure the callout is shown + profileCreatedDate = new Date("2024-12-24"); + } + + const hasCalloutBeenDismissed = await firstValueFrom( + this.vaultPageService.isCalloutDismissed(this.activeUserId), + ); + + this.showNewCustomizationSettingsCallout = + !hasCalloutBeenDismissed && profileCreatedDate < new Date("2024-12-25"); + } + + async goToAppearance() { + await this.router.navigate(["/appearance"]); + } + + async dismissCallout() { + if (this.activeUserId) { + await this.vaultPageService.dismissCallout(this.activeUserId); + } + } + + async ngOnDestroy() { + await this.dismissCallout(); + } +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-page.service.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-page.service.ts new file mode 100644 index 00000000000..75354298c26 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-page.service.ts @@ -0,0 +1,41 @@ +import { inject, Injectable } from "@angular/core"; +import { map, Observable } from "rxjs"; + +import { + BANNERS_DISMISSED_DISK, + StateProvider, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; + +export const NEW_CUSTOMIZATION_OPTIONS_CALLOUT_DISMISSED_KEY = new UserKeyDefinition( + BANNERS_DISMISSED_DISK, + "newCustomizationOptionsCalloutDismissed", + { + deserializer: (calloutDismissed) => calloutDismissed, + clearOn: [], // Do not clear dismissed callouts + }, +); + +@Injectable() +export class VaultPageService { + private stateProvider = inject(StateProvider); + + async unDismissCallout(userId: UserId): Promise { + await this.stateProvider + .getUser(userId, NEW_CUSTOMIZATION_OPTIONS_CALLOUT_DISMISSED_KEY) + .update(() => false); + } + + isCalloutDismissed(userId: UserId): Observable { + return this.stateProvider + .getUser(userId, NEW_CUSTOMIZATION_OPTIONS_CALLOUT_DISMISSED_KEY) + .state$.pipe(map((dismissed) => !!dismissed)); + } + + async dismissCallout(userId: UserId): Promise { + await this.stateProvider + .getUser(userId, NEW_CUSTOMIZATION_OPTIONS_CALLOUT_DISMISSED_KEY) + .update(() => true); + } +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html index 8cb538a429a..bb7cd8e52d0 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html @@ -85,4 +85,5 @@ >
+ diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts index 73b691bc4ac..1ae1b205af3 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts @@ -15,6 +15,7 @@ import { } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -44,7 +45,9 @@ import { NewItemDropdownV2Component, NewItemInitialValues, } from "./new-item-dropdown/new-item-dropdown-v2.component"; +import { NewSettingsCalloutComponent } from "./new-settings-callout/new-settings-callout.component"; import { VaultHeaderV2Component } from "./vault-header/vault-header-v2.component"; +import { VaultPageService } from "./vault-page.service"; import { AutofillVaultListItemsComponent, VaultListItemsContainerComponent } from "."; @@ -77,7 +80,9 @@ enum VaultState { DecryptionFailureDialogComponent, BannerComponent, AtRiskPasswordCalloutComponent, + NewSettingsCalloutComponent, ], + providers: [VaultPageService], }) export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { @ViewChild(CdkVirtualScrollableElement) virtualScrollElement?: CdkVirtualScrollableElement; @@ -115,6 +120,7 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { protected noResultsIcon = Icons.NoResults; protected VaultStateEnum = VaultState; + protected showNewCustomizationSettingsCallout = false; constructor( private vaultPopupItemsService: VaultPopupItemsService, @@ -124,6 +130,8 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { private destroyRef: DestroyRef, private cipherService: CipherService, private dialogService: DialogService, + private vaultProfileService: VaultProfileService, + private vaultPageService: VaultPageService, ) { combineLatest([ this.vaultPopupItemsService.emptyVault$, @@ -178,7 +186,7 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { }); } - ngOnDestroy(): void { + ngOnDestroy() { this.vaultScrollPositionService.stop(); } From bc415d807c8da0c08eedf658a092bfc08b619146 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 24 Feb 2025 14:56:15 -0800 Subject: [PATCH 06/41] [PM-13989] - Extension Vault screen - allow copy icon to copy data directly if only 1 piece of data is available (#13520) * wip - copy button overhaul * finalize item copy actions single item copy --- apps/browser/src/_locales/en/messages.json | 14 ++ .../item-copy-actions.component.html | 136 +++++++++++------- .../item-copy-actions.component.ts | 52 ++++++- 3 files changed, 153 insertions(+), 49 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index b2d042945b0..ea9e62916a2 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -4236,6 +4236,20 @@ } } }, + "copyFieldValue": { + "message": "Copy $FIELD$, $VALUE$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "value": { + "content": "$2", + "example": "Foo" + } + } + }, "noValuesToCopy": { "message": "No values to copy" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html index fbfebe8efff..bb3a7b12096 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html @@ -36,32 +36,46 @@ - - + - - + bitIconButton="bwi-clone" + size="small" + [appA11yTitle]=" + hasLoginValues ? ('copyInfoTitle' | i18n: cipher.name) : ('noValuesToCopy' | i18n) + " + [disabled]="!hasLoginValues" + [bitMenuTriggerFor]="loginOptions" + > + + + + + + @@ -92,52 +106,78 @@ - - - - + + + + + + + - - - - - - + + + + + + + + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts index 53439dc4abd..a51e5f5406a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts @@ -4,6 +4,7 @@ import { CommonModule } from "@angular/common"; import { Component, Input, inject } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { IconButtonModule, ItemModule, MenuModule } from "@bitwarden/components"; @@ -11,6 +12,11 @@ import { CopyCipherFieldDirective } from "@bitwarden/vault"; import { VaultPopupCopyButtonsService } from "../../../services/vault-popup-copy-buttons.service"; +type CipherItem = { + value: string; + key: string; +}; + @Component({ standalone: true, selector: "app-item-copy-actions", @@ -37,6 +43,50 @@ export class ItemCopyActionsComponent { ); } + get singleCopiableLogin() { + const loginItems: CipherItem[] = [ + { value: this.cipher.login.username, key: "username" }, + { value: this.cipher.login.password, key: "password" }, + { value: this.cipher.login.totp, key: "totp" }, + ]; + // If both the password and username are visible but the password is hidden, return the username + if (!this.cipher.viewPassword && this.cipher.login.username && this.cipher.login.password) { + return { value: this.cipher.login.username, key: this.i18nService.t("username") }; + } + return this.findSingleCopiableItem(loginItems); + } + + get singleCopiableCard() { + const cardItems: CipherItem[] = [ + { value: this.cipher.card.code, key: "code" }, + { value: this.cipher.card.number, key: "number" }, + ]; + return this.findSingleCopiableItem(cardItems); + } + + get singleCopiableIdentity() { + const identityItems: CipherItem[] = [ + { value: this.cipher.identity.fullAddressForCopy, key: "address" }, + { value: this.cipher.identity.email, key: "email" }, + { value: this.cipher.identity.username, key: "username" }, + { value: this.cipher.identity.phone, key: "phone" }, + ]; + return this.findSingleCopiableItem(identityItems); + } + + /* + * Given a list of CipherItems, if there is only one item with a value, + * return it with the translated key. Otherwise return null + */ + findSingleCopiableItem(items: { value: string; key: string }[]): CipherItem | null { + const singleItemWithValue = items.find( + (key) => key.value && items.every((f) => f === key || !f.value), + ); + return singleItemWithValue + ? { value: singleItemWithValue.value, key: this.i18nService.t(singleItemWithValue.key) } + : null; + } + get hasCardValues() { return !!this.cipher.card.code || !!this.cipher.card.number; } @@ -62,5 +112,5 @@ export class ItemCopyActionsComponent { ); } - constructor() {} + constructor(private i18nService: I18nService) {} } From f66446fa69ff6f0e54028d35d2130142a6e73360 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Tue, 25 Feb 2025 11:05:33 +0100 Subject: [PATCH 07/41] Renovate: disable major upgrades of angular (#13533) * Renovate: disable major upgrades of angular --- .github/renovate.json5 | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 6d6fbbd2539..a0826039bb8 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -22,6 +22,18 @@ description: "Determined by Angular", enabled: false, }, + { + matchSourceUrls: [ + "https://github.com/angular-eslint/angular-eslint", + "https://github.com/angular/angular-cli", + "https://github.com/angular/angular", + "https://github.com/angular/components", + "https://github.com/ng-select/ng-select", + ], + matchUpdateTypes: ["major"], + description: "Manually updated using ng update", + enabled: false, + }, { matchPackageNames: ["typescript", "zone.js"], matchUpdateTypes: "patch", @@ -90,12 +102,8 @@ }, { matchPackageNames: [ - "@angular-eslint/eslint-plugin-template", - "@angular-eslint/eslint-plugin", "@angular-eslint/schematics", - "@angular-eslint/template-parser", - "@typescript-eslint/eslint-plugin", - "@typescript-eslint/parser", + "angular-eslint", "eslint-config-prettier", "eslint-import-resolver-typescript", "eslint-plugin-import", @@ -106,6 +114,7 @@ "eslint", "husky", "lint-staged", + "typescript-eslint", ], groupName: "Linting minor-patch", matchUpdateTypes: ["minor", "patch"], From 240f9f934894154f6e98ace93357bd27ded40c88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Tue, 25 Feb 2025 12:30:26 +0100 Subject: [PATCH 08/41] Make native-messaging-test-runner use desktop_proxy (#11923) * Make native-messaging-test-runner use desktop_proxy * Remove node-ipc * Fix build and implement proxy selection * Remove eslint disable --------- Co-authored-by: Matt Bishop --- .../package-lock.json | 81 +-------- .../native-messaging-test-runner/package.json | 4 +- .../src/ipc.service.ts | 169 +++++++++++++----- .../tsconfig.json | 9 +- package-lock.json | 70 -------- package.json | 2 - 6 files changed, 140 insertions(+), 195 deletions(-) diff --git a/apps/desktop/native-messaging-test-runner/package-lock.json b/apps/desktop/native-messaging-test-runner/package-lock.json index f9c5f0709e4..d3f6c53a373 100644 --- a/apps/desktop/native-messaging-test-runner/package-lock.json +++ b/apps/desktop/native-messaging-test-runner/package-lock.json @@ -12,15 +12,13 @@ "@bitwarden/common": "file:../../../libs/common", "@bitwarden/node": "file:../../../libs/node", "module-alias": "2.2.3", - "node-ipc": "9.2.1", "ts-node": "10.9.2", "uuid": "11.0.5", "yargs": "17.7.2" }, "devDependencies": { "@types/node": "22.10.7", - "@types/node-ipc": "9.2.3", - "typescript": "4.7.4" + "typescript": "5.4.2" } }, "../../../libs/common": { @@ -31,10 +29,7 @@ "../../../libs/node": { "name": "@bitwarden/node", "version": "0.0.0", - "license": "GPL-3.0", - "dependencies": { - "@bitwarden/common": "file:../common" - } + "license": "GPL-3.0" }, "node_modules/@bitwarden/common": { "resolved": "../../../libs/common", @@ -114,16 +109,6 @@ "undici-types": "~6.20.0" } }, - "node_modules/@types/node-ipc": { - "version": "9.2.3", - "resolved": "https://registry.npmjs.org/@types/node-ipc/-/node-ipc-9.2.3.tgz", - "integrity": "sha512-/MvSiF71fYf3+zwqkh/zkVkZj1hl1Uobre9EMFy08mqfJNAmpR0vmPgOUdEIDVgifxHj6G1vYMPLSBLLxoDACQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -225,15 +210,6 @@ "node": ">=0.3.1" } }, - "node_modules/easy-stack": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/easy-stack/-/easy-stack-1.0.1.tgz", - "integrity": "sha512-wK2sCs4feiiJeFXn3zvY0p41mdU5VUgbgs1rNsc/y5ngFUijdWd+iIN8eoyuZHKB8xN6BL4PdWmzqFmxNg6V2w==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -249,15 +225,6 @@ "node": ">=6" } }, - "node_modules/event-pubsub": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/event-pubsub/-/event-pubsub-4.3.0.tgz", - "integrity": "sha512-z7IyloorXvKbFx9Bpie2+vMJKKx1fH1EN5yiTfp8CiLOTptSYy1g8H4yDpGlEdshL1PBiFtBHepF2cNsqeEeFQ==", - "license": "Unlicense", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -276,27 +243,6 @@ "node": ">=8" } }, - "node_modules/js-message": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz", - "integrity": "sha512-efJLHhLjIyKRewNS9EGZ4UpI8NguuL6fKkhRxVuMmrGV2xN/0APGdQYwLFky5w9naebSZ0OwAGp0G6/2Cg90rA==", - "license": "MIT", - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/js-queue": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/js-queue/-/js-queue-2.0.2.tgz", - "integrity": "sha512-pbKLsbCfi7kriM3s1J4DDCo7jQkI58zPLHi0heXPzPlj0hjUsm+FesPUbE0DSbIVIK503A36aUBoCN7eMFedkA==", - "license": "MIT", - "dependencies": { - "easy-stack": "^1.0.1" - }, - "engines": { - "node": ">=1.0.0" - } - }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -309,20 +255,6 @@ "integrity": "sha512-23g5BFj4zdQL/b6tor7Ji+QY4pEfNH784BMslY9Qb0UnJWRAt+lQGLYmRaM0KDBwIG23ffEBELhZDP2rhi9f/Q==", "license": "MIT" }, - "node_modules/node-ipc": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/node-ipc/-/node-ipc-9.2.1.tgz", - "integrity": "sha512-mJzaM6O3xHf9VT8BULvJSbdVbmHUKRNOH7zDDkCrA1/T+CVjq2WVIDfLt0azZRXpgArJtl3rtmEozrbXPZ9GaQ==", - "license": "MIT", - "dependencies": { - "event-pubsub": "4.3.0", - "js-message": "1.0.7", - "js-queue": "2.0.2" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -402,16 +334,15 @@ } }, "node_modules/typescript": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", - "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", - "license": "Apache-2.0", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", + "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/undici-types": { diff --git a/apps/desktop/native-messaging-test-runner/package.json b/apps/desktop/native-messaging-test-runner/package.json index 977b93e70d2..0df272c142f 100644 --- a/apps/desktop/native-messaging-test-runner/package.json +++ b/apps/desktop/native-messaging-test-runner/package.json @@ -17,15 +17,13 @@ "@bitwarden/common": "file:../../../libs/common", "@bitwarden/node": "file:../../../libs/node", "module-alias": "2.2.3", - "node-ipc": "9.2.1", "ts-node": "10.9.2", "uuid": "11.0.5", "yargs": "17.7.2" }, "devDependencies": { "@types/node": "22.10.7", - "@types/node-ipc": "9.2.3", - "typescript": "4.7.4" + "typescript": "5.4.2" }, "_moduleAliases": { "@bitwarden/common": "dist/libs/common/src", diff --git a/apps/desktop/native-messaging-test-runner/src/ipc.service.ts b/apps/desktop/native-messaging-test-runner/src/ipc.service.ts index 68c7ac73ab0..b02ff1a4225 100644 --- a/apps/desktop/native-messaging-test-runner/src/ipc.service.ts +++ b/apps/desktop/native-messaging-test-runner/src/ipc.service.ts @@ -1,9 +1,7 @@ /* eslint-disable no-console */ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { homedir } from "os"; - -import * as NodeIPC from "node-ipc"; +import { ChildProcess, spawn } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; // eslint-disable-next-line no-restricted-imports import { MessageCommon } from "../../src/models/native-messaging/message-common"; @@ -13,11 +11,6 @@ import { UnencryptedMessageResponse } from "../../src/models/native-messaging/un import Deferred from "./deferred"; import { race } from "./race"; -NodeIPC.config.id = "native-messaging-test-runner"; -NodeIPC.config.maxRetries = 0; -NodeIPC.config.silent = true; - -const DESKTOP_APP_PATH = `${homedir}/tmp/app.bitwarden`; const DEFAULT_MESSAGE_TIMEOUT = 10 * 1000; // 10 seconds export type MessageHandler = (MessageCommon) => void; @@ -42,6 +35,10 @@ export default class IPCService { // A set of deferred promises that are awaiting socket connection private awaitingConnection = new Set>(); + // The IPC desktop_proxy process + private process?: ChildProcess; + private processOutputBuffer = Buffer.alloc(0); + constructor( private socketName: string, private messageHandler: MessageHandler, @@ -72,47 +69,47 @@ export default class IPCService { private _connect() { this.connectionState = IPCConnectionState.Connecting; - NodeIPC.connectTo(this.socketName, DESKTOP_APP_PATH, () => { - // Process incoming message - this.getSocket().on("message", (message: any) => { - this.processMessage(message); - }); + const proxyPath = selectProxyPath(); + console.log(`[IPCService] connecting to proxy at ${proxyPath}`); - this.getSocket().on("error", (error: Error) => { - // Only makes sense as long as config.maxRetries stays set to 0. Otherwise this will be - // invoked multiple times each time a connection error happens - console.log("[IPCService] errored"); - console.log( - "\x1b[33m Please make sure the desktop app is running locally and 'Allow DuckDuckGo browser integration' setting is enabled \x1b[0m", - ); - this.awaitingConnection.forEach((deferred) => { - console.log(`rejecting: ${deferred}`); - deferred.reject(error); - }); - this.awaitingConnection.clear(); - }); + this.process = spawn(proxyPath, process.argv.slice(1), { + cwd: process.cwd(), + stdio: "pipe", + shell: false, + }); - this.getSocket().on("connect", () => { - console.log("[IPCService] connected"); - this.connectionState = IPCConnectionState.Connected; + this.process.stdout.on("data", (data: Buffer) => { + this.processIpcMessage(data); + }); - this.awaitingConnection.forEach((deferred) => { - deferred.resolve(null); - }); - this.awaitingConnection.clear(); - }); + this.process.stderr.on("data", (data: Buffer) => { + console.error(`proxy log: ${data}`); + }); - this.getSocket().on("disconnect", () => { - console.log("[IPCService] disconnected"); - this.connectionState = IPCConnectionState.Disconnected; + this.process.on("error", (error) => { + // Only makes sense as long as config.maxRetries stays set to 0. Otherwise this will be + // invoked multiple times each time a connection error happens + console.log("[IPCService] errored"); + console.log( + "\x1b[33m Please make sure the desktop app is running locally and 'Allow DuckDuckGo browser integration' setting is enabled \x1b[0m", + ); + this.awaitingConnection.forEach((deferred) => { + console.log(`rejecting: ${deferred}`); + deferred.reject(error); }); + this.awaitingConnection.clear(); + }); + + this.process.on("exit", () => { + console.log("[IPCService] disconnected"); + this.connectionState = IPCConnectionState.Disconnected; }); } disconnect() { console.log("[IPCService] disconnecting..."); if (this.connectionState !== IPCConnectionState.Disconnected) { - NodeIPC.disconnect(this.socketName); + this.process?.kill(); } } @@ -133,7 +130,7 @@ export default class IPCService { this.pendingMessages.set(message.messageId, deferred); - this.getSocket().emit("message", message); + this.sendIpcMessage(message); try { // Since we can not guarantee that a response message will ever be sent, we put a timeout @@ -151,8 +148,56 @@ export default class IPCService { } } - private getSocket() { - return NodeIPC.of[this.socketName]; + // As we're using the desktop_proxy to communicate with the native messaging directly, + // the messages need to follow Native Messaging Host protocol (uint32 size followed by message). + // https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging#native-messaging-host-protocol + private sendIpcMessage(message: MessageCommon) { + const messageStr = JSON.stringify(message); + const buffer = Buffer.alloc(4 + messageStr.length); + buffer.writeUInt32LE(messageStr.length, 0); + buffer.write(messageStr, 4); + + this.process?.stdin.write(buffer); + } + + private processIpcMessage(data: Buffer) { + this.processOutputBuffer = Buffer.concat([this.processOutputBuffer, data]); + + // We might receive more than one IPC message per data event, so we need to process them all + // We continue as long as we have at least 4 + 1 bytes in the buffer, where the first 4 bytes + // represent the message length and the 5th byte is the message + while (this.processOutputBuffer.length > 4) { + // Read the message length and ensure we have the full message + const msgLength = this.processOutputBuffer.readUInt32LE(0); + if (msgLength + 4 < this.processOutputBuffer.length) { + return; + } + + // Parse the message from the buffer + const messageStr = this.processOutputBuffer.subarray(4, msgLength + 4).toString(); + const message = JSON.parse(messageStr); + + // Store the remaining buffer, which is part of the next message + this.processOutputBuffer = this.processOutputBuffer.subarray(msgLength + 4); + + // Process the connect/disconnect messages separately + if (message?.command === "connected") { + console.log("[IPCService] connected"); + this.connectionState = IPCConnectionState.Connected; + + this.awaitingConnection.forEach((deferred) => { + deferred.resolve(null); + }); + this.awaitingConnection.clear(); + continue; + } else if (message?.command === "disconnected") { + console.log("[IPCService] disconnected"); + this.connectionState = IPCConnectionState.Disconnected; + continue; + } + + this.processMessage(message); + } } private processMessage(message: any) { @@ -172,3 +217,41 @@ export default class IPCService { } } } + +function selectProxyPath(): string { + const proxyExtension = process.platform === "win32" ? ".exe" : ""; + + // If the PROXY_PATH environment variable is set, use that + if (process.env.PROXY_PATH) { + if (!fs.existsSync(process.env.PROXY_PATH)) { + throw new Error(`PROXY_PATH is set to ${process.env.PROXY_PATH} but the file does not exist`); + } + return process.env.PROXY_PATH; + } + + // Otherwise try the debug build if present + const debugProxyPath = path.join( + __dirname, + "..", + "..", + "..", + "..", + "..", + "..", + "desktop_native", + "target", + "debug", + `desktop_proxy${proxyExtension}`, + ); + if (fs.existsSync(debugProxyPath)) { + return debugProxyPath; + } + + // On MacOS, try the release build (sandboxed) + const macReleaseProxyPath = `/Applications/Bitwarden.app/Contents/MacOS/desktop_proxy${proxyExtension}`; + if (process.platform === "darwin" && fs.existsSync(macReleaseProxyPath)) { + return macReleaseProxyPath; + } + + throw new Error("Could not find the desktop_proxy executable"); +} diff --git a/apps/desktop/native-messaging-test-runner/tsconfig.json b/apps/desktop/native-messaging-test-runner/tsconfig.json index 72a28de3f7a..59c7040e509 100644 --- a/apps/desktop/native-messaging-test-runner/tsconfig.json +++ b/apps/desktop/native-messaging-test-runner/tsconfig.json @@ -5,12 +5,17 @@ "target": "es6", "module": "CommonJS", "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, "sourceMap": false, "declaration": false, "paths": { "@src/*": ["src/*"], - "@bitwarden/node/*": ["../../../libs/node/src/*"], - "@bitwarden/common/*": ["../../../libs/common/src/*"] + "@bitwarden/admin-console/*": ["../../../libs/admin-console/src/*"], + "@bitwarden/auth/*": ["../../../libs/auth/src/*"], + "@bitwarden/common/*": ["../../../libs/common/src/*"], + "@bitwarden/key-management": ["../../../libs/key-management/src/"], + "@bitwarden/node/*": ["../../../libs/node/src/*"] }, "plugins": [ { diff --git a/package-lock.json b/package-lock.json index 622ee9bf9d4..01aace3228c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -113,7 +113,6 @@ "@types/node": "22.10.7", "@types/node-fetch": "2.6.4", "@types/node-forge": "1.3.11", - "@types/node-ipc": "9.2.3", "@types/papaparse": "5.3.15", "@types/proper-lockfile": "4.1.4", "@types/retry": "0.12.5", @@ -157,7 +156,6 @@ "json5": "2.2.3", "lint-staged": "15.4.1", "mini-css-extract-plugin": "2.9.2", - "node-ipc": "9.2.1", "postcss": "8.5.1", "postcss-loader": "8.1.1", "prettier": "3.4.2", @@ -10047,16 +10045,6 @@ "@types/node": "*" } }, - "node_modules/@types/node-ipc": { - "version": "9.2.3", - "resolved": "https://registry.npmjs.org/@types/node-ipc/-/node-ipc-9.2.3.tgz", - "integrity": "sha512-/MvSiF71fYf3+zwqkh/zkVkZj1hl1Uobre9EMFy08mqfJNAmpR0vmPgOUdEIDVgifxHj6G1vYMPLSBLLxoDACQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/node/node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", @@ -15862,16 +15850,6 @@ "dev": true, "license": "MIT" }, - "node_modules/easy-stack": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/easy-stack/-/easy-stack-1.0.1.tgz", - "integrity": "sha512-wK2sCs4feiiJeFXn3zvY0p41mdU5VUgbgs1rNsc/y5ngFUijdWd+iIN8eoyuZHKB8xN6BL4PdWmzqFmxNg6V2w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -17182,16 +17160,6 @@ "node": ">= 0.6" } }, - "node_modules/event-pubsub": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/event-pubsub/-/event-pubsub-4.3.0.tgz", - "integrity": "sha512-z7IyloorXvKbFx9Bpie2+vMJKKx1fH1EN5yiTfp8CiLOTptSYy1g8H4yDpGlEdshL1PBiFtBHepF2cNsqeEeFQ==", - "dev": true, - "license": "Unlicense", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/event-stream": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-4.0.1.tgz", @@ -21964,29 +21932,6 @@ "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", "license": "MIT" }, - "node_modules/js-message": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz", - "integrity": "sha512-efJLHhLjIyKRewNS9EGZ4UpI8NguuL6fKkhRxVuMmrGV2xN/0APGdQYwLFky5w9naebSZ0OwAGp0G6/2Cg90rA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/js-queue": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/js-queue/-/js-queue-2.0.2.tgz", - "integrity": "sha512-pbKLsbCfi7kriM3s1J4DDCo7jQkI58zPLHi0heXPzPlj0hjUsm+FesPUbE0DSbIVIK503A36aUBoCN7eMFedkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "easy-stack": "^1.0.1" - }, - "engines": { - "node": ">=1.0.0" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -25710,21 +25655,6 @@ "license": "MIT", "peer": true }, - "node_modules/node-ipc": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/node-ipc/-/node-ipc-9.2.1.tgz", - "integrity": "sha512-mJzaM6O3xHf9VT8BULvJSbdVbmHUKRNOH7zDDkCrA1/T+CVjq2WVIDfLt0azZRXpgArJtl3rtmEozrbXPZ9GaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "event-pubsub": "4.3.0", - "js-message": "1.0.7", - "js-queue": "2.0.2" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/node-releases": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", diff --git a/package.json b/package.json index 44733e4ab07..32b5b2b6af3 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,6 @@ "@types/node": "22.10.7", "@types/node-fetch": "2.6.4", "@types/node-forge": "1.3.11", - "@types/node-ipc": "9.2.3", "@types/papaparse": "5.3.15", "@types/proper-lockfile": "4.1.4", "@types/retry": "0.12.5", @@ -118,7 +117,6 @@ "json5": "2.2.3", "lint-staged": "15.4.1", "mini-css-extract-plugin": "2.9.2", - "node-ipc": "9.2.1", "postcss": "8.5.1", "postcss-loader": "8.1.1", "prettier": "3.4.2", From d11321e28e72f39256d73488a610e01d9528327f Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 25 Feb 2025 14:47:08 +0100 Subject: [PATCH 09/41] Fix ssh agent on flatpak and mac app store (#13324) --- .../peercred_unix_listener_stream.rs | 17 ++++------------ .../core/src/ssh_agent/peerinfo/models.rs | 4 ++++ .../desktop_native/core/src/ssh_agent/unix.rs | 20 ++++++++++++++----- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/peercred_unix_listener_stream.rs b/apps/desktop/desktop_native/core/src/ssh_agent/peercred_unix_listener_stream.rs index f0114fc08da..da9d8a54318 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/peercred_unix_listener_stream.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/peercred_unix_listener_stream.rs @@ -31,26 +31,17 @@ impl Stream for PeercredUnixListenerStream { Ok(peer) => match peer.pid() { Some(pid) => pid, None => { - return Poll::Ready(Some(Err(io::Error::new( - io::ErrorKind::Other, - "Failed to get peer PID", - )))); + return Poll::Ready(Some(Ok((stream, PeerInfo::unknown())))); } }, - Err(err) => { - return Poll::Ready(Some(Err(io::Error::new( - io::ErrorKind::Other, - format!("Failed to get peer credentials: {}", err), - )))); + Err(_) => { + return Poll::Ready(Some(Ok((stream, PeerInfo::unknown())))); } }; let peer_info = peerinfo::gather::get_peer_info(pid as u32); match peer_info { Ok(info) => Poll::Ready(Some(Ok((stream, info)))), - Err(err) => Poll::Ready(Some(Err(io::Error::new( - io::ErrorKind::Other, - format!("Failed to get peer info: {}", err), - )))), + Err(_) => Poll::Ready(Some(Ok((stream, PeerInfo::unknown())))), } } Poll::Ready(Err(err)) => Poll::Ready(Some(Err(err))), diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/models.rs b/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/models.rs index 823d912883e..9c2ee363e8f 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/models.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/models.rs @@ -29,4 +29,8 @@ impl PeerInfo { pub fn process_name(&self) -> &str { &self.process_name } + + pub fn unknown() -> Self { + Self::new(0, 0, "Unknown application".to_string()) + } } diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs b/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs index ae03421a425..40bc36b1b9e 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs @@ -47,11 +47,21 @@ impl BitwardenDesktopAgent { return; } }; - ssh_agent_directory - .join(".bitwarden-ssh-agent.sock") - .to_str() - .expect("Path should be valid") - .to_owned() + + let is_flatpak = std::env::var("container") == Ok("flatpak".to_string()); + if !is_flatpak { + ssh_agent_directory + .join(".bitwarden-ssh-agent.sock") + .to_str() + .expect("Path should be valid") + .to_owned() + } else { + ssh_agent_directory + .join(".var/app/com.bitwarden.desktop/data/.bitwarden-ssh-agent.sock") + .to_str() + .expect("Path should be valid") + .to_owned() + } } }; From 6d1914f43db8af28f97d75ad40a0af6e15c2b530 Mon Sep 17 00:00:00 2001 From: Vicki League Date: Tue, 25 Feb 2025 09:56:01 -0500 Subject: [PATCH 10/41] [CL-485] Add small delay for async action loading state (#12835) --- .../src/async-actions/bit-action.directive.ts | 29 +++-- .../async-actions/form-button.directive.ts | 6 +- .../src/async-actions/standalone.mdx | 29 ++++- .../src/async-actions/standalone.stories.ts | 35 +++++- .../src/button/button.component.html | 4 +- .../components/src/button/button.component.ts | 72 ++++++++--- .../icon-button/icon-button.component.html | 4 +- .../src/icon-button/icon-button.component.ts | 116 +++++++++++++----- .../src/shared/button-like.abstraction.ts | 6 +- .../components/send-form.component.ts | 4 +- .../cipher-attachments.component.spec.ts | 24 ++-- .../cipher-attachments.component.ts | 8 +- .../components/cipher-form.component.ts | 4 +- .../item-details-section.component.spec.ts | 3 + .../add-edit-folder-dialog.component.ts | 2 +- .../assign-collections.component.ts | 4 +- 16 files changed, 253 insertions(+), 97 deletions(-) diff --git a/libs/components/src/async-actions/bit-action.directive.ts b/libs/components/src/async-actions/bit-action.directive.ts index 3e793ae2ecd..ac50082852a 100644 --- a/libs/components/src/async-actions/bit-action.directive.ts +++ b/libs/components/src/async-actions/bit-action.directive.ts @@ -21,30 +21,35 @@ export class BitActionDirective implements OnDestroy { private destroy$ = new Subject(); private _loading$ = new BehaviorSubject(false); - disabled = false; - - @Input("bitAction") handler: FunctionReturningAwaitable; - + /** + * Observable of loading behavior subject + * + * Used in `form-button.directive.ts` + */ readonly loading$ = this._loading$.asObservable(); - constructor( - private buttonComponent: ButtonLikeAbstraction, - @Optional() private validationService?: ValidationService, - @Optional() private logService?: LogService, - ) {} - get loading() { return this._loading$.value; } set loading(value: boolean) { this._loading$.next(value); - this.buttonComponent.loading = value; + this.buttonComponent.loading.set(value); } + disabled = false; + + @Input("bitAction") handler: FunctionReturningAwaitable; + + constructor( + private buttonComponent: ButtonLikeAbstraction, + @Optional() private validationService?: ValidationService, + @Optional() private logService?: LogService, + ) {} + @HostListener("click") protected async onClick() { - if (!this.handler || this.loading || this.disabled || this.buttonComponent.disabled) { + if (!this.handler || this.loading || this.disabled || this.buttonComponent.disabled()) { return; } diff --git a/libs/components/src/async-actions/form-button.directive.ts b/libs/components/src/async-actions/form-button.directive.ts index 7c92865b984..1c2855f32e7 100644 --- a/libs/components/src/async-actions/form-button.directive.ts +++ b/libs/components/src/async-actions/form-button.directive.ts @@ -41,15 +41,15 @@ export class BitFormButtonDirective implements OnDestroy { if (submitDirective && buttonComponent) { submitDirective.loading$.pipe(takeUntil(this.destroy$)).subscribe((loading) => { if (this.type === "submit") { - buttonComponent.loading = loading; + buttonComponent.loading.set(loading); } else { - buttonComponent.disabled = this.disabled || loading; + buttonComponent.disabled.set(this.disabled || loading); } }); submitDirective.disabled$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => { if (this.disabled !== false) { - buttonComponent.disabled = this.disabled || disabled; + buttonComponent.disabled.set(this.disabled || disabled); } }); } diff --git a/libs/components/src/async-actions/standalone.mdx b/libs/components/src/async-actions/standalone.mdx index efde494f2dd..f484ea01c58 100644 --- a/libs/components/src/async-actions/standalone.mdx +++ b/libs/components/src/async-actions/standalone.mdx @@ -1,6 +1,7 @@ -import { Meta } from "@storybook/addon-docs"; +import { Meta, Story } from "@storybook/addon-docs"; +import * as stories from "./standalone.stories.ts"; - + # Standalone Async Actions @@ -8,9 +9,13 @@ These directives should be used when building a standalone button that triggers in the background, eg. Refresh buttons. For non-submit buttons that are associated with forms see [Async Actions In Forms](?path=/story/component-library-async-actions-in-forms-documentation--page). +If the long running background task resolves quickly (e.g. less than 75 ms), the loading spinner +will not display on the button. This prevents an undesirable "flicker" of the loading spinner when +it is not necessary for the user to see it. + ## Usage -Adding async actions to standalone buttons requires the following 2 steps +Adding async actions to standalone buttons requires the following 2 steps: ### 1. Add a handler to your `Component` @@ -60,3 +65,21 @@ from how click handlers are usually defined with the output syntax `(click)="han `; ``` + +## Stories + +### Promise resolves -- loading spinner is displayed + + + +### Promise resolves -- quickly without loading spinner + + + +### Promise rejects + + + +### Observable + + diff --git a/libs/components/src/async-actions/standalone.stories.ts b/libs/components/src/async-actions/standalone.stories.ts index f658dfb0f01..52b85b88561 100644 --- a/libs/components/src/async-actions/standalone.stories.ts +++ b/libs/components/src/async-actions/standalone.stories.ts @@ -11,9 +11,9 @@ import { IconButtonModule } from "../icon-button"; import { BitActionDirective } from "./bit-action.directive"; -const template = ` +const template = /*html*/ ` `; @@ -22,9 +22,30 @@ const template = ` selector: "app-promise-example", }) class PromiseExampleComponent { + statusEmoji = "🟡"; action = async () => { await new Promise((resolve, reject) => { - setTimeout(resolve, 2000); + setTimeout(() => { + resolve(); + this.statusEmoji = "🟢"; + }, 5000); + }); + }; +} + +@Component({ + template, + selector: "app-action-resolves-quickly", +}) +class ActionResolvesQuicklyComponent { + statusEmoji = "🟡"; + + action = async () => { + await new Promise((resolve, reject) => { + setTimeout(() => { + resolve(); + this.statusEmoji = "🟢"; + }, 50); }); }; } @@ -59,6 +80,7 @@ export default { PromiseExampleComponent, ObservableExampleComponent, RejectedPromiseExampleComponent, + ActionResolvesQuicklyComponent, ], imports: [ButtonModule, IconButtonModule, BitActionDirective], providers: [ @@ -100,3 +122,10 @@ export const RejectedPromise: ObservableStory = { template: ``, }), }; + +export const ActionResolvesQuickly: PromiseStory = { + render: (args) => ({ + props: args, + template: ``, + }), +}; diff --git a/libs/components/src/button/button.component.html b/libs/components/src/button/button.component.html index ee4d150dfcc..a07ab9fb99b 100644 --- a/libs/components/src/button/button.component.html +++ b/libs/components/src/button/button.component.html @@ -1,10 +1,10 @@ - + diff --git a/libs/components/src/button/button.component.ts b/libs/components/src/button/button.component.ts index 96311f91529..0b4ce3073c3 100644 --- a/libs/components/src/button/button.component.ts +++ b/libs/components/src/button/button.component.ts @@ -2,7 +2,9 @@ // @ts-strict-ignore import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { NgClass } from "@angular/common"; -import { Input, HostBinding, Component } from "@angular/core"; +import { Input, HostBinding, Component, model, computed } from "@angular/core"; +import { toObservable, toSignal } from "@angular/core/rxjs-interop"; +import { debounce, interval } from "rxjs"; import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction"; @@ -49,6 +51,9 @@ const buttonStyles: Record = { providers: [{ provide: ButtonLikeAbstraction, useExisting: ButtonComponent }], standalone: true, imports: [NgClass], + host: { + "[attr.disabled]": "disabledAttr()", + }, }) export class ButtonComponent implements ButtonLikeAbstraction { @HostBinding("class") get classList() { @@ -64,24 +69,41 @@ export class ButtonComponent implements ButtonLikeAbstraction { "tw-no-underline", "hover:tw-no-underline", "focus:tw-outline-none", - "disabled:tw-bg-secondary-300", - "disabled:hover:tw-bg-secondary-300", - "disabled:tw-border-secondary-300", - "disabled:hover:tw-border-secondary-300", - "disabled:!tw-text-muted", - "disabled:hover:!tw-text-muted", - "disabled:tw-cursor-not-allowed", - "disabled:hover:tw-no-underline", ] .concat(this.block ? ["tw-w-full", "tw-block"] : ["tw-inline-block"]) - .concat(buttonStyles[this.buttonType ?? "secondary"]); + .concat(buttonStyles[this.buttonType ?? "secondary"]) + .concat( + this.showDisabledStyles() || this.disabled() + ? [ + "disabled:tw-bg-secondary-300", + "disabled:hover:tw-bg-secondary-300", + "disabled:tw-border-secondary-300", + "disabled:hover:tw-border-secondary-300", + "disabled:!tw-text-muted", + "disabled:hover:!tw-text-muted", + "disabled:tw-cursor-not-allowed", + "disabled:hover:tw-no-underline", + ] + : [], + ); } - @HostBinding("attr.disabled") - get disabledAttr() { - const disabled = this.disabled != null && this.disabled !== false; - return disabled || this.loading ? true : null; - } + protected disabledAttr = computed(() => { + const disabled = this.disabled() != null && this.disabled() !== false; + return disabled || this.loading() ? true : null; + }); + + /** + * Determine whether it is appropriate to display the disabled styles. We only want to show + * the disabled styles if the button is truly disabled, or if the loading styles are also + * visible. + * + * We can't use `disabledAttr` for this, because it returns `true` when `loading` is `true`. + * We only want to show disabled styles during loading if `showLoadingStyles` is `true`. + */ + protected showDisabledStyles = computed(() => { + return this.showLoadingStyle() || (this.disabledAttr() && this.loading() === false); + }); @Input() buttonType: ButtonType; @@ -96,7 +118,23 @@ export class ButtonComponent implements ButtonLikeAbstraction { this._block = coerceBooleanProperty(value); } - @Input() loading = false; + loading = model(false); - @Input() disabled = false; + /** + * Determine whether it is appropriate to display a loading spinner. We only want to show + * a spinner if it's been more than 75 ms since the `loading` state began. This prevents + * a spinner "flash" for actions that are synchronous/nearly synchronous. + * + * We can't use `loading` for this, because we still need to disable the button during + * the full `loading` state. I.e. we only want the spinner to be debounced, not the + * loading state. + * + * This pattern of converting a signal to an observable and back to a signal is not + * recommended. TODO -- find better way to use debounce with signals (CL-596) + */ + protected showLoadingStyle = toSignal( + toObservable(this.loading).pipe(debounce((isLoading) => interval(isLoading ? 75 : 0))), + ); + + disabled = model(false); } diff --git a/libs/components/src/icon-button/icon-button.component.html b/libs/components/src/icon-button/icon-button.component.html index 6eeaaaffaf0..0145f0b0ba5 100644 --- a/libs/components/src/icon-button/icon-button.component.html +++ b/libs/components/src/icon-button/icon-button.component.html @@ -1,10 +1,10 @@ - + = { "hover:tw-bg-transparent-hover", "hover:tw-border-text-contrast", "focus-visible:before:tw-ring-text-contrast", - "disabled:tw-opacity-60", - "disabled:hover:tw-border-transparent", - "disabled:hover:tw-bg-transparent", ...focusRing, ], main: [ @@ -46,9 +45,6 @@ const styles: Record = { "hover:tw-bg-transparent-hover", "hover:tw-border-primary-600", "focus-visible:before:tw-ring-primary-600", - "disabled:!tw-text-secondary-300", - "disabled:hover:tw-border-transparent", - "disabled:hover:tw-bg-transparent", ...focusRing, ], muted: [ @@ -60,11 +56,8 @@ const styles: Record = { "hover:tw-bg-transparent-hover", "hover:tw-border-primary-600", "focus-visible:before:tw-ring-primary-600", - "disabled:!tw-text-secondary-300", "aria-expanded:hover:tw-bg-secondary-700", "aria-expanded:hover:tw-border-secondary-700", - "disabled:hover:tw-border-transparent", - "disabled:hover:tw-bg-transparent", ...focusRing, ], primary: [ @@ -74,9 +67,6 @@ const styles: Record = { "hover:tw-bg-primary-600", "hover:tw-border-primary-600", "focus-visible:before:tw-ring-primary-600", - "disabled:tw-opacity-60", - "disabled:hover:tw-border-primary-600", - "disabled:hover:tw-bg-primary-600", ...focusRing, ], secondary: [ @@ -86,10 +76,6 @@ const styles: Record = { "hover:!tw-text-contrast", "hover:tw-bg-text-muted", "focus-visible:before:tw-ring-primary-600", - "disabled:tw-opacity-60", - "disabled:hover:tw-border-text-muted", - "disabled:hover:tw-bg-transparent", - "disabled:hover:!tw-text-muted", ...focusRing, ], danger: [ @@ -100,10 +86,6 @@ const styles: Record = { "hover:tw-bg-transparent", "hover:tw-border-primary-600", "focus-visible:before:tw-ring-primary-600", - "disabled:!tw-text-secondary-300", - "disabled:hover:tw-border-transparent", - "disabled:hover:tw-bg-transparent", - "disabled:hover:!tw-text-secondary-300", ...focusRing, ], light: [ @@ -113,10 +95,48 @@ const styles: Record = { "hover:tw-bg-transparent-hover", "hover:tw-border-text-alt2", "focus-visible:before:tw-ring-text-alt2", + ...focusRing, + ], + unstyled: [], +}; + +const disabledStyles: Record = { + contrast: [ + "disabled:tw-opacity-60", + "disabled:hover:tw-border-transparent", + "disabled:hover:tw-bg-transparent", + ], + main: [ + "disabled:!tw-text-secondary-300", + "disabled:hover:tw-border-transparent", + "disabled:hover:tw-bg-transparent", + ], + muted: [ + "disabled:!tw-text-secondary-300", + "disabled:hover:tw-border-transparent", + "disabled:hover:tw-bg-transparent", + ], + primary: [ + "disabled:tw-opacity-60", + "disabled:hover:tw-border-primary-600", + "disabled:hover:tw-bg-primary-600", + ], + secondary: [ + "disabled:tw-opacity-60", + "disabled:hover:tw-border-text-muted", + "disabled:hover:tw-bg-transparent", + "disabled:hover:!tw-text-muted", + ], + danger: [ + "disabled:!tw-text-secondary-300", + "disabled:hover:tw-border-transparent", + "disabled:hover:tw-bg-transparent", + "disabled:hover:!tw-text-secondary-300", + ], + light: [ "disabled:tw-opacity-60", "disabled:hover:tw-border-transparent", "disabled:hover:tw-bg-transparent", - ...focusRing, ], unstyled: [], }; @@ -137,11 +157,14 @@ const sizes: Record = { ], standalone: true, imports: [NgClass], + host: { + "[attr.disabled]": "disabledAttr()", + }, }) export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement { @Input("bitIconButton") icon: string; - @Input() buttonType: IconButtonType; + @Input() buttonType: IconButtonType = "main"; @Input() size: IconButtonSize = "default"; @@ -155,22 +178,51 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE "hover:tw-no-underline", "focus:tw-outline-none", ] - .concat(styles[this.buttonType ?? "main"]) - .concat(sizes[this.size]); + .concat(styles[this.buttonType]) + .concat(sizes[this.size]) + .concat(this.showDisabledStyles() || this.disabled() ? disabledStyles[this.buttonType] : []); } get iconClass() { return [this.icon, "!tw-m-0"]; } - @HostBinding("attr.disabled") - get disabledAttr() { - const disabled = this.disabled != null && this.disabled !== false; - return disabled || this.loading ? true : null; - } + protected disabledAttr = computed(() => { + const disabled = this.disabled() != null && this.disabled() !== false; + return disabled || this.loading() ? true : null; + }); - @Input() loading = false; - @Input() disabled = false; + /** + * Determine whether it is appropriate to display the disabled styles. We only want to show + * the disabled styles if the button is truly disabled, or if the loading styles are also + * visible. + * + * We can't use `disabledAttr` for this, because it returns `true` when `loading` is `true`. + * We only want to show disabled styles during loading if `showLoadingStyles` is `true`. + */ + protected showDisabledStyles = computed(() => { + return this.showLoadingStyle() || (this.disabledAttr() && this.loading() === false); + }); + + loading = model(false); + + /** + * Determine whether it is appropriate to display a loading spinner. We only want to show + * a spinner if it's been more than 75 ms since the `loading` state began. This prevents + * a spinner "flash" for actions that are synchronous/nearly synchronous. + * + * We can't use `loading` for this, because we still need to disable the button during + * the full `loading` state. I.e. we only want the spinner to be debounced, not the + * loading state. + * + * This pattern of converting a signal to an observable and back to a signal is not + * recommended. TODO -- find better way to use debounce with signals (CL-596) + */ + protected showLoadingStyle = toSignal( + toObservable(this.loading).pipe(debounce((isLoading) => interval(isLoading ? 75 : 0))), + ); + + disabled = model(false); getFocusTarget() { return this.elementRef.nativeElement; diff --git a/libs/components/src/shared/button-like.abstraction.ts b/libs/components/src/shared/button-like.abstraction.ts index 026a4b83024..5ee9d272594 100644 --- a/libs/components/src/shared/button-like.abstraction.ts +++ b/libs/components/src/shared/button-like.abstraction.ts @@ -1,8 +1,10 @@ // FIXME: Update this file to be type safe and remove this and next line +import { ModelSignal } from "@angular/core"; + // @ts-strict-ignore export type ButtonType = "primary" | "secondary" | "danger" | "unstyled"; export abstract class ButtonLikeAbstraction { - loading: boolean; - disabled: boolean; + loading: ModelSignal; + disabled: ModelSignal; } diff --git a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts index 8abdaa69bb8..3149307bdd5 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts @@ -120,11 +120,11 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send ngAfterViewInit(): void { if (this.submitBtn) { this.bitSubmit.loading$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((loading) => { - this.submitBtn.loading = loading; + this.submitBtn.loading.set(loading); }); this.bitSubmit.disabled$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((disabled) => { - this.submitBtn.disabled = disabled; + this.submitBtn.disabled.set(disabled); }); } } 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 ce12ca95e1e..f8aeb695e4f 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 @@ -103,7 +103,7 @@ describe("CipherAttachmentsComponent", () => { fixture = TestBed.createComponent(CipherAttachmentsComponent); component = fixture.componentInstance; component.cipherId = "5555-444-3333" as CipherId; - component.submitBtn = {} as ButtonComponent; + component.submitBtn = TestBed.createComponent(ButtonComponent).componentInstance; fixture.detectChanges(); }); @@ -134,34 +134,38 @@ describe("CipherAttachmentsComponent", () => { describe("bitSubmit", () => { beforeEach(() => { - component.submitBtn.disabled = undefined; - component.submitBtn.loading = undefined; + component.submitBtn.disabled.set(undefined); + component.submitBtn.loading.set(undefined); }); it("updates sets initial state of the submit button", async () => { await component.ngOnInit(); - expect(component.submitBtn.disabled).toBe(true); + expect(component.submitBtn.disabled()).toBe(true); }); it("sets submitBtn loading state", () => { + jest.useFakeTimers(); + component.bitSubmit.loading = true; - expect(component.submitBtn.loading).toBe(true); + jest.runAllTimers(); + + expect(component.submitBtn.loading()).toBe(true); component.bitSubmit.loading = false; - expect(component.submitBtn.loading).toBe(false); + expect(component.submitBtn.loading()).toBe(false); }); it("sets submitBtn disabled state", () => { component.bitSubmit.disabled = true; - expect(component.submitBtn.disabled).toBe(true); + expect(component.submitBtn.disabled()).toBe(true); component.bitSubmit.disabled = false; - expect(component.submitBtn.disabled).toBe(false); + expect(component.submitBtn.disabled()).toBe(false); }); }); @@ -169,7 +173,7 @@ describe("CipherAttachmentsComponent", () => { let file: File; beforeEach(() => { - component.submitBtn.disabled = undefined; + component.submitBtn.disabled.set(undefined); file = new File([""], "attachment.txt", { type: "text/plain" }); const inputElement = fixture.debugElement.query(By.css("input[type=file]")); @@ -189,7 +193,7 @@ describe("CipherAttachmentsComponent", () => { }); it("updates disabled state of submit button", () => { - expect(component.submitBtn.disabled).toBe(false); + expect(component.submitBtn.disabled()).toBe(false); }); }); diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts index 7e26e8afae9..5380f9e434e 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts @@ -114,7 +114,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit { return; } - this.submitBtn.disabled = status !== "VALID"; + this.submitBtn.disabled.set(status !== "VALID"); }); } @@ -127,7 +127,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit { // Update the initial state of the submit button if (this.submitBtn) { - this.submitBtn.disabled = !this.attachmentForm.valid; + this.submitBtn.disabled.set(!this.attachmentForm.valid); } } @@ -137,7 +137,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit { return; } - this.submitBtn.loading = loading; + this.submitBtn.loading.set(loading); }); this.bitSubmit.disabled$.pipe(takeUntilDestroyed(this.destroy$)).subscribe((disabled) => { @@ -145,7 +145,7 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit { return; } - this.submitBtn.disabled = disabled; + this.submitBtn.disabled.set(disabled); }); } diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.ts b/libs/vault/src/cipher-form/components/cipher-form.component.ts index 7335471799b..080af489253 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.ts @@ -144,11 +144,11 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci ngAfterViewInit(): void { if (this.submitBtn) { this.bitSubmit.loading$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((loading) => { - this.submitBtn.loading = loading; + this.submitBtn.loading.set(loading); }); this.bitSubmit.disabled$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((disabled) => { - this.submitBtn.disabled = disabled; + this.submitBtn.disabled.set(disabled); }); } } diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts index aa68770774a..074b4b9e4b4 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -250,6 +250,7 @@ describe("ItemDetailsSectionComponent", () => { describe("showOwnership", () => { it("should return true if ownership change is allowed or in edit mode with at least one organization", () => { + component.config.allowPersonalOwnership = true; jest.spyOn(component, "allowOwnershipChange", "get").mockReturnValue(true); expect(component.showOwnership).toBe(true); @@ -261,6 +262,7 @@ describe("ItemDetailsSectionComponent", () => { }); it("should hide the ownership control if showOwnership is false", async () => { + component.config.allowPersonalOwnership = true; jest.spyOn(component, "showOwnership", "get").mockReturnValue(false); fixture.detectChanges(); await fixture.whenStable(); @@ -271,6 +273,7 @@ describe("ItemDetailsSectionComponent", () => { }); it("should show the ownership control if showOwnership is true", async () => { + component.config.allowPersonalOwnership = true; jest.spyOn(component, "allowOwnershipChange", "get").mockReturnValue(true); fixture.detectChanges(); await fixture.whenStable(); diff --git a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts index 362063ff345..0979fc73560 100644 --- a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts +++ b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts @@ -104,7 +104,7 @@ export class AddEditFolderDialogComponent implements AfterViewInit, OnInit { return; } - this.submitBtn.loading = loading; + this.submitBtn.loading.set(loading); }); } diff --git a/libs/vault/src/components/assign-collections.component.ts b/libs/vault/src/components/assign-collections.component.ts index 76a6a1b10a6..6fbad5ac1cb 100644 --- a/libs/vault/src/components/assign-collections.component.ts +++ b/libs/vault/src/components/assign-collections.component.ts @@ -213,7 +213,7 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI return; } - this.submitBtn.loading = loading; + this.submitBtn.loading.set(loading); }); this.bitSubmit.disabled$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => { @@ -221,7 +221,7 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI return; } - this.submitBtn.disabled = disabled; + this.submitBtn.disabled.set(disabled); }); } From e0a3a05c49cf8307f2f6cec41262da4c07df2bd4 Mon Sep 17 00:00:00 2001 From: Danielle Flinn <43477473+danielleflinn@users.noreply.github.com> Date: Tue, 25 Feb 2025 08:04:34 -0800 Subject: [PATCH 11/41] CL-554 update extension navigation text size (#13549) --- .../platform/popup/layout/popup-tab-navigation.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a4ae3161b47..bed4eac3f90 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 @@ -7,7 +7,7 @@
  • @@ -42,7 +47,7 @@ bitButton bitFormButton [bitAction]="exportEvents" - [disabled]="dirtyDates" + [disabled]="dirtyDates || usePlaceHolderEvents" > {{ "export" | i18n }} @@ -50,6 +55,13 @@
+ + {{ "upgradeEventLogMessage" | i18n }} + {{ "loading" | i18n }} -

{{ "noEventsInList" | i18n }}

- + @let displayedEvents = organization?.useEvents ? events : placeholderEvents; + +

{{ "noEventsInList" | i18n }}

+ {{ "timestamp" | i18n }} @@ -70,8 +84,10 @@ - - {{ e.date | date: "medium" }} + + + {{ i > 4 && usePlaceHolderEvents ? "******" : (e.date | date: "medium") }} + {{ e.appName }} @@ -92,3 +108,26 @@ {{ "loadMore" | i18n }}
+ + +
+
+ + +

+ {{ "limitedEventLogs" | i18n: ProductTierType[organization?.productTierType] }} +

+

+ {{ "upgradeForFullEvents" | i18n }} +

+ + +
+
+
diff --git a/apps/web/src/app/admin-console/organizations/manage/events.component.ts b/apps/web/src/app/admin-console/organizations/manage/events.component.ts index c6969f5b55e..737a38ee2ab 100644 --- a/apps/web/src/app/admin-console/organizations/manage/events.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/events.component.ts @@ -2,11 +2,12 @@ // @ts-strict-ignore import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { concatMap, firstValueFrom, Subject, takeUntil } from "rxjs"; +import { concatMap, firstValueFrom, lastValueFrom, Subject, takeUntil } from "rxjs"; import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { getOrganizationById, OrganizationService, @@ -15,18 +16,29 @@ import { ProviderService } from "@bitwarden/common/admin-console/abstractions/pr import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { EventSystemUser } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EventResponse } from "@bitwarden/common/models/response/event.response"; +import { EventView } from "@bitwarden/common/models/view/event.view"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { ToastService } from "@bitwarden/components"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { + ChangePlanDialogResultType, + openChangePlanDialog, +} from "../../../billing/organizations/change-plan-dialog.component"; import { EventService } from "../../../core"; import { EventExportService } from "../../../tools/event-export"; import { BaseEventsComponent } from "../../common/base.events.component"; +import { placeholderEvents } from "./placeholder-events"; + const EVENT_SYSTEM_USER_TO_TRANSLATION: Record = { [EventSystemUser.SCIM]: null, // SCIM acronym not able to be translated so just display SCIM [EventSystemUser.DomainVerification]: "domainVerification", @@ -41,10 +53,19 @@ export class EventsComponent extends BaseEventsComponent implements OnInit, OnDe exportFileName = "org-events"; organizationId: string; organization: Organization; + organizationSubscription: OrganizationSubscriptionResponse; + + placeholderEvents = placeholderEvents as EventView[]; private orgUsersUserIdMap = new Map(); private destroy$ = new Subject(); + readonly ProductTierType = ProductTierType; + + protected isBreadcrumbEventLogsEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.PM12276_BreadcrumbEventLogs, + ); + constructor( private apiService: ApiService, private route: ActivatedRoute, @@ -57,10 +78,13 @@ export class EventsComponent extends BaseEventsComponent implements OnInit, OnDe private userNamePipe: UserNamePipe, private organizationService: OrganizationService, private organizationUserApiService: OrganizationUserApiService, + private organizationApiService: OrganizationApiServiceAbstraction, private providerService: ProviderService, fileDownloadService: FileDownloadService, toastService: ToastService, private accountService: AccountService, + private dialogService: DialogService, + private configService: ConfigService, ) { super( eventService, @@ -84,10 +108,16 @@ export class EventsComponent extends BaseEventsComponent implements OnInit, OnDe .organizations$(userId) .pipe(getOrganizationById(this.organizationId)), ); - if (this.organization == null || !this.organization.useEvents) { - await this.router.navigate(["/organizations", this.organizationId]); - return; + + if (!this.organization.useEvents) { + this.eventsForm.get("start").disable(); + this.eventsForm.get("end").disable(); + + this.organizationSubscription = await this.organizationApiService.getSubscription( + this.organizationId, + ); } + await this.load(); }), takeUntil(this.destroy$), @@ -126,7 +156,6 @@ export class EventsComponent extends BaseEventsComponent implements OnInit, OnDe this.logService.warning(e); } } - await this.refreshEvents(); this.loaded = true; } @@ -186,6 +215,23 @@ export class EventsComponent extends BaseEventsComponent implements OnInit, OnDe return id?.substring(0, 8); } + async changePlan() { + const reference = openChangePlanDialog(this.dialogService, { + data: { + organizationId: this.organizationId, + subscription: this.organizationSubscription, + productTierType: this.organization.productTierType, + }, + }); + + const result = await lastValueFrom(reference.closed); + + if (result === ChangePlanDialogResultType.Closed) { + return; + } + await this.load(); + } + ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); diff --git a/apps/web/src/app/admin-console/organizations/manage/placeholder-events.ts b/apps/web/src/app/admin-console/organizations/manage/placeholder-events.ts new file mode 100644 index 00000000000..3b13ee060bf --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/manage/placeholder-events.ts @@ -0,0 +1,63 @@ +function getRandomDateTime() { + const now = new Date(); + const past24Hours = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const randomTime = + past24Hours.getTime() + Math.random() * (now.getTime() - past24Hours.getTime()); + const randomDate = new Date(randomTime); + + return randomDate.toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: true, + }); +} + +const asteriskPlaceholders = new Array(6).fill({ + appName: "***", + userName: "**********", + userEmail: "**********", + message: "**********", +}); + +export const placeholderEvents = [ + { + date: getRandomDateTime(), + appName: "Extension - Firefox", + userName: "Alice", + userEmail: "alice@email.com", + message: "Logged in", + }, + { + date: getRandomDateTime(), + appName: "Mobile - iOS", + userName: "Bob", + message: `Viewed item 000000`, + }, + { + date: getRandomDateTime(), + appName: "Desktop - Linux", + userName: "Carlos", + userEmail: "carlos@email.com", + message: "Login attempt failed with incorrect password", + }, + { + date: getRandomDateTime(), + appName: "Web vault - Chrome", + userName: "Ivan", + userEmail: "ivan@email.com", + message: `Confirmed user 000000`, + }, + { + date: getRandomDateTime(), + appName: "Mobile - Android", + userName: "Franz", + userEmail: "franz@email.com", + message: `Sent item 000000 to trash`, + }, +] + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) + .concat(asteriskPlaceholders); diff --git a/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts index 2de5b83c40a..635053dd1e2 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts @@ -1,12 +1,14 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { NgModule } from "@angular/core"; -import { RouterModule, Routes } from "@angular/router"; +import { inject, NgModule } from "@angular/core"; +import { CanMatchFn, RouterModule, Routes } from "@angular/router"; +import { map } from "rxjs"; import { canAccessReportingTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -/* eslint no-restricted-imports: "off" -- Normally prohibited by Tools Team eslint rules but required here */ import { ExposedPasswordsReportComponent } from "../../../tools/reports/pages/organizations/exposed-passwords-report.component"; import { InactiveTwoFactorReportComponent } from "../../../tools/reports/pages/organizations/inactive-two-factor-report.component"; import { ReusedPasswordsReportComponent } from "../../../tools/reports/pages/organizations/reused-passwords-report.component"; @@ -20,6 +22,11 @@ import { EventsComponent } from "../manage/events.component"; import { ReportsHomeComponent } from "./reports-home.component"; +const breadcrumbEventLogsPermission$: CanMatchFn = () => + inject(ConfigService) + .getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs) + .pipe(map((breadcrumbEventLogs) => breadcrumbEventLogs === true)); + const routes: Routes = [ { path: "", @@ -81,6 +88,20 @@ const routes: Routes = [ }, ], }, + // Event routing is temporarily duplicated + { + path: "events", + component: EventsComponent, + canMatch: [breadcrumbEventLogsPermission$], // if this matches, the flag is ON + canActivate: [ + organizationPermissionsGuard( + (org) => (org.canAccessEventLogs && org.useEvents) || org.isOwner, + ), + ], + data: { + titleId: "eventLogs", + }, + }, { path: "events", component: EventsComponent, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 352dfd1fc72..3d50e842a81 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -10513,5 +10513,23 @@ }, "removeUnlockWithPinPolicyDesc": { "message": "Do not allow members to unlock their account with a PIN." + }, + "limitedEventLogs": { + "message": "$PRODUCT_TYPE$ plans do not have access to real event logs", + "placeholders": { + "product_type": { + "content": "$1", + "example": "Teams" + } + } + }, + "upgradeForFullEvents": { + "message": "Get full access to organization event logs by upgrading to a Teams or Enterprise plan." + }, + "upgradeEventLogTitle" : { + "message" : "Upgrade for real event log data" + }, + "upgradeEventLogMessage":{ + "message" : "These events are examples only and do not reflect real events within your Bitwarden organization." } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index ca6a26bc531..2f3e6bb724b 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -48,6 +48,7 @@ export enum FeatureFlag { NewDeviceVerification = "new-device-verification", PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal", RecoveryCodeLogin = "pm-17128-recovery-code-login", + PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -106,6 +107,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.NewDeviceVerification]: FALSE, [FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE, [FeatureFlag.RecoveryCodeLogin]: FALSE, + [FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; From 6f2a713b03cb84a9fc9675c39b9ee05429293407 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 25 Feb 2025 16:12:55 -0500 Subject: [PATCH 16/41] [deps] Vault: Update koa to v2.15.4 [SECURITY] (#13380) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/cli/package.json | 2 +- package-lock.json | 10 +++++----- package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index a6c099826c5..4b650e58805 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -72,7 +72,7 @@ "inquirer": "8.2.6", "jsdom": "26.0.0", "jszip": "3.10.1", - "koa": "2.15.3", + "koa": "2.15.4", "koa-bodyparser": "4.4.1", "koa-json": "2.0.2", "lowdb": "1.0.0", diff --git a/package-lock.json b/package-lock.json index c721bc6e9bc..1431f31daac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,7 @@ "jquery": "3.7.1", "jsdom": "26.0.0", "jszip": "3.10.1", - "koa": "2.15.3", + "koa": "2.15.4", "koa-bodyparser": "4.4.1", "koa-json": "2.0.2", "lit": "3.2.1", @@ -209,7 +209,7 @@ "inquirer": "8.2.6", "jsdom": "26.0.0", "jszip": "3.10.1", - "koa": "2.15.3", + "koa": "2.15.4", "koa-bodyparser": "4.4.1", "koa-json": "2.0.2", "lowdb": "1.0.0", @@ -24433,9 +24433,9 @@ } }, "node_modules/koa": { - "version": "2.15.3", - "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.3.tgz", - "integrity": "sha512-j/8tY9j5t+GVMLeioLaxweJiKUayFhlGqNTzf2ZGwL0ZCQijd2RLHK0SLW5Tsko8YyyqCZC2cojIb0/s62qTAg==", + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.4.tgz", + "integrity": "sha512-7fNBIdrU2PEgLljXoPWoyY4r1e+ToWCmzS/wwMPbUNs7X+5MMET1ObhJBlUkF5uZG9B6QhM2zS1TsH6adegkiQ==", "license": "MIT", "dependencies": { "accepts": "^1.3.5", diff --git a/package.json b/package.json index 32b5b2b6af3..54b1f642086 100644 --- a/package.json +++ b/package.json @@ -178,7 +178,7 @@ "jquery": "3.7.1", "jsdom": "26.0.0", "jszip": "3.10.1", - "koa": "2.15.3", + "koa": "2.15.4", "koa-bodyparser": "4.4.1", "koa-json": "2.0.2", "lit": "3.2.1", From e6e6058f9e6ec0488dffeff80eec715b725febb5 Mon Sep 17 00:00:00 2001 From: Danielle Flinn <43477473+danielleflinn@users.noreply.github.com> Date: Tue, 25 Feb 2025 13:55:21 -0800 Subject: [PATCH 17/41] CL-261: update popover styles and fix stories (#13547) * updated popover styles and fix stories * fixed alignment of icon button title and added long title story --- .../src/popover/popover.component.html | 8 +-- .../src/popover/popover.component.ts | 3 +- .../components/src/popover/popover.stories.ts | 50 +++++++++++++------ 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/libs/components/src/popover/popover.component.html b/libs/components/src/popover/popover.component.html index 8da3b002031..cb0681822d0 100644 --- a/libs/components/src/popover/popover.component.html +++ b/libs/components/src/popover/popover.component.html @@ -1,11 +1,11 @@ diff --git a/libs/vault/src/components/carousel/carousel.component.ts b/libs/vault/src/components/carousel/carousel.component.ts index ab6d0a38f3e..2346ee29902 100644 --- a/libs/vault/src/components/carousel/carousel.component.ts +++ b/libs/vault/src/components/carousel/carousel.component.ts @@ -8,12 +8,12 @@ import { ContentChildren, ElementRef, EventEmitter, + inject, Input, Output, QueryList, ViewChild, ViewChildren, - inject, } from "@angular/core"; import { ButtonModule } from "@bitwarden/components"; @@ -89,7 +89,7 @@ export class VaultCarouselComponent implements AfterViewInit { this.slideChange.emit(index); } - ngAfterViewInit(): void { + async ngAfterViewInit() { this.keyManager = new FocusKeyManager(this.carouselButtons) .withHorizontalOrientation("ltr") .withWrap() @@ -98,7 +98,7 @@ export class VaultCarouselComponent implements AfterViewInit { // Set the first carousel button as active, this avoids having to double tab the arrow keys on initial focus. this.keyManager.setFirstItemActive(); - this.setMinHeightOfCarousel(); + await this.setMinHeightOfCarousel(); } /** @@ -106,7 +106,7 @@ export class VaultCarouselComponent implements AfterViewInit { * Render each slide in a temporary portal outlet to get the height of each slide * and store the tallest slide height. */ - private setMinHeightOfCarousel() { + private async setMinHeightOfCarousel() { // Store the height of the carousel button element. const heightOfButtonsPx = this.carouselButtonWrapper.nativeElement.offsetHeight; @@ -121,13 +121,14 @@ export class VaultCarouselComponent implements AfterViewInit { // to determine the height of the first slide. let tallestSlideHeightPx = containerHeight - heightOfButtonsPx; - this.slides.forEach((slide, index) => { - // Skip the first slide, the height is accounted for above. - if (index === this.selectedIndex) { - return; + for (let i = 0; i < this.slides.length; i++) { + if (i === this.selectedIndex) { + continue; } + this.tempSlideOutlet.attach(this.slides.get(i)!.content); - this.tempSlideOutlet.attach(slide.content); + // Wait for the slide to render. Otherwise, the previous slide may not have been removed from the DOM yet. + await new Promise(requestAnimationFrame); // Store the height of the current slide if is larger than the current stored height; if (this.tempSlideContainer.nativeElement.offsetHeight > tallestSlideHeightPx) { @@ -136,8 +137,7 @@ export class VaultCarouselComponent implements AfterViewInit { // cleanup the outlet this.tempSlideOutlet.detach(); - }); - + } // Set the min height of the entire carousel based on the largest slide. this.minHeight = `${tallestSlideHeightPx + heightOfButtonsPx}px`; this.changeDetectorRef.detectChanges(); diff --git a/libs/vault/src/components/carousel/carousel.module.ts b/libs/vault/src/components/carousel/carousel.module.ts new file mode 100644 index 00000000000..c426e7f89c2 --- /dev/null +++ b/libs/vault/src/components/carousel/carousel.module.ts @@ -0,0 +1,10 @@ +import { NgModule } from "@angular/core"; + +import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component"; +import { VaultCarouselComponent } from "./carousel.component"; + +@NgModule({ + imports: [VaultCarouselComponent, VaultCarouselSlideComponent], + exports: [VaultCarouselComponent, VaultCarouselSlideComponent], +}) +export class VaultCarouselModule {} diff --git a/libs/vault/src/components/carousel/index.ts b/libs/vault/src/components/carousel/index.ts index a785c261020..b2fcfb087f5 100644 --- a/libs/vault/src/components/carousel/index.ts +++ b/libs/vault/src/components/carousel/index.ts @@ -1 +1 @@ -export { VaultCarouselComponent } from "./carousel.component"; +export { VaultCarouselModule } from "./carousel.module"; diff --git a/libs/vault/src/components/dark-image-source.directive.ts b/libs/vault/src/components/dark-image-source.directive.ts new file mode 100644 index 00000000000..6f3e03ef914 --- /dev/null +++ b/libs/vault/src/components/dark-image-source.directive.ts @@ -0,0 +1,62 @@ +import { + DestroyRef, + Directive, + ElementRef, + HostBinding, + inject, + input, + OnInit, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { combineLatest, Observable } from "rxjs"; + +import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; +import { Theme } from "@bitwarden/common/platform/enums"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; + +/** + * Directive that will switch the image source based on the currently applied theme. + * + * @example + * ```html + * + * ``` + */ +@Directive({ + selector: "[appDarkImgSrc]", + standalone: true, +}) +export class DarkImageSourceDirective implements OnInit { + private themeService = inject(ThemeStateService); + private systemTheme$: Observable = inject(SYSTEM_THEME_OBSERVABLE); + private el = inject(ElementRef); + private destroyRef = inject(DestroyRef); + + /** + * The image source to use when the light theme is applied. Automatically assigned the value + * of the `` src attribute. + */ + protected lightImgSrc: string | undefined; + + /** + * The image source to use when the dark theme is applied. + */ + darkImgSrc = input.required({ alias: "appDarkImgSrc" }); + + @HostBinding("attr.src") src: string | undefined; + + ngOnInit() { + // Set the light image source from the element's current src attribute + this.lightImgSrc = this.el.nativeElement.getAttribute("src"); + + // Update the image source based on the active theme + combineLatest([this.themeService.selectedTheme$, this.systemTheme$]) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(([theme, systemTheme]) => { + const appliedTheme = theme === "system" ? systemTheme : theme; + const isDark = + appliedTheme === "dark" || appliedTheme === "nord" || appliedTheme === "solarizedDark"; + this.src = isDark ? this.darkImgSrc() : this.lightImgSrc; + }); + } +} diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index ac905c1f5ef..e4857411d05 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -4,6 +4,7 @@ export { CopyCipherFieldService, CopyAction } from "./services/copy-cipher-field export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directive"; export { OrgIconDirective } from "./components/org-icon.directive"; export { CanDeleteCipherDirective } from "./components/can-delete-cipher.directive"; +export { DarkImageSourceDirective } from "./components/dark-image-source.directive"; export * from "./utils/observable-utilities"; @@ -21,6 +22,7 @@ export { NewDeviceVerificationNoticePageOneComponent } from "./components/new-de export { NewDeviceVerificationNoticePageTwoComponent } from "./components/new-device-verification-notice/new-device-verification-notice-page-two.component"; export { DecryptionFailureDialogComponent } from "./components/decryption-failure-dialog/decryption-failure-dialog.component"; export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.component"; +export * from "./components/carousel"; export * as VaultIcons from "./icons"; From 16ffedc06bdf37b5c7ef93ae22bec08dd52c4760 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Wed, 26 Feb 2025 13:45:57 -0800 Subject: [PATCH 32/41] [PM-18463] [PM-18465] At-risk Password Page Fixes (#13573) * [PM-18463] Add hyphen and fix description pluralization * [PM-18463] Add spacing between buttons * [PM-18463] Ensure callout does not flash --- apps/browser/src/_locales/en/messages.json | 21 ++++++++++----- .../at-risk-passwords.component.html | 3 ++- .../at-risk-passwords.component.spec.ts | 25 ++++++++++++++--- .../at-risk-passwords.component.ts | 27 +++++++++++++++---- 4 files changed, 61 insertions(+), 15 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index ce73bd99d36..0c25288fb9a 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1397,14 +1397,14 @@ }, "useAnotherTwoStepMethod": { "message": "Use another two-step login method" - }, + }, "selectAnotherMethod": { "message": "Select another method", "description": "Select another two-step login method" }, "useYourRecoveryCode": { "message": "Use your recovery code" - }, + }, "insertYubiKey": { "message": "Insert your YubiKey into your computer's USB port, then touch its button." }, @@ -2446,8 +2446,17 @@ "atRiskPasswords": { "message": "At-risk passwords" }, - "atRiskPasswordsDescSingleOrg": { - "message": "$ORGANIZATION$ is requesting you change the $COUNT$ passwords because they are at risk.", + "atRiskPasswordDescSingleOrg": { + "message": "$ORGANIZATION$ is requesting you change one password because it is at-risk.", + "placeholders": { + "organization": { + "content": "$1", + "example": "Acme Corp" + } + } + }, + "atRiskPasswordsDescSingleOrgPlural": { + "message": "$ORGANIZATION$ is requesting you change the $COUNT$ passwords because they are at-risk.", "placeholders": { "organization": { "content": "$1", @@ -2459,8 +2468,8 @@ } } }, - "atRiskPasswordsDescMultiOrg": { - "message": "Your organizations are requesting you change the $COUNT$ passwords because they are at risk.", + "atRiskPasswordsDescMultiOrgPlural": { + "message": "Your organizations are requesting you change the $COUNT$ passwords because they are at-risk.", "placeholders": { "count": { "content": "$1", diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.html b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.html index 044848eec8c..cd93401c861 100644 --- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.html +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.html @@ -6,7 +6,7 @@ {{ "turnOnAutofill" | i18n }} diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts index c719618b33a..3bf786ad5b7 100644 --- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts @@ -198,8 +198,27 @@ describe("AtRiskPasswordsComponent", () => { describe("pageDescription$", () => { it("should use single org description when tasks belong to one org", async () => { - const description = await firstValueFrom(component["pageDescription$"]); - expect(description).toBe("atRiskPasswordsDescSingleOrg"); + // Single task + let description = await firstValueFrom(component["pageDescription$"]); + expect(description).toBe("atRiskPasswordDescSingleOrg"); + + // Multiple tasks + mockTasks$.next([ + { + id: "task", + organizationId: "org", + cipherId: "cipher", + type: SecurityTaskType.UpdateAtRiskCredential, + } as SecurityTask, + { + id: "task2", + organizationId: "org", + cipherId: "cipher2", + type: SecurityTaskType.UpdateAtRiskCredential, + } as SecurityTask, + ]); + description = await firstValueFrom(component["pageDescription$"]); + expect(description).toBe("atRiskPasswordsDescSingleOrgPlural"); }); it("should use multiple org description when tasks belong to multiple orgs", async () => { @@ -218,7 +237,7 @@ describe("AtRiskPasswordsComponent", () => { } as SecurityTask, ]); const description = await firstValueFrom(component["pageDescription$"]); - expect(description).toBe("atRiskPasswordsDescMultiOrg"); + expect(description).toBe("atRiskPasswordsDescMultiOrgPlural"); }); }); diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts index 471bdfeed19..dd3d53fed7d 100644 --- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts @@ -115,14 +115,23 @@ export class AtRiskPasswordsComponent implements OnInit { startWith(true), ); - protected calloutDismissed$ = this.activeUserData$.pipe( + private calloutDismissed$ = this.activeUserData$.pipe( switchMap(({ userId }) => this.atRiskPasswordPageService.isCalloutDismissed(userId)), ); - - protected inlineAutofillSettingEnabled$ = this.autofillSettingsService.inlineMenuVisibility$.pipe( + private inlineAutofillSettingEnabled$ = this.autofillSettingsService.inlineMenuVisibility$.pipe( map((setting) => setting !== AutofillOverlayVisibility.Off), ); + protected showAutofillCallout$ = combineLatest([ + this.calloutDismissed$, + this.inlineAutofillSettingEnabled$, + ]).pipe( + map(([calloutDismissed, inlineAutofillSettingEnabled]) => { + return !calloutDismissed && !inlineAutofillSettingEnabled; + }), + startWith(false), + ); + protected atRiskItems$ = this.activeUserData$.pipe( map(({ tasks, ciphers }) => tasks @@ -143,11 +152,19 @@ export class AtRiskPasswordsComponent implements OnInit { const [orgId] = orgIds; return this.organizationService.organizations$(userId).pipe( getOrganizationById(orgId), - map((org) => this.i18nService.t("atRiskPasswordsDescSingleOrg", org?.name, tasks.length)), + map((org) => + this.i18nService.t( + tasks.length === 1 + ? "atRiskPasswordDescSingleOrg" + : "atRiskPasswordsDescSingleOrgPlural", + org?.name, + tasks.length, + ), + ), ); } - return of(this.i18nService.t("atRiskPasswordsDescMultiOrg", tasks.length)); + return of(this.i18nService.t("atRiskPasswordsDescMultiOrgPlural", tasks.length)); }), ); From a2b9844fa456e1490ae47f88fbccd41e21823768 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Wed, 26 Feb 2025 15:57:29 -0800 Subject: [PATCH 33/41] [PM-18623][PM-18621][PM-18615] fix defects for new settings popover (#13572) * fix defects for new settings popover * also check for "click items to autofill" setting --- .../new-settings-callout.component.html | 8 +++++++- .../new-settings-callout.component.ts | 14 +++++++++++++- .../components/vault-v2/vault-page.service.ts | 6 ------ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-settings-callout/new-settings-callout.component.html b/apps/browser/src/vault/popup/components/vault-v2/new-settings-callout/new-settings-callout.component.html index 727a8e938af..a6abe8ed3ac 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-settings-callout/new-settings-callout.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/new-settings-callout/new-settings-callout.component.html @@ -14,7 +14,13 @@ > diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-settings-callout/new-settings-callout.component.ts b/apps/browser/src/vault/popup/components/vault-v2/new-settings-callout/new-settings-callout.component.ts index 71549906474..713dc21c424 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-settings-callout/new-settings-callout.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/new-settings-callout/new-settings-callout.component.ts @@ -9,8 +9,10 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { UserId } from "@bitwarden/common/types/guid"; +import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { ButtonModule, PopoverModule } from "@bitwarden/components"; +import { VaultPopupCopyButtonsService } from "../../../services/vault-popup-copy-buttons.service"; import { VaultPageService } from "../vault-page.service"; @Component({ @@ -30,11 +32,18 @@ export class NewSettingsCalloutComponent implements OnInit, OnDestroy { private vaultPageService: VaultPageService, private router: Router, private logService: LogService, + private copyButtonService: VaultPopupCopyButtonsService, + private vaultSettingsService: VaultSettingsService, ) {} async ngOnInit() { this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const showQuickCopyActions = await firstValueFrom(this.copyButtonService.showQuickCopyActions$); + const clickItemsToAutofillVaultView = await firstValueFrom( + this.vaultSettingsService.clickItemsToAutofillVaultView$, + ); + let profileCreatedDate: Date; try { @@ -50,7 +59,10 @@ export class NewSettingsCalloutComponent implements OnInit, OnDestroy { ); this.showNewCustomizationSettingsCallout = - !hasCalloutBeenDismissed && profileCreatedDate < new Date("2024-12-25"); + !showQuickCopyActions && + !clickItemsToAutofillVaultView && + !hasCalloutBeenDismissed && + profileCreatedDate < new Date("2024-12-25"); } async goToAppearance() { diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-page.service.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-page.service.ts index 75354298c26..a7c52ed4c51 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-page.service.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-page.service.ts @@ -21,12 +21,6 @@ export const NEW_CUSTOMIZATION_OPTIONS_CALLOUT_DISMISSED_KEY = new UserKeyDefini export class VaultPageService { private stateProvider = inject(StateProvider); - async unDismissCallout(userId: UserId): Promise { - await this.stateProvider - .getUser(userId, NEW_CUSTOMIZATION_OPTIONS_CALLOUT_DISMISSED_KEY) - .update(() => false); - } - isCalloutDismissed(userId: UserId): Observable { return this.stateProvider .getUser(userId, NEW_CUSTOMIZATION_OPTIONS_CALLOUT_DISMISSED_KEY) From 182ff6481dcbd928f1df2fb16c8a381eb611b7b0 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Wed, 26 Feb 2025 21:49:48 -0500 Subject: [PATCH 34/41] Remove early return from redirect initialization. (#13585) --- libs/auth/src/angular/sso/sso.component.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/libs/auth/src/angular/sso/sso.component.ts b/libs/auth/src/angular/sso/sso.component.ts index cd3429323b5..cc9e5f83c01 100644 --- a/libs/auth/src/angular/sso/sso.component.ts +++ b/libs/auth/src/angular/sso/sso.component.ts @@ -155,13 +155,6 @@ export class SsoComponent implements OnInit { return; } - // Detect if we are on the first portion of the SSO flow - // and have been sent here from another client with the info in query params - if (this.hasParametersFromOtherClientRedirect(qParams)) { - this.initializeFromRedirectFromOtherClient(qParams); - return; - } - // Detect if we have landed here but only have an SSO identifier in the URL. // This is used by integrations that want to "short-circuit" the login to send users // directly to their IdP to simulate IdP-initiated SSO, so we submit automatically. @@ -172,8 +165,15 @@ export class SsoComponent implements OnInit { return; } - // If we're routed here with no additional parameters, we'll try to determine the - // identifier using claimed domain or local state saved from their last attempt. + // Detect if we are on the first portion of the SSO flow + // and have been sent here from another client with the info in query params. + // If so, we want to initialize the SSO flow with those values. + if (this.hasParametersFromOtherClientRedirect(qParams)) { + this.initializeFromRedirectFromOtherClient(qParams); + } + + // Try to determine the identifier using claimed domain or local state + // persisted from the user's last login attempt. await this.initializeIdentifierFromEmailOrStorage(); } From ec488e4f847f1d476e4f95f01d711e6207e334a6 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Thu, 27 Feb 2025 10:00:52 -0500 Subject: [PATCH 35/41] [PM-18664] Prevent display of Auth Request notification on triggering device (#13597) * Send device identifier in header. * Added null to apiUrl property for strict typing. * Added null to apiUrl for strict typing. --- .../auth-request/auth-request-api.service.ts | 15 ++++++++++++++- libs/common/src/abstractions/api.service.ts | 2 +- libs/common/src/services/api.service.ts | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/libs/auth/src/common/services/auth-request/auth-request-api.service.ts b/libs/auth/src/common/services/auth-request/auth-request-api.service.ts index b5fc72588a6..c9fec1400c9 100644 --- a/libs/auth/src/common/services/auth-request/auth-request-api.service.ts +++ b/libs/auth/src/common/services/auth-request/auth-request-api.service.ts @@ -54,7 +54,20 @@ export class DefaultAuthRequestApiService implements AuthRequestApiService { async postAuthRequest(request: AuthRequest): Promise { try { - const response = await this.apiService.send("POST", "/auth-requests/", request, false, true); + // Submit the current device identifier in the header as well as in the POST body. + // The value in the header will be used to build the request context and ensure that the resulting + // notifications have the current device as a source. + const response = await this.apiService.send( + "POST", + "/auth-requests/", + request, + false, + true, + null, + (headers) => { + headers.set("Device-Identifier", request.deviceIdentifier); + }, + ); return new AuthRequestResponse(response); } catch (e: unknown) { diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 5bd2221860b..fe3f356719b 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -142,7 +142,7 @@ export abstract class ApiService { body: any, authed: boolean, hasResponse: boolean, - apiUrl?: string, + apiUrl?: string | null, alterHeaders?: (headers: Headers) => void, ) => Promise; diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index ad59ad0837a..6d90f2ac253 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -1863,7 +1863,7 @@ export class ApiService implements ApiServiceAbstraction { body: any, authed: boolean, hasResponse: boolean, - apiUrl?: string, + apiUrl?: string | null, alterHeaders?: (headers: Headers) => void, ): Promise { const env = await firstValueFrom(this.environmentService.environment$); From e6aaa6556356bc4c5b13a32a1d08f93f67d5c334 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Thu, 27 Feb 2025 10:53:24 -0500 Subject: [PATCH 36/41] fix(New-UI-Login-SSO): [Auth/PM-18693] LoginComp - fix form validation not showing up on SSO click (#13601) --- libs/auth/src/angular/login/login.component.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts index 47109b00bbb..a84fb93bd23 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -629,12 +629,7 @@ export class LoginComponent implements OnInit, OnDestroy { * Handle the SSO button click. */ async handleSsoClick() { - // Make sure the email is not empty, for type safety const email = this.formGroup.value.email; - if (!email) { - this.logService.error("Email is required for SSO"); - return; - } // Make sure the email is valid const isEmailValid = await this.validateEmail(); @@ -642,6 +637,12 @@ export class LoginComponent implements OnInit, OnDestroy { return; } + // Make sure the email is not empty, for type safety + if (!email) { + this.logService.error("Email is required for SSO"); + return; + } + // Save the email configuration for the login component await this.saveEmailSettings(); From 1da7f2052cbf3e9396c26e3236aaba88babac9ff Mon Sep 17 00:00:00 2001 From: Vicki League Date: Thu, 27 Feb 2025 11:39:46 -0500 Subject: [PATCH 37/41] [PM-18663] Fix calls to bit-button loading states (#13592) --- .../src/billing/popup/settings/premium-v2.component.html | 6 +++--- .../app/billing/individual/user-subscription.component.html | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/browser/src/billing/popup/settings/premium-v2.component.html b/apps/browser/src/billing/popup/settings/premium-v2.component.html index f578de8ae7a..4f87a0f6781 100644 --- a/apps/browser/src/billing/popup/settings/premium-v2.component.html +++ b/apps/browser/src/billing/popup/settings/premium-v2.component.html @@ -45,14 +45,14 @@ #refreshBtn type="button" (click)="refresh()" - [disabled]="$any(refreshBtn).loading" + [disabled]="$any(refreshBtn).loading()" [appApiAction]="refreshPromise" bitButton > - {{ "premiumRefresh" | i18n }} + {{ "premiumRefresh" | i18n }} diff --git a/apps/web/src/app/billing/individual/user-subscription.component.html b/apps/web/src/app/billing/individual/user-subscription.component.html index 1c1382cd816..e801237467a 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.html +++ b/apps/web/src/app/billing/individual/user-subscription.component.html @@ -27,7 +27,7 @@ #reinstateBtn (click)="reinstate()" [appApiAction]="reinstatePromise" - [disabled]="$any(reinstateBtn).loading" + [disabled]="$any(reinstateBtn).loading()" > {{ "reinstateSubscription" | i18n }} @@ -109,7 +109,7 @@ class="tw-ml-auto" (click)="cancelSubscription()" [appApiAction]="cancelPromise" - [disabled]="$any(cancelBtn).loading" + [disabled]="$any(cancelBtn).loading()" *ngIf="subscription && !subscription.cancelled && !subscriptionMarkedForCancel" > {{ "cancelSubscription" | i18n }} From 319a4dd1cc929c04140e988eac7af06f40fe98c4 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Thu, 27 Feb 2025 13:17:47 -0500 Subject: [PATCH 38/41] Remove checks of device verification flags on client (#13604) --- apps/browser/src/popup/app-routing.module.ts | 8 +------- apps/desktop/src/app/app-routing.module.ts | 8 +------- apps/web/src/app/oss-routing.module.ts | 8 +------- libs/common/src/enums/feature-flag.enum.ts | 2 -- 4 files changed, 3 insertions(+), 23 deletions(-) diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index eb09f719aaf..76894b23d0a 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -17,7 +17,6 @@ import { tdeDecryptionRequiredGuard, unauthGuardFn, } from "@bitwarden/angular/auth/guards"; -import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards"; import { AnonLayoutWrapperComponent, @@ -45,7 +44,6 @@ import { UserLockIcon, VaultIcon, } from "@bitwarden/auth/angular"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { LockComponent } from "@bitwarden/key-management-ui"; import { NewDeviceVerificationNoticePageOneComponent, @@ -245,11 +243,7 @@ const routes: Routes = [ { path: "device-verification", component: ExtensionAnonLayoutWrapperComponent, - canActivate: [ - canAccessFeature(FeatureFlag.NewDeviceVerification), - unauthGuardFn(), - activeAuthGuard(), - ], + canActivate: [unauthGuardFn(), activeAuthGuard()], children: [{ path: "", component: NewDeviceVerificationComponent }], data: { pageIcon: DeviceVerificationIcon, diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index b9dccf70322..1ebb1f8de39 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -15,7 +15,6 @@ import { tdeDecryptionRequiredGuard, unauthGuardFn, } from "@bitwarden/angular/auth/guards"; -import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards"; import { AnonLayoutWrapperComponent, @@ -43,7 +42,6 @@ import { NewDeviceVerificationComponent, DeviceVerificationIcon, } from "@bitwarden/auth/angular"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { LockComponent } from "@bitwarden/key-management-ui"; import { NewDeviceVerificationNoticePageOneComponent, @@ -123,11 +121,7 @@ const routes: Routes = [ { path: "device-verification", component: AnonLayoutWrapperComponent, - canActivate: [ - canAccessFeature(FeatureFlag.NewDeviceVerification), - unauthGuardFn(), - activeAuthGuard(), - ], + canActivate: [unauthGuardFn(), activeAuthGuard()], children: [{ path: "", component: NewDeviceVerificationComponent }], data: { pageIcon: DeviceVerificationIcon, diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 81057426500..091c5440d70 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -11,7 +11,6 @@ import { unauthGuardFn, activeAuthGuard, } from "@bitwarden/angular/auth/guards"; -import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards"; import { AnonLayoutWrapperComponent, @@ -42,7 +41,6 @@ import { NewDeviceVerificationComponent, DeviceVerificationIcon, } from "@bitwarden/auth/angular"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { LockComponent } from "@bitwarden/key-management-ui"; import { NewDeviceVerificationNoticePageOneComponent, @@ -611,11 +609,7 @@ const routes: Routes = [ }, { path: "device-verification", - canActivate: [ - canAccessFeature(FeatureFlag.NewDeviceVerification), - unauthGuardFn(), - activeAuthGuard(), - ], + canActivate: [unauthGuardFn(), activeAuthGuard()], children: [ { path: "", diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 2f3e6bb724b..5d34e5b8310 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -45,7 +45,6 @@ export enum FeatureFlag { PrivateKeyRegeneration = "pm-12241-private-key-regeneration", ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs", AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner", - NewDeviceVerification = "new-device-verification", PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal", RecoveryCodeLogin = "pm-17128-recovery-code-login", PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features", @@ -104,7 +103,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PrivateKeyRegeneration]: FALSE, [FeatureFlag.ResellerManagedOrgAlert]: FALSE, [FeatureFlag.AccountDeprovisioningBanner]: FALSE, - [FeatureFlag.NewDeviceVerification]: FALSE, [FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE, [FeatureFlag.RecoveryCodeLogin]: FALSE, [FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE, From 407368b3ea0ba3774c0e73d20af6599c1913d2cd Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 27 Feb 2025 20:55:24 +0100 Subject: [PATCH 39/41] [PM-18706] Added permission check for organizational inactive 2fa report (#13610) * Added permission check for organizational inactive 2fa report Only display the cipher's name if the user running the report does not have permissions to view/edit the cipher * Add appropiate access modifiers to newly added members/methods --------- Co-authored-by: Daniel James Smith --- .../inactive-two-factor-report.component.html | 21 ++++++++++++------- .../inactive-two-factor-report.component.ts | 11 ++++++++++ .../inactive-two-factor-report.component.ts | 9 ++++++++ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.html b/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.html index 52bbb1c5e6d..e667a65235b 100644 --- a/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.html +++ b/apps/web/src/app/tools/reports/pages/inactive-two-factor-report.component.html @@ -47,14 +47,19 @@ - {{ r.name }} + + {{ r.name }} + + + {{ r.name }} + { return this.cipherService.getAllFromApiForOrganization(this.organization.id); } + + protected canManageCipher(c: CipherView): boolean { + return this.manageableCiphers.some((x) => x.id === c.id); + } } From eaeea195e4ec8df9b6c02e93cea2f40584b80a9a Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 27 Feb 2025 20:55:38 +0100 Subject: [PATCH 40/41] Added permission check for organizational unsecure website (#13611) Only display the cipher's name if the user running the report does not have permissions to view/edit the cipher Co-authored-by: Daniel James Smith --- .../unsecured-websites-report.component.ts | 9 +++++ .../unsecured-websites-report.component.html | 22 ++++++----- .../unsecured-websites-report.component.ts | 38 ++++++++----------- 3 files changed, 37 insertions(+), 32 deletions(-) diff --git a/apps/web/src/app/tools/reports/pages/organizations/unsecured-websites-report.component.ts b/apps/web/src/app/tools/reports/pages/organizations/unsecured-websites-report.component.ts index 156f3331d32..748986fe5bb 100644 --- a/apps/web/src/app/tools/reports/pages/organizations/unsecured-websites-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/organizations/unsecured-websites-report.component.ts @@ -13,6 +13,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { DialogService } from "@bitwarden/components"; import { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault"; @@ -41,6 +42,9 @@ export class UnsecuredWebsitesReportComponent extends BaseUnsecuredWebsitesReportComponent implements OnInit { + // Contains a list of ciphers, the user running the report, can manage + private manageableCiphers: Cipher[]; + constructor( cipherService: CipherService, dialogService: DialogService, @@ -80,6 +84,7 @@ export class UnsecuredWebsitesReportComponent .organizations$(userId) .pipe(getOrganizationById(params.organizationId)), ); + this.manageableCiphers = await this.cipherService.getAll(userId); await super.ngOnInit(); }); } @@ -87,4 +92,8 @@ export class UnsecuredWebsitesReportComponent getAllCiphers(): Promise { return this.cipherService.getAllFromApiForOrganization(this.organization.id); } + + protected canManageCipher(c: CipherView): boolean { + return this.manageableCiphers.some((x) => x.id === c.id); + } } diff --git a/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.html b/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.html index 9293915363e..6632413a79e 100644 --- a/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.html +++ b/apps/web/src/app/tools/reports/pages/unsecured-websites-report.component.html @@ -47,15 +47,19 @@ - - {{ r.name }} - + + {{ r.name }} + + + {{ r.name }} + { - const containsUnsecured = this.cipherContainsUnsecured(c); - if (containsUnsecured === false) { - return false; - } - - const canView = this.canView(c, allCollections); - return canView; + return this.cipherContainsUnsecured(c); }); this.filterCiphersByOrg(unsecuredCiphers); @@ -74,7 +67,12 @@ export class UnsecuredWebsitesReportComponent extends CipherReportComponent impl * @param cipher Current cipher with unsecured uri */ private cipherContainsUnsecured(cipher: CipherView): boolean { - if (cipher.type !== CipherType.Login || !cipher.login.hasUris || cipher.isDeleted) { + if ( + cipher.type !== CipherType.Login || + !cipher.login.hasUris || + cipher.isDeleted || + (!this.organization && !cipher.edit) + ) { return false; } @@ -85,19 +83,13 @@ export class UnsecuredWebsitesReportComponent extends CipherReportComponent impl } /** - * If the user does not have readonly set or it's false they have the ability to edit - * @param cipher Current cipher with unsecured uri - * @param allCollections The collections for the user + * Provides a way to determine if someone with permissions to run an organizational report is also able to view/edit ciphers within the results + * Default to true for indivduals running reports on their own vault. + * @param c CipherView + * @returns boolean */ - private canView(cipher: CipherView, allCollections: Collection[]): boolean { - if (!cipher.organizationId) { - return true; - } - - return ( - allCollections.filter( - (item) => cipher.collectionIds.indexOf(item.id) > -1 && !(item.readOnly ?? false), - ).length > 0 - ); + protected canManageCipher(c: CipherView): boolean { + // this will only ever be false from the org view; + return true; } } From 0b6828a72ba5aa84fc41f2ac9d04b7de08a5d08c Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Thu, 27 Feb 2025 14:49:13 -0600 Subject: [PATCH 41/41] [PM-18456] Display unassigned items in reports (#13612) --- .../pages/organizations/exposed-passwords-report.component.ts | 3 +++ .../organizations/inactive-two-factor-report.component.ts | 3 +++ .../pages/organizations/reused-passwords-report.component.ts | 3 +++ .../pages/organizations/unsecured-websites-report.component.ts | 3 +++ .../pages/organizations/weak-passwords-report.component.ts | 3 +++ 5 files changed, 15 insertions(+) diff --git a/apps/web/src/app/tools/reports/pages/organizations/exposed-passwords-report.component.ts b/apps/web/src/app/tools/reports/pages/organizations/exposed-passwords-report.component.ts index 4f523dbf7ba..2722e66f14f 100644 --- a/apps/web/src/app/tools/reports/pages/organizations/exposed-passwords-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/organizations/exposed-passwords-report.component.ts @@ -91,6 +91,9 @@ export class ExposedPasswordsReportComponent } canManageCipher(c: CipherView): boolean { + if (c.collectionIds.length === 0) { + return true; + } return this.manageableCiphers.some((x) => x.id === c.id); } } diff --git a/apps/web/src/app/tools/reports/pages/organizations/inactive-two-factor-report.component.ts b/apps/web/src/app/tools/reports/pages/organizations/inactive-two-factor-report.component.ts index 5979e99c0fa..9b53d583b99 100644 --- a/apps/web/src/app/tools/reports/pages/organizations/inactive-two-factor-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/organizations/inactive-two-factor-report.component.ts @@ -94,6 +94,9 @@ export class InactiveTwoFactorReportComponent } protected canManageCipher(c: CipherView): boolean { + if (c.collectionIds.length === 0) { + return true; + } return this.manageableCiphers.some((x) => x.id === c.id); } } diff --git a/apps/web/src/app/tools/reports/pages/organizations/reused-passwords-report.component.ts b/apps/web/src/app/tools/reports/pages/organizations/reused-passwords-report.component.ts index 1e2f5225d59..bcd573fb09d 100644 --- a/apps/web/src/app/tools/reports/pages/organizations/reused-passwords-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/organizations/reused-passwords-report.component.ts @@ -89,6 +89,9 @@ export class ReusedPasswordsReportComponent } canManageCipher(c: CipherView): boolean { + if (c.collectionIds.length === 0) { + return true; + } return this.manageableCiphers.some((x) => x.id === c.id); } } diff --git a/apps/web/src/app/tools/reports/pages/organizations/unsecured-websites-report.component.ts b/apps/web/src/app/tools/reports/pages/organizations/unsecured-websites-report.component.ts index 748986fe5bb..e653a6b9a05 100644 --- a/apps/web/src/app/tools/reports/pages/organizations/unsecured-websites-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/organizations/unsecured-websites-report.component.ts @@ -94,6 +94,9 @@ export class UnsecuredWebsitesReportComponent } protected canManageCipher(c: CipherView): boolean { + if (c.collectionIds.length === 0) { + return true; + } return this.manageableCiphers.some((x) => x.id === c.id); } } diff --git a/apps/web/src/app/tools/reports/pages/organizations/weak-passwords-report.component.ts b/apps/web/src/app/tools/reports/pages/organizations/weak-passwords-report.component.ts index 82abc8561fb..41018d69c22 100644 --- a/apps/web/src/app/tools/reports/pages/organizations/weak-passwords-report.component.ts +++ b/apps/web/src/app/tools/reports/pages/organizations/weak-passwords-report.component.ts @@ -93,6 +93,9 @@ export class WeakPasswordsReportComponent } canManageCipher(c: CipherView): boolean { + if (c.collectionIds.length === 0) { + return true; + } return this.manageableCiphers.some((x) => x.id === c.id); } }