From 25ada6f80f7f1ba14fc06d4755559727f480fc94 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Mon, 4 Aug 2025 09:20:12 -0700 Subject: [PATCH 01/24] refactor(login-approval-component) [Auth/PM-14940] Update LoginApprovalComponent (#15511) - Renames the `LoginApprovalComponent` to `LoginApprovalDialogComponent` - Renames the property `notificationId` to `authRequestId` for clarity - Updates text content on the component --- apps/browser/src/_locales/en/messages.json | 19 -- apps/desktop/src/app/app.component.ts | 5 +- .../src/app/services/services.module.ts | 8 +- ...approval-dialog-component.service.spec.ts} | 22 +-- ...ogin-approval-dialog-component.service.ts} | 12 +- apps/desktop/src/locales/en/messages.json | 63 +++++-- .../device-management-old.component.ts | 4 +- apps/web/src/locales/en/messages.json | 19 -- .../device-management-item-group.component.ts | 7 +- .../device-management-table.component.ts | 7 +- ...-approval-dialog-component.service.spec.ts | 30 ++++ ...login-approval-dialog-component.service.ts | 16 ++ libs/angular/src/auth/login-approval/index.ts | 3 + ...l-dialog-component.service.abstraction.ts} | 4 +- .../login-approval-dialog.component.html} | 16 +- .../login-approval-dialog.component.spec.ts} | 61 ++++--- .../login-approval-dialog.component.ts} | 167 ++++++++++-------- .../src/services/jslib-services.module.ts | 8 +- libs/auth/src/angular/index.ts | 4 - ...t-login-approval-component.service.spec.ts | 25 --- ...efault-login-approval-component.service.ts | 16 -- libs/auth/src/common/abstractions/index.ts | 1 - 22 files changed, 261 insertions(+), 256 deletions(-) rename apps/desktop/src/auth/login/{desktop-login-approval-component.service.spec.ts => desktop-login-approval-dialog-component.service.spec.ts} (75%) rename apps/desktop/src/auth/login/{desktop-login-approval-component.service.ts => desktop-login-approval-dialog-component.service.ts} (65%) create mode 100644 libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.spec.ts create mode 100644 libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.ts create mode 100644 libs/angular/src/auth/login-approval/index.ts rename libs/{auth/src/common/abstractions/login-approval-component.service.abstraction.ts => angular/src/auth/login-approval/login-approval-dialog-component.service.abstraction.ts} (57%) rename libs/{auth/src/angular/login-approval/login-approval.component.html => angular/src/auth/login-approval/login-approval-dialog.component.html} (72%) rename libs/{auth/src/angular/login-approval/login-approval.component.spec.ts => angular/src/auth/login-approval/login-approval-dialog.component.spec.ts} (77%) rename libs/{auth/src/angular/login-approval/login-approval.component.ts => angular/src/auth/login-approval/login-approval-dialog.component.ts} (56%) delete mode 100644 libs/auth/src/angular/login-approval/default-login-approval-component.service.spec.ts delete mode 100644 libs/auth/src/angular/login-approval/default-login-approval-component.service.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index a1b41b44bfd..ad933c24875 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3657,25 +3657,6 @@ "thisRequestIsNoLongerValid": { "message": "This request is no longer valid." }, - "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" - }, - "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", - "placeholders": { - "email": { - "content": "$1", - "example": "name@example.com" - }, - "device": { - "content": "$2", - "example": "iOS" - } - } - }, - "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." - }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 72bad5befe9..b0c5eb03723 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -24,11 +24,12 @@ import { } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; +import { LoginApprovalDialogComponent } from "@bitwarden/angular/auth/login-approval"; import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction"; import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { FingerprintDialogComponent, LoginApprovalComponent } from "@bitwarden/auth/angular"; +import { FingerprintDialogComponent } from "@bitwarden/auth/angular"; import { DESKTOP_SSO_CALLBACK, LogoutReason, @@ -476,7 +477,7 @@ export class AppComponent implements OnInit, OnDestroy { case "openLoginApproval": if (message.notificationId != null) { this.dialogService.closeAll(); - const dialogRef = LoginApprovalComponent.open(this.dialogService, { + const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, { notificationId: message.notificationId, }); await firstValueFrom(dialogRef.closed); diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 1bd8924ac15..4482c38fc3a 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -5,6 +5,7 @@ import { Router } from "@angular/router"; import { Subject, merge } from "rxjs"; import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; +import { LoginApprovalDialogComponentServiceAbstraction } from "@bitwarden/angular/auth/login-approval"; import { SetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction"; import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { @@ -31,7 +32,6 @@ import { } from "@bitwarden/auth/angular"; import { InternalUserDecryptionOptionsServiceAbstraction, - LoginApprovalComponentServiceAbstraction, LoginEmailService, SsoUrlService, } from "@bitwarden/auth/common"; @@ -107,7 +107,7 @@ import { import { LockComponentService } from "@bitwarden/key-management-ui"; import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault"; -import { DesktopLoginApprovalComponentService } from "../../auth/login/desktop-login-approval-component.service"; +import { DesktopLoginApprovalDialogComponentService } from "../../auth/login/desktop-login-approval-dialog-component.service"; import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service"; import { DesktopTwoFactorAuthDuoComponentService } from "../../auth/services/desktop-two-factor-auth-duo-component.service"; import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service"; @@ -444,8 +444,8 @@ const safeProviders: SafeProvider[] = [ deps: [], }), safeProvider({ - provide: LoginApprovalComponentServiceAbstraction, - useClass: DesktopLoginApprovalComponentService, + provide: LoginApprovalDialogComponentServiceAbstraction, + useClass: DesktopLoginApprovalDialogComponentService, deps: [I18nServiceAbstraction], }), safeProvider({ diff --git a/apps/desktop/src/auth/login/desktop-login-approval-component.service.spec.ts b/apps/desktop/src/auth/login/desktop-login-approval-dialog-component.service.spec.ts similarity index 75% rename from apps/desktop/src/auth/login/desktop-login-approval-component.service.spec.ts rename to apps/desktop/src/auth/login/desktop-login-approval-dialog-component.service.spec.ts index efe17960068..2ae584d7e7f 100644 --- a/apps/desktop/src/auth/login/desktop-login-approval-component.service.spec.ts +++ b/apps/desktop/src/auth/login/desktop-login-approval-dialog-component.service.spec.ts @@ -2,13 +2,13 @@ import { TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; import { Subject } from "rxjs"; -import { LoginApprovalComponent } from "@bitwarden/auth/angular"; +import { LoginApprovalDialogComponent } from "@bitwarden/angular/auth/login-approval"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { DesktopLoginApprovalComponentService } from "./desktop-login-approval-component.service"; +import { DesktopLoginApprovalDialogComponentService } from "./desktop-login-approval-dialog-component.service"; -describe("DesktopLoginApprovalComponentService", () => { - let service: DesktopLoginApprovalComponentService; +describe("DesktopLoginApprovalDialogComponentService", () => { + let service: DesktopLoginApprovalDialogComponentService; let i18nService: MockProxy; let originalIpc: any; @@ -31,12 +31,12 @@ describe("DesktopLoginApprovalComponentService", () => { TestBed.configureTestingModule({ providers: [ - DesktopLoginApprovalComponentService, + DesktopLoginApprovalDialogComponentService, { provide: I18nServiceAbstraction, useValue: i18nService }, ], }); - service = TestBed.inject(DesktopLoginApprovalComponentService); + service = TestBed.inject(DesktopLoginApprovalDialogComponentService); }); afterEach(() => { @@ -54,7 +54,7 @@ describe("DesktopLoginApprovalComponentService", () => { const message = `Confirm access attempt for ${email}`; const closeText = "Close"; - const loginApprovalComponent = { email } as LoginApprovalComponent; + const loginApprovalDialogComponent = { email } as LoginApprovalDialogComponent; i18nService.t.mockImplementation((key: string) => { switch (key) { case "accountAccessRequested": @@ -71,18 +71,20 @@ describe("DesktopLoginApprovalComponentService", () => { jest.spyOn(ipc.platform, "isWindowVisible").mockResolvedValue(false); jest.spyOn(ipc.auth, "loginRequest").mockResolvedValue(); - await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalComponent.email); + await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalDialogComponent.email); expect(ipc.auth.loginRequest).toHaveBeenCalledWith(title, message, closeText); }); it("does not call ipc.auth.loginRequest when window is visible", async () => { - const loginApprovalComponent = { email: "test@bitwarden.com" } as LoginApprovalComponent; + const loginApprovalDialogComponent = { + email: "test@bitwarden.com", + } as LoginApprovalDialogComponent; jest.spyOn(ipc.platform, "isWindowVisible").mockResolvedValue(true); jest.spyOn(ipc.auth, "loginRequest"); - await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalComponent.email); + await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalDialogComponent.email); expect(ipc.auth.loginRequest).not.toHaveBeenCalled(); }); diff --git a/apps/desktop/src/auth/login/desktop-login-approval-component.service.ts b/apps/desktop/src/auth/login/desktop-login-approval-dialog-component.service.ts similarity index 65% rename from apps/desktop/src/auth/login/desktop-login-approval-component.service.ts rename to apps/desktop/src/auth/login/desktop-login-approval-dialog-component.service.ts index 3b4658f34c5..9c48f71990a 100644 --- a/apps/desktop/src/auth/login/desktop-login-approval-component.service.ts +++ b/apps/desktop/src/auth/login/desktop-login-approval-dialog-component.service.ts @@ -1,13 +1,15 @@ import { Injectable } from "@angular/core"; -import { DefaultLoginApprovalComponentService } from "@bitwarden/auth/angular"; -import { LoginApprovalComponentServiceAbstraction } from "@bitwarden/auth/common"; +import { + DefaultLoginApprovalDialogComponentService, + LoginApprovalDialogComponentServiceAbstraction, +} from "@bitwarden/angular/auth/login-approval"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; @Injectable() -export class DesktopLoginApprovalComponentService - extends DefaultLoginApprovalComponentService - implements LoginApprovalComponentServiceAbstraction +export class DesktopLoginApprovalDialogComponentService + extends DefaultLoginApprovalDialogComponentService + implements LoginApprovalDialogComponentServiceAbstraction { constructor(private i18nService: I18nServiceAbstraction) { super(); diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 97b1107056e..8b30bd85ec9 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -3027,9 +3027,6 @@ "message": "Toggle character count", "description": "'Character count' describes a feature that displays a number next to each character of the password." }, - "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" - }, "accessAttemptBy": { "message": "Access attempt by $EMAIL$", "placeholders": { @@ -3039,6 +3036,50 @@ } } }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "webApp": { + "message": "Web app" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "server": { + "message": "Server" + }, + "loginRequest": { + "message": "Login request" + }, "deviceType": { "message": "Device Type" }, @@ -3054,22 +3095,6 @@ "denyAccess": { "message": "Deny access" }, - "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", - "placeholders": { - "email": { - "content": "$1", - "example": "name@example.com" - }, - "device": { - "content": "$2", - "example": "iOS" - } - } - }, - "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." - }, "justNow": { "message": "Just now" }, diff --git a/apps/web/src/app/auth/settings/security/device-management-old.component.ts b/apps/web/src/app/auth/settings/security/device-management-old.component.ts index 556ba381acc..816da6e873f 100644 --- a/apps/web/src/app/auth/settings/security/device-management-old.component.ts +++ b/apps/web/src/app/auth/settings/security/device-management-old.component.ts @@ -3,7 +3,7 @@ import { Component, DestroyRef } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { firstValueFrom } from "rxjs"; -import { LoginApprovalComponent } from "@bitwarden/auth/angular"; +import { LoginApprovalDialogComponent } from "@bitwarden/angular/auth/login-approval"; import { AuthRequestApiServiceAbstraction } from "@bitwarden/auth/common"; import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; import { @@ -325,7 +325,7 @@ export class DeviceManagementOldComponent { return; } - const dialogRef = LoginApprovalComponent.open(this.dialogService, { + const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, { notificationId: device.devicePendingAuthRequest.id, }); diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 2a75bf51900..62f73fd4935 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1428,9 +1428,6 @@ "notificationSentDevicePart1": { "message": "Unlock Bitwarden on your device or on the " }, - "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" - }, "accessAttemptBy": { "message": "Access attempt by $EMAIL$", "placeholders": { @@ -3981,22 +3978,6 @@ "thisRequestIsNoLongerValid": { "message": "This request is no longer valid." }, - "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", - "placeholders": { - "email": { - "content": "$1", - "example": "name@example.com" - }, - "device": { - "content": "$2", - "example": "iOS" - } - } - }, - "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." - }, "loginRequestApprovedForEmailOnDevice": { "message": "Login request approved for $EMAIL$ on $DEVICE$", "placeholders": { diff --git a/libs/angular/src/auth/device-management/device-management-item-group.component.ts b/libs/angular/src/auth/device-management/device-management-item-group.component.ts index 62468a18225..864712ceb78 100644 --- a/libs/angular/src/auth/device-management/device-management-item-group.component.ts +++ b/libs/angular/src/auth/device-management/device-management-item-group.component.ts @@ -2,13 +2,12 @@ import { CommonModule } from "@angular/common"; import { Component, Input } from "@angular/core"; import { firstValueFrom } from "rxjs"; -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { LoginApprovalComponent } from "@bitwarden/auth/angular"; import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response"; import { BadgeModule, DialogService, ItemModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; +import { LoginApprovalDialogComponent } from "../login-approval/login-approval-dialog.component"; + import { DeviceDisplayData } from "./device-management.component"; import { clearAuthRequestAndResortDevices } from "./resort-devices.helper"; @@ -29,7 +28,7 @@ export class DeviceManagementItemGroupComponent { return; } - const loginApprovalDialog = LoginApprovalComponent.open(this.dialogService, { + const loginApprovalDialog = LoginApprovalDialogComponent.open(this.dialogService, { notificationId: pendingAuthRequest.id, }); diff --git a/libs/angular/src/auth/device-management/device-management-table.component.ts b/libs/angular/src/auth/device-management/device-management-table.component.ts index 1d20e54deec..c3c835f05ed 100644 --- a/libs/angular/src/auth/device-management/device-management-table.component.ts +++ b/libs/angular/src/auth/device-management/device-management-table.component.ts @@ -3,9 +3,6 @@ import { Component, Input, OnChanges, SimpleChanges } from "@angular/core"; import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { LoginApprovalComponent } from "@bitwarden/auth/angular"; import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { @@ -17,6 +14,8 @@ import { TableModule, } from "@bitwarden/components"; +import { LoginApprovalDialogComponent } from "../login-approval/login-approval-dialog.component"; + import { DeviceDisplayData } from "./device-management.component"; import { clearAuthRequestAndResortDevices } from "./resort-devices.helper"; @@ -68,7 +67,7 @@ export class DeviceManagementTableComponent implements OnChanges { return; } - const loginApprovalDialog = LoginApprovalComponent.open(this.dialogService, { + const loginApprovalDialog = LoginApprovalDialogComponent.open(this.dialogService, { notificationId: pendingAuthRequest.id, }); diff --git a/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.spec.ts b/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.spec.ts new file mode 100644 index 00000000000..018b1ce2547 --- /dev/null +++ b/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.spec.ts @@ -0,0 +1,30 @@ +import { TestBed } from "@angular/core/testing"; + +import { DefaultLoginApprovalDialogComponentService } from "./default-login-approval-dialog-component.service"; +import { LoginApprovalDialogComponent } from "./login-approval-dialog.component"; + +describe("DefaultLoginApprovalDialogComponentService", () => { + let service: DefaultLoginApprovalDialogComponentService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [DefaultLoginApprovalDialogComponentService], + }); + + service = TestBed.inject(DefaultLoginApprovalDialogComponentService); + }); + + it("is created successfully", () => { + expect(service).toBeTruthy(); + }); + + it("has showLoginRequestedAlertIfWindowNotVisible method that is a no-op", async () => { + const loginApprovalDialogComponent = {} as LoginApprovalDialogComponent; + + const result = await service.showLoginRequestedAlertIfWindowNotVisible( + loginApprovalDialogComponent.email, + ); + + expect(result).toBeUndefined(); + }); +}); diff --git a/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.ts b/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.ts new file mode 100644 index 00000000000..5fefd3c3abb --- /dev/null +++ b/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.ts @@ -0,0 +1,16 @@ +import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval-dialog-component.service.abstraction"; + +/** + * Default implementation of the LoginApprovalDialogComponentServiceAbstraction. + */ +export class DefaultLoginApprovalDialogComponentService + implements LoginApprovalDialogComponentServiceAbstraction +{ + /** + * No-op implementation of the showLoginRequestedAlertIfWindowNotVisible method. + * @returns + */ + async showLoginRequestedAlertIfWindowNotVisible(email?: string): Promise { + return; + } +} diff --git a/libs/angular/src/auth/login-approval/index.ts b/libs/angular/src/auth/login-approval/index.ts new file mode 100644 index 00000000000..7b34b17d56b --- /dev/null +++ b/libs/angular/src/auth/login-approval/index.ts @@ -0,0 +1,3 @@ +export * from "./login-approval-dialog.component"; +export * from "./login-approval-dialog-component.service.abstraction"; +export * from "./default-login-approval-dialog-component.service"; diff --git a/libs/auth/src/common/abstractions/login-approval-component.service.abstraction.ts b/libs/angular/src/auth/login-approval/login-approval-dialog-component.service.abstraction.ts similarity index 57% rename from libs/auth/src/common/abstractions/login-approval-component.service.abstraction.ts rename to libs/angular/src/auth/login-approval/login-approval-dialog-component.service.abstraction.ts index eaa62359808..f29311402a7 100644 --- a/libs/auth/src/common/abstractions/login-approval-component.service.abstraction.ts +++ b/libs/angular/src/auth/login-approval/login-approval-dialog-component.service.abstraction.ts @@ -1,7 +1,7 @@ /** - * Abstraction for the LoginApprovalComponent service. + * Abstraction for the LoginApprovalDialogComponent service. */ -export abstract class LoginApprovalComponentServiceAbstraction { +export abstract class LoginApprovalDialogComponentServiceAbstraction { /** * Shows a login requested alert if the window is not visible. */ diff --git a/libs/auth/src/angular/login-approval/login-approval.component.html b/libs/angular/src/auth/login-approval/login-approval-dialog.component.html similarity index 72% rename from libs/auth/src/angular/login-approval/login-approval.component.html rename to libs/angular/src/auth/login-approval/login-approval-dialog.component.html index d37e30c5e0a..f2850406235 100644 --- a/libs/auth/src/angular/login-approval/login-approval.component.html +++ b/libs/angular/src/auth/login-approval/login-approval-dialog.component.html @@ -1,5 +1,6 @@ - {{ "areYouTryingToAccessYourAccount" | i18n }} + {{ "loginRequest" | i18n }} +
@@ -8,28 +9,29 @@ -

{{ "accessAttemptBy" | i18n: email }}

+

{{ "accessAttemptBy" | i18n: email }}

- {{ "fingerprintPhraseHeader" | i18n }} + {{ "fingerprintPhraseHeader" | i18n }}

{{ fingerprintPhrase }}

- {{ "deviceType" | i18n }} -

{{ authRequestResponse?.requestDeviceType }}

+ {{ "deviceType" | i18n }} +

{{ readableDeviceTypeName }}

- {{ "location" | i18n }} + {{ "location" | i18n }}

{{ authRequestResponse?.requestCountryName }} ({{ authRequestResponse?.requestIpAddress }})

- {{ "time" | i18n }} + {{ "time" | i18n }}

{{ requestTimeText }}

+ - - - - - - - - - {{ "launch" | i18n }} - - - - - + @if (!decryptionFailure && !hideMenu) { - - - - + appStopProp + appA11yTitle="{{ 'options' | i18n }}" + > + + + + + + + + {{ "launch" | i18n }} + + + + + + + + + + + } diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts index cb4d8ad70b1..32037493e36 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts @@ -189,8 +189,14 @@ export class VaultCipherRowComponent implements OnInit return this.i18nService.t("noAccess"); } + protected get showCopyUsername(): boolean { + const usernameCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "username"); + return this.isNotDeletedLoginCipher && usernameCopy; + } + protected get showCopyPassword(): boolean { - return this.isNotDeletedLoginCipher && this.cipher.viewPassword; + const passwordCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "password"); + return this.isNotDeletedLoginCipher && this.cipher.viewPassword && passwordCopy; } protected get showCopyTotp(): boolean { @@ -201,16 +207,20 @@ export class VaultCipherRowComponent implements OnInit return this.isNotDeletedLoginCipher && this.canLaunch; } - protected get disableMenu() { + protected get isDeletedCanRestore(): boolean { + return CipherViewLikeUtils.isDeleted(this.cipher) && this.canRestoreCipher; + } + + protected get hideMenu() { return !( - this.isNotDeletedLoginCipher || + this.isDeletedCanRestore || + this.showCopyUsername || this.showCopyPassword || this.showCopyTotp || this.showLaunchUri || this.showAttachments || this.showClone || - this.canEditCipher || - (CipherViewLikeUtils.isDeleted(this.cipher) && this.canRestoreCipher) + this.canEditCipher ); } From bddd81ce7973a991d826e57fba35bac3ab5383da Mon Sep 17 00:00:00 2001 From: Ben Brooks <56796209+bensbits91@users.noreply.github.com> Date: Mon, 4 Aug 2025 11:02:15 -0700 Subject: [PATCH 03/24] fix: [PM-23494] detect ID vs login inputs on booksamillion.com (#15548) * fix: [PM-23494] add newsletter checks; cleanup Signed-off-by: Ben Brooks * fix: [PM-23494] improve isExplicitIdentityEmailField, add NewEmailFieldKeywords Signed-off-by: Ben Brooks * fix: [PM-23494] improve isNewsletterForm, add NewsletterFormNames Signed-off-by: Ben Brooks --------- Signed-off-by: Ben Brooks --- .../autofill/services/autofill-constants.ts | 9 +++ ...inline-menu-field-qualification.service.ts | 80 ++++++++++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/apps/browser/src/autofill/services/autofill-constants.ts b/apps/browser/src/autofill/services/autofill-constants.ts index 55c3cced726..7467d5d4ba7 100644 --- a/apps/browser/src/autofill/services/autofill-constants.ts +++ b/apps/browser/src/autofill/services/autofill-constants.ts @@ -50,6 +50,15 @@ export class AutoFillConstants { static readonly SearchFieldNames: string[] = ["search", "query", "find", "go"]; + static readonly NewEmailFieldKeywords: string[] = [ + "new-email", + "newemail", + "new email", + "neue e-mail", + ]; + + static readonly NewsletterFormNames: string[] = ["newsletter"]; + static readonly FieldIgnoreList: string[] = ["captcha", "findanything", "forgot"]; static readonly PasswordFieldExcludeList: string[] = [ diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts index 9b16a0cfbdd..b12017484eb 100644 --- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts @@ -58,6 +58,8 @@ export class InlineMenuFieldQualificationService "neue e-mail", "pwdcheck", ]; + private newEmailFieldKeywords = new Set(AutoFillConstants.NewEmailFieldKeywords); + private newsletterFormKeywords = new Set(AutoFillConstants.NewsletterFormNames); private updatePasswordFieldKeywords = [ "update password", "change password", @@ -152,6 +154,61 @@ export class InlineMenuFieldQualificationService private totpFieldAutocompleteValue = "one-time-code"; private premiumEnabled = false; + /** + * Validates the provided field to indicate if the field is a new email field used for account creation/registration. + * + * @param field - The field to validate + */ + private isExplicitIdentityEmailField(field: AutofillField): boolean { + const matchFieldAttributeValues = [field.type, field.htmlName, field.htmlID, field.placeholder]; + for (let attrIndex = 0; attrIndex < matchFieldAttributeValues.length; attrIndex++) { + if (!matchFieldAttributeValues[attrIndex]) { + continue; + } + + for (let keywordIndex = 0; keywordIndex < matchFieldAttributeValues.length; keywordIndex++) { + if (this.newEmailFieldKeywords.has(matchFieldAttributeValues[attrIndex])) { + return true; + } + } + } + + return false; + } + + /** + * Validates the provided form to indicate if the form is related to newsletter registration. + * + * @param parentForm - The form to validate + */ + private isNewsletterForm(parentForm: any): boolean { + if (!parentForm) { + return false; + } + + const matchFieldAttributeValues = [ + parentForm.type, + parentForm.htmlName, + parentForm.htmlID, + parentForm.placeholder, + ]; + + for (let attrIndex = 0; attrIndex < matchFieldAttributeValues.length; attrIndex++) { + const attrValue = matchFieldAttributeValues[attrIndex]; + if (!attrValue || typeof attrValue !== "string") { + continue; + } + const attrValueLower = attrValue.toLowerCase(); + for (const keyword of this.newsletterFormKeywords) { + if (attrValueLower.includes(keyword.toLowerCase())) { + return true; + } + } + } + + return false; + } + constructor() { void Promise.all([ sendExtensionMessage("getInlineMenuFieldQualificationFeatureFlag"), @@ -300,7 +357,11 @@ export class InlineMenuFieldQualificationService return false; } - return this.fieldContainsAutocompleteValues(field, this.identityAutocompleteValues); + return ( + // Recognize explicit identity email fields (like id="new-email") + this.isFieldForIdentityEmail(field) || + this.fieldContainsAutocompleteValues(field, this.identityAutocompleteValues) + ); } /** @@ -397,6 +458,12 @@ export class InlineMenuFieldQualificationService ): boolean { // If the provided field is set with an autocomplete of "username", we should assume that // the page developer intends for this field to be interpreted as a username field. + + // Exclude non-login email field from being treated as a login username field + if (this.isExplicitIdentityEmailField(field)) { + return false; + } + if (this.fieldContainsAutocompleteValues(field, this.loginUsernameAutocompleteValues)) { const newPasswordFieldsInPageDetails = pageDetails.fields.filter( (field) => field.viewable && this.isNewPasswordField(field), @@ -415,6 +482,10 @@ export class InlineMenuFieldQualificationService const parentForm = pageDetails.forms[field.form]; const passwordFieldsInPageDetails = pageDetails.fields.filter(this.isCurrentPasswordField); + if (this.isNewsletterForm(parentForm)) { + return false; + } + // If the field is not structured within a form, we need to identify if the field is used in conjunction // with a password field. If that's the case, then we should assume that it is a form field element. if (!parentForm) { @@ -822,9 +893,14 @@ export class InlineMenuFieldQualificationService * @param field - The field to validate */ isFieldForIdentityEmail = (field: AutofillField): boolean => { + if (this.isExplicitIdentityEmailField(field)) { + return true; + } + if ( this.fieldContainsAutocompleteValues(field, this.emailAutocompleteValue) || - field.type === "email" + field.type === "email" || + field.htmlName === "email" ) { return true; } From d5e78e95499f1c8d058743e21e1b2faca35dcc3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Mon, 4 Aug 2025 15:45:17 -0400 Subject: [PATCH 04/24] [PM-24092] fix positional argument processing (#15756) --- apps/cli/src/tools/send/send.program.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/tools/send/send.program.ts b/apps/cli/src/tools/send/send.program.ts index 650f448e558..82699e273c3 100644 --- a/apps/cli/src/tools/send/send.program.ts +++ b/apps/cli/src/tools/send/send.program.ts @@ -149,11 +149,11 @@ export class SendProgram extends BaseProgram { private templateCommand(): Command { return new Command("template") - .argument("", "Valid objects are: send.text, send.file") + .argument("", "Valid objects are: send.text, text, send.file, file") .description("Get json templates for send objects") - .action((options: OptionValues) => - this.processResponse(new SendTemplateCommand().run(options.object)), - ); + .action((object: string) => { + this.processResponse(new SendTemplateCommand().run(object)); + }); } private getCommand(): Command { From 80b74b3300e15b4ae414dc06044cc9b02b6c10a6 Mon Sep 17 00:00:00 2001 From: Andrei Date: Mon, 4 Aug 2025 22:50:32 +0300 Subject: [PATCH 05/24] [PM-22472] break generated passphrase on separators (#15112) --- libs/components/src/color-password/color-password.component.ts | 2 +- .../components/src/credential-generator.component.html | 2 +- .../generator/components/src/password-generator.component.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/components/src/color-password/color-password.component.ts b/libs/components/src/color-password/color-password.component.ts index fb6f6568101..3a91330f316 100644 --- a/libs/components/src/color-password/color-password.component.ts +++ b/libs/components/src/color-password/color-password.component.ts @@ -44,7 +44,7 @@ export class ColorPasswordComponent { @HostBinding("class") get classList() { - return ["tw-min-w-0", "tw-whitespace-pre-wrap", "tw-break-all"]; + return ["tw-min-w-0", "tw-whitespace-pre-wrap", "tw-break-words"]; } getCharacterClass(character: string) { diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index 559554b7ddb..3f9813f4384 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -14,7 +14,7 @@ -
+
diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index cc5bdba6062..ce0768fe128 100644 --- a/libs/tools/generator/components/src/password-generator.component.html +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -11,7 +11,7 @@ -
+
From 920145b393c3ec55fbe6cfa587ad196532995052 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 5 Aug 2025 08:38:45 -0500 Subject: [PATCH 06/24] get updated cipher from `cipherViews$` on desktop. This avoids a race condition with waiting for the new cipher to be upserted in some cases. (#15859) --- .../src/vault/app/vault/vault-v2.component.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index 3c4a85f96b7..b408eb799fd 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -466,15 +466,24 @@ export class VaultV2Component return; } - const updatedCipher = await this.cipherService.get( - this.cipherId as CipherId, - this.activeUserId as UserId, - ); - const updatedCipherView = await this.cipherService.decrypt( - updatedCipher, - this.activeUserId as UserId, + // The encrypted state of ciphers is updated when an attachment is added, + // but the cache is also cleared. Depending on timing, `cipherService.get` can return the + // old cipher. Retrieve the updated cipher from `cipherViews$`, + // which refreshes after the cached is cleared. + const updatedCipherView = await firstValueFrom( + this.cipherService.cipherViews$(this.activeUserId!).pipe( + filter((c) => !!c), + map((ciphers) => ciphers.find((c) => c.id === this.cipherId)), + ), ); + // `find` can return undefined but that shouldn't happen as + // this would mean that the cipher was deleted. + // To make TypeScript happy, exit early if it isn't found. + if (!updatedCipherView) { + return; + } + this.cipherFormComponent.patchCipher((currentCipher) => { currentCipher.attachments = updatedCipherView.attachments; currentCipher.revisionDate = updatedCipherView.revisionDate; @@ -499,7 +508,6 @@ export class VaultV2Component if (cipher.decryptionFailure) { invokeMenu(menu); - return; } if (!cipher.isDeleted) { From 7145092889cf9808e26fa3ed2a69053176ca405b Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 5 Aug 2025 08:42:05 -0500 Subject: [PATCH 07/24] [PM-24119] Manually open extension message (#15827) * refactor manually open extension error message to a separate component * allow icons and max width to be updated via setAnonLayoutWrapperData * set error state when the extension fails to open * bump timeout to 2000ms. I was seeing false error states when attempting to open the extension * fix initialization of css variables --- .../browser-extension-prompt.component.html | 9 +----- .../browser-extension-prompt.component.ts | 6 ++-- .../manually-open-extension.component.html | 8 +++++ .../manually-open-extension.component.ts | 14 +++++++++ .../add-extension-videos.component.ts | 4 +-- .../setup-extension.component.html | 4 +++ .../setup-extension.component.spec.ts | 30 +++++++++++++++++-- .../setup-extension.component.ts | 24 +++++++++++++-- .../web-browser-interaction.service.ts | 2 +- .../anon-layout-wrapper.component.ts | 8 +++++ 10 files changed, 89 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html create mode 100644 apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html index 1c643fcc3e4..56332cc424b 100644 --- a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html @@ -26,14 +26,7 @@ -

- {{ "openExtensionManuallyPart1" | i18n }} - - {{ "openExtensionManuallyPart2" | i18n }} -

+
diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts index 624275a8297..177311cbfde 100644 --- a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts @@ -3,17 +3,17 @@ import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; import { ButtonComponent, IconModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; -import { VaultIcons } from "@bitwarden/vault"; import { BrowserExtensionPromptService, BrowserPromptState, } from "../../services/browser-extension-prompt.service"; +import { ManuallyOpenExtensionComponent } from "../manually-open-extension/manually-open-extension.component"; @Component({ selector: "vault-browser-extension-prompt", templateUrl: "./browser-extension-prompt.component.html", - imports: [CommonModule, I18nPipe, ButtonComponent, IconModule], + imports: [CommonModule, I18nPipe, ButtonComponent, IconModule, ManuallyOpenExtensionComponent], }) export class BrowserExtensionPromptComponent implements OnInit, OnDestroy { /** Current state of the prompt page */ @@ -22,8 +22,6 @@ export class BrowserExtensionPromptComponent implements OnInit, OnDestroy { /** All available page states */ protected BrowserPromptState = BrowserPromptState; - protected BitwardenIcon = VaultIcons.BitwardenIcon; - /** Content of the meta[name="viewport"] element */ private viewportContent: string | null = null; diff --git a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html new file mode 100644 index 00000000000..22c36e51177 --- /dev/null +++ b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html @@ -0,0 +1,8 @@ +

+ {{ "openExtensionManuallyPart1" | i18n }} + + {{ "openExtensionManuallyPart2" | i18n }} +

diff --git a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts new file mode 100644 index 00000000000..22041b61198 --- /dev/null +++ b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts @@ -0,0 +1,14 @@ +import { Component } from "@angular/core"; + +import { IconModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { VaultIcons } from "@bitwarden/vault"; + +@Component({ + selector: "vault-manually-open-extension", + templateUrl: "./manually-open-extension.component.html", + imports: [I18nPipe, IconModule], +}) +export class ManuallyOpenExtensionComponent { + protected BitwardenIcon = VaultIcons.BitwardenIcon; +} diff --git a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts index d053e05c36b..6bde812065b 100644 --- a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts +++ b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts @@ -34,8 +34,8 @@ export class AddExtensionVideosComponent { /** CSS classes for the video container, pulled into the class only for readability. */ protected videoContainerClass = [ "tw-absolute tw-left-0 tw-top-0 tw-w-[15rem] tw-opacity-0 md:tw-opacity-100 md:tw-relative lg:tw-w-[17rem] tw-max-w-full tw-aspect-[0.807]", - `[${this.cssOverlayVariable}:0.7] after:tw-absolute after:tw-top-0 after:tw-left-0 after:tw-size-full after:tw-bg-primary-100 after:tw-content-[''] after:tw-rounded-lg after:tw-opacity-[--overlay-opacity]`, - `[${this.cssBorderVariable}:0] before:tw-absolute before:tw-top-0 before:tw-left-0 before:tw-w-full before:tw-h-2 before:tw-bg-primary-600 before:tw-content-[''] before:tw-rounded-t-lg before:tw-opacity-[--border-opacity]`, + `[--overlay-opacity:0.7] after:tw-absolute after:tw-top-0 after:tw-left-0 after:tw-size-full after:tw-bg-primary-100 after:tw-content-[''] after:tw-rounded-lg after:tw-opacity-[--overlay-opacity]`, + `[--border-opacity:0] before:tw-absolute before:tw-top-0 before:tw-left-0 before:tw-w-full before:tw-h-2 before:tw-bg-primary-600 before:tw-content-[''] before:tw-rounded-t-lg before:tw-opacity-[--border-opacity]`, "after:tw-transition-opacity after:tw-duration-400 after:tw-ease-linear", "before:tw-transition-opacity before:tw-duration-400 before:tw-ease-linear", ].join(" "); diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html index c23fa0aac35..ac24383a4d3 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html @@ -54,3 +54,7 @@

+ +
+ +
diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts index e824cd92f37..8bb80e6fb44 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts @@ -1,4 +1,4 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; import { Router, RouterModule } from "@angular/router"; import { BehaviorSubject } from "rxjs"; @@ -11,10 +11,12 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { StateProvider } from "@bitwarden/common/platform/state"; +import { AnonLayoutWrapperDataService } from "@bitwarden/components"; +import { VaultIcons } from "@bitwarden/vault"; import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service"; -import { SetupExtensionComponent } from "./setup-extension.component"; +import { SetupExtensionComponent, SetupExtensionState } from "./setup-extension.component"; describe("SetupExtensionComponent", () => { let fixture: ComponentFixture; @@ -24,12 +26,14 @@ describe("SetupExtensionComponent", () => { const navigate = jest.fn().mockResolvedValue(true); const openExtension = jest.fn().mockResolvedValue(true); const update = jest.fn().mockResolvedValue(true); + const setAnonLayoutWrapperData = jest.fn(); const extensionInstalled$ = new BehaviorSubject(null); beforeEach(async () => { navigate.mockClear(); openExtension.mockClear(); update.mockClear(); + setAnonLayoutWrapperData.mockClear(); getFeatureFlag.mockClear().mockResolvedValue(true); window.matchMedia = jest.fn().mockReturnValue(false); @@ -40,6 +44,7 @@ describe("SetupExtensionComponent", () => { { provide: ConfigService, useValue: { getFeatureFlag } }, { provide: WebBrowserInteractionService, useValue: { extensionInstalled$, openExtension } }, { provide: PlatformUtilsService, useValue: { getDevice: () => DeviceType.UnknownBrowser } }, + { provide: AnonLayoutWrapperDataService, useValue: { setAnonLayoutWrapperData } }, { provide: AccountService, useValue: { activeAccount$: new BehaviorSubject({ account: { id: "account-id" } }) }, @@ -136,6 +141,27 @@ describe("SetupExtensionComponent", () => { it("dismisses the extension page", () => { expect(update).toHaveBeenCalledTimes(1); }); + + it("shows error state when extension fails to open", fakeAsync(() => { + openExtension.mockRejectedValueOnce(new Error("Failed to open extension")); + + const openExtensionButton = fixture.debugElement.query(By.css("button")); + + openExtensionButton.triggerEventHandler("click"); + + tick(); + + expect(component["state"]).toBe(SetupExtensionState.ManualOpen); + expect(setAnonLayoutWrapperData).toHaveBeenCalledWith({ + pageTitle: { + key: "somethingWentWrong", + }, + pageIcon: VaultIcons.BrowserExtensionIcon, + hideIcon: false, + hideCardWrapper: false, + maxWidth: "md", + }); + })); }); }); }); diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts index 14770ca5d6c..67d13ef1e4f 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts @@ -15,6 +15,7 @@ import { StateProvider } from "@bitwarden/common/platform/state"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { getWebStoreUrl } from "@bitwarden/common/vault/utils/get-web-store-url"; import { + AnonLayoutWrapperDataService, ButtonComponent, DialogRef, DialogService, @@ -25,6 +26,7 @@ import { VaultIcons } from "@bitwarden/vault"; import { SETUP_EXTENSION_DISMISSED } from "../../guards/setup-extension-redirect.guard"; import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service"; +import { ManuallyOpenExtensionComponent } from "../manually-open-extension/manually-open-extension.component"; import { AddExtensionLaterDialogComponent, @@ -32,10 +34,11 @@ import { } from "./add-extension-later-dialog.component"; import { AddExtensionVideosComponent } from "./add-extension-videos.component"; -const SetupExtensionState = { +export const SetupExtensionState = { Loading: "loading", NeedsExtension: "needs-extension", Success: "success", + ManualOpen: "manual-open", } as const; type SetupExtensionState = UnionOfValues; @@ -51,6 +54,7 @@ type SetupExtensionState = UnionOfValues; IconModule, RouterModule, AddExtensionVideosComponent, + ManuallyOpenExtensionComponent, ], }) export class SetupExtensionComponent implements OnInit, OnDestroy { @@ -63,6 +67,7 @@ export class SetupExtensionComponent implements OnInit, OnDestroy { private stateProvider = inject(StateProvider); private accountService = inject(AccountService); private document = inject(DOCUMENT); + private anonLayoutWrapperDataService = inject(AnonLayoutWrapperDataService); protected SetupExtensionState = SetupExtensionState; protected PartyIcon = VaultIcons.Party; @@ -153,8 +158,21 @@ export class SetupExtensionComponent implements OnInit, OnDestroy { } /** Opens the browser extension */ - openExtension() { - void this.webBrowserExtensionInteractionService.openExtension(); + async openExtension() { + await this.webBrowserExtensionInteractionService.openExtension().catch(() => { + this.state = SetupExtensionState.ManualOpen; + + // Update the anon layout data to show the proper error design + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageTitle: { + key: "somethingWentWrong", + }, + pageIcon: VaultIcons.BrowserExtensionIcon, + hideIcon: false, + hideCardWrapper: false, + maxWidth: "md", + }); + }); } /** Update local state to never show this page again. */ diff --git a/apps/web/src/app/vault/services/web-browser-interaction.service.ts b/apps/web/src/app/vault/services/web-browser-interaction.service.ts index 1f91942591b..ed5e2ef9948 100644 --- a/apps/web/src/app/vault/services/web-browser-interaction.service.ts +++ b/apps/web/src/app/vault/services/web-browser-interaction.service.ts @@ -25,7 +25,7 @@ import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum * used to allow for the extension to open and then emit to the message. * NOTE: This value isn't computed by any means, it is just a reasonable timeout for the extension to respond. */ -const OPEN_RESPONSE_TIMEOUT_MS = 1500; +const OPEN_RESPONSE_TIMEOUT_MS = 2000; /** * Timeout for checking if the extension is installed. diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts index 34fdc5b60fc..4b570df9814 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts @@ -157,6 +157,14 @@ export class AnonLayoutWrapperComponent implements OnInit { this.hideCardWrapper = data.hideCardWrapper; } + if (data.hideIcon !== undefined) { + this.hideIcon = data.hideIcon; + } + + if (data.maxWidth !== undefined) { + this.maxWidth = data.maxWidth; + } + // Manually fire change detection to avoid ExpressionChangedAfterItHasBeenCheckedError // when setting the page data from a service this.changeDetectorRef.detectChanges(); From 2a3e1ae1f5f970a756bd90a564fb9f1a5ceb2124 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Tue, 5 Aug 2025 09:25:50 -0500 Subject: [PATCH 08/24] [PM-23619] Remove getPrivateKey from the key service and update consumers (#15784) * remove getPrivateKey from keyService * Update consumer code * Increase unit test coverage --- .../services/emergency-access.service.spec.ts | 334 +++++++++++++++--- .../services/emergency-access.service.ts | 19 +- ...rgency-access-takeover-dialog.component.ts | 2 + .../view/emergency-access-view.component.ts | 6 +- .../src/abstractions/key.service.ts | 12 +- libs/key-management/src/key.service.spec.ts | 118 ++++--- libs/key-management/src/key.service.ts | 10 - 7 files changed, 358 insertions(+), 143 deletions(-) diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts index ff062b31e6b..2ff38f6eab0 100644 --- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts +++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { MockProxy } from "jest-mock-extended"; import mock from "jest-mock-extended/lib/Mock"; +import { of } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; @@ -14,9 +15,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "@bitwarden/common/types/csprng"; import { UserId } from "@bitwarden/common/types/guid"; -import { UserKey, MasterKey } from "@bitwarden/common/types/key"; +import { UserKey, MasterKey, UserPrivateKey } from "@bitwarden/common/types/key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { KdfType, KeyService } from "@bitwarden/key-management"; +import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management"; import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type"; import { EmergencyAccessType } from "../enums/emergency-access-type"; @@ -26,6 +27,7 @@ import { EmergencyAccessGranteeDetailsResponse, EmergencyAccessGrantorDetailsResponse, EmergencyAccessTakeoverResponse, + EmergencyAccessViewResponse, } from "../response/emergency-access.response"; import { EmergencyAccessApiService } from "./emergency-access-api.service"; @@ -142,88 +144,306 @@ describe("EmergencyAccessService", () => { }); }); + describe("getViewOnlyCiphers", () => { + const params = { + id: "emergency-access-id", + activeUserId: Utils.newGuid() as UserId, + }; + + it("throws an error is the active user's private key isn't available", async () => { + keyService.userPrivateKey$.mockReturnValue(of(null)); + + await expect( + emergencyAccessService.getViewOnlyCiphers(params.id, params.activeUserId), + ).rejects.toThrow("Active user does not have a private key, cannot get view only ciphers."); + }); + + it("should return decrypted and sorted ciphers", async () => { + const emergencyAccessViewResponse = { + keyEncrypted: "mockKeyEncrypted", + ciphers: [ + { id: "cipher1", name: "encryptedName1" }, + { id: "cipher2", name: "encryptedName2" }, + ], + } as EmergencyAccessViewResponse; + + const mockEncryptedCipher1 = { + id: "cipher1", + decrypt: jest.fn().mockResolvedValue({ id: "cipher1", decrypted: true }), + }; + const mockEncryptedCipher2 = { + id: "cipher2", + decrypt: jest.fn().mockResolvedValue({ id: "cipher2", decrypted: true }), + }; + emergencyAccessViewResponse.ciphers.map = jest.fn().mockImplementation(() => { + return [mockEncryptedCipher1, mockEncryptedCipher2]; + }); + cipherService.getLocaleSortingFunction.mockReturnValue((a: any, b: any) => + a.id.localeCompare(b.id), + ); + emergencyAccessApiService.postEmergencyAccessView.mockResolvedValue( + emergencyAccessViewResponse, + ); + + const mockPrivateKey = new Uint8Array(64) as UserPrivateKey; + keyService.userPrivateKey$.mockReturnValue(of(mockPrivateKey)); + + const mockDecryptedGrantorUserKey = new SymmetricCryptoKey(new Uint8Array(64)); + encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(mockDecryptedGrantorUserKey); + const mockGrantorUserKey = mockDecryptedGrantorUserKey as UserKey; + + const result = await emergencyAccessService.getViewOnlyCiphers( + params.id, + params.activeUserId, + ); + + expect(result).toEqual([ + { id: "cipher1", decrypted: true }, + { id: "cipher2", decrypted: true }, + ]); + expect(mockEncryptedCipher1.decrypt).toHaveBeenCalledWith(mockGrantorUserKey); + expect(mockEncryptedCipher2.decrypt).toHaveBeenCalledWith(mockGrantorUserKey); + expect(emergencyAccessApiService.postEmergencyAccessView).toHaveBeenCalledWith(params.id); + expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId); + expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith( + new EncString(emergencyAccessViewResponse.keyEncrypted), + mockPrivateKey, + ); + expect(cipherService.getLocaleSortingFunction).toHaveBeenCalled(); + }); + }); + describe("takeover", () => { - const mockId = "emergencyAccessId"; - const mockEmail = "emergencyAccessEmail"; - const mockName = "emergencyAccessName"; + const params = { + id: "emergencyAccessId", + masterPassword: "mockPassword", + email: "emergencyAccessEmail", + activeUserId: Utils.newGuid() as UserId, + }; + + const takeoverResponse = { + keyEncrypted: "EncryptedKey", + kdf: KdfType.PBKDF2_SHA256, + kdfIterations: 500, + } as EmergencyAccessTakeoverResponse; + + const userPrivateKey = new Uint8Array(64) as UserPrivateKey; + const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as MasterKey; + const mockMasterKeyHash = "mockMasterKeyHash"; + let mockGrantorUserKey: UserKey; + + // must mock [UserKey, EncString] return from keyService.encryptUserKeyWithMasterKey + // where UserKey is the decrypted grantor user key + const mockMasterKeyEncryptedUserKey = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "mockMasterKeyEncryptedUserKey", + ); + + beforeEach(() => { + emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(takeoverResponse); + keyService.userPrivateKey$.mockReturnValue(of(userPrivateKey)); + + const mockDecryptedGrantorUserKey = new SymmetricCryptoKey(new Uint8Array(64)); + encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(mockDecryptedGrantorUserKey); + mockGrantorUserKey = mockDecryptedGrantorUserKey as UserKey; + + keyService.makeMasterKey.mockResolvedValueOnce(mockMasterKey); + keyService.hashMasterKey.mockResolvedValueOnce(mockMasterKeyHash); + keyService.encryptUserKeyWithMasterKey.mockResolvedValueOnce([ + mockGrantorUserKey, + mockMasterKeyEncryptedUserKey, + ]); + }); it("posts a new password when decryption succeeds", async () => { // Arrange - emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce({ - keyEncrypted: "EncryptedKey", - kdf: KdfType.PBKDF2_SHA256, - kdfIterations: 500, - } as EmergencyAccessTakeoverResponse); - - const mockDecryptedGrantorUserKey = new Uint8Array(64); - keyService.getPrivateKey.mockResolvedValue(new Uint8Array(64)); - encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce( - new SymmetricCryptoKey(mockDecryptedGrantorUserKey), - ); - - const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as MasterKey; - - keyService.makeMasterKey.mockResolvedValueOnce(mockMasterKey); - - const mockMasterKeyHash = "mockMasterKeyHash"; - keyService.hashMasterKey.mockResolvedValueOnce(mockMasterKeyHash); - - // must mock [UserKey, EncString] return from keyService.encryptUserKeyWithMasterKey - // where UserKey is the decrypted grantor user key - const mockMasterKeyEncryptedUserKey = new EncString( - EncryptionType.AesCbc256_HmacSha256_B64, - "mockMasterKeyEncryptedUserKey", - ); - - const mockUserKey = new SymmetricCryptoKey(mockDecryptedGrantorUserKey) as UserKey; - - keyService.encryptUserKeyWithMasterKey.mockResolvedValueOnce([ - mockUserKey, - mockMasterKeyEncryptedUserKey, - ]); + const expectedKdfConfig = new PBKDF2KdfConfig(takeoverResponse.kdfIterations); const expectedEmergencyAccessPasswordRequest = new EmergencyAccessPasswordRequest(); expectedEmergencyAccessPasswordRequest.newMasterPasswordHash = mockMasterKeyHash; expectedEmergencyAccessPasswordRequest.key = mockMasterKeyEncryptedUserKey.encryptedString; // Act - await emergencyAccessService.takeover(mockId, mockEmail, mockName); + await emergencyAccessService.takeover( + params.id, + params.masterPassword, + params.email, + params.activeUserId, + ); // Assert + expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId); + expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith( + new EncString(takeoverResponse.keyEncrypted), + userPrivateKey, + ); + expect(keyService.makeMasterKey).toHaveBeenCalledWith( + params.masterPassword, + params.email, + expectedKdfConfig, + ); + expect(keyService.hashMasterKey).toHaveBeenCalledWith(params.masterPassword, mockMasterKey); + expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith( + mockMasterKey, + mockGrantorUserKey, + ); expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith( - mockId, + params.id, expectedEmergencyAccessPasswordRequest, ); }); - it("should not post a new password if decryption fails", async () => { - encryptService.rsaDecrypt.mockResolvedValueOnce(null); - emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce({ + it("uses argon2 KDF if takeover response is argon2", async () => { + const argon2TakeoverResponse = { keyEncrypted: "EncryptedKey", - kdf: KdfType.PBKDF2_SHA256, - kdfIterations: 500, - } as EmergencyAccessTakeoverResponse); - keyService.getPrivateKey.mockResolvedValue(new Uint8Array(64)); + kdf: KdfType.Argon2id, + kdfIterations: 3, + kdfMemory: 64, + kdfParallelism: 4, + } as EmergencyAccessTakeoverResponse; + emergencyAccessApiService.postEmergencyAccessTakeover.mockReset(); + emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce( + argon2TakeoverResponse, + ); + + const expectedKdfConfig = new Argon2KdfConfig( + argon2TakeoverResponse.kdfIterations, + argon2TakeoverResponse.kdfMemory, + argon2TakeoverResponse.kdfParallelism, + ); + + const expectedEmergencyAccessPasswordRequest = new EmergencyAccessPasswordRequest(); + expectedEmergencyAccessPasswordRequest.newMasterPasswordHash = mockMasterKeyHash; + expectedEmergencyAccessPasswordRequest.key = mockMasterKeyEncryptedUserKey.encryptedString; + + await emergencyAccessService.takeover( + params.id, + params.masterPassword, + params.email, + params.activeUserId, + ); + + expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId); + expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith( + new EncString(argon2TakeoverResponse.keyEncrypted), + userPrivateKey, + ); + expect(keyService.makeMasterKey).toHaveBeenCalledWith( + params.masterPassword, + params.email, + expectedKdfConfig, + ); + expect(keyService.hashMasterKey).toHaveBeenCalledWith(params.masterPassword, mockMasterKey); + expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith( + mockMasterKey, + mockGrantorUserKey, + ); + expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith( + params.id, + expectedEmergencyAccessPasswordRequest, + ); + }); + + it("throws an error if masterKeyEncryptedUserKey is not found", async () => { + keyService.encryptUserKeyWithMasterKey.mockReset(); + keyService.encryptUserKeyWithMasterKey.mockResolvedValueOnce(null); + const expectedKdfConfig = new PBKDF2KdfConfig(takeoverResponse.kdfIterations); await expect( - emergencyAccessService.takeover(mockId, mockEmail, mockName), - ).rejects.toThrowError("Failed to decrypt grantor key"); + emergencyAccessService.takeover( + params.id, + params.masterPassword, + params.email, + params.activeUserId, + ), + ).rejects.toThrow("masterKeyEncryptedUserKey not found"); + expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId); + expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith( + new EncString(takeoverResponse.keyEncrypted), + userPrivateKey, + ); + expect(keyService.makeMasterKey).toHaveBeenCalledWith( + params.masterPassword, + params.email, + expectedKdfConfig, + ); + expect(keyService.hashMasterKey).toHaveBeenCalledWith(params.masterPassword, mockMasterKey); + expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith( + mockMasterKey, + mockGrantorUserKey, + ); + expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled(); + }); + + it("should not post a new password if decryption fails", async () => { + emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(takeoverResponse); + encryptService.decapsulateKeyUnsigned.mockReset(); + encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(null); + + await expect( + emergencyAccessService.takeover( + params.id, + params.masterPassword, + params.email, + params.activeUserId, + ), + ).rejects.toThrow("Failed to decrypt grantor key"); + + expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId); + expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith( + new EncString(takeoverResponse.keyEncrypted), + userPrivateKey, + ); + expect(keyService.makeMasterKey).not.toHaveBeenCalled(); + expect(keyService.hashMasterKey).not.toHaveBeenCalled(); + expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled(); + expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled(); + }); + + it("should not post a new password if decryption throws", async () => { + encryptService.decapsulateKeyUnsigned.mockReset(); + encryptService.decapsulateKeyUnsigned.mockImplementationOnce(() => { + throw new Error("Failed to unwrap grantor key"); + }); + + await expect( + emergencyAccessService.takeover( + params.id, + params.masterPassword, + params.email, + params.activeUserId, + ), + ).rejects.toThrowError("Failed to unwrap grantor key"); + + expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId); + expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith( + new EncString(takeoverResponse.keyEncrypted), + userPrivateKey, + ); + expect(keyService.makeMasterKey).not.toHaveBeenCalled(); + expect(keyService.hashMasterKey).not.toHaveBeenCalled(); + expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled(); expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled(); }); it("should throw an error if the users private key cannot be retrieved", async () => { - emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce({ - keyEncrypted: "EncryptedKey", - kdf: KdfType.PBKDF2_SHA256, - kdfIterations: 500, - } as EmergencyAccessTakeoverResponse); - keyService.getPrivateKey.mockResolvedValue(null); + keyService.userPrivateKey$.mockReturnValue(of(null)); - await expect(emergencyAccessService.takeover(mockId, mockEmail, mockName)).rejects.toThrow( - "user does not have a private key", - ); + await expect( + emergencyAccessService.takeover( + params.id, + params.masterPassword, + params.email, + params.activeUserId, + ), + ).rejects.toThrow("user does not have a private key"); + expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId); + expect(encryptService.decapsulateKeyUnsigned).not.toHaveBeenCalled(); + expect(keyService.makeMasterKey).not.toHaveBeenCalled(); + expect(keyService.hashMasterKey).not.toHaveBeenCalled(); + expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled(); expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts index 9a31bd9c107..cce8d9345b2 100644 --- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts +++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts @@ -1,4 +1,5 @@ import { Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data"; @@ -237,11 +238,14 @@ export class EmergencyAccessService * Gets the grantor ciphers for an emergency access in view mode. * Intended for grantee. * @param id emergency access id + * @param activeUserId the user id of the active user */ - async getViewOnlyCiphers(id: string): Promise { + async getViewOnlyCiphers(id: string, activeUserId: UserId): Promise { const response = await this.emergencyAccessApiService.postEmergencyAccessView(id); - const activeUserPrivateKey = await this.keyService.getPrivateKey(); + const activeUserPrivateKey = await firstValueFrom( + this.keyService.userPrivateKey$(activeUserId), + ); if (activeUserPrivateKey == null) { throw new Error("Active user does not have a private key, cannot get view only ciphers."); @@ -264,11 +268,14 @@ export class EmergencyAccessService * @param id emergency access id * @param masterPassword new master password * @param email email address of grantee (must be consistent or login will fail) + * @param activeUserId the user id of the active user */ - async takeover(id: string, masterPassword: string, email: string) { + async takeover(id: string, masterPassword: string, email: string, activeUserId: UserId) { const takeoverResponse = await this.emergencyAccessApiService.postEmergencyAccessTakeover(id); - const activeUserPrivateKey = await this.keyService.getPrivateKey(); + const activeUserPrivateKey = await firstValueFrom( + this.keyService.userPrivateKey$(activeUserId), + ); if (activeUserPrivateKey == null) { throw new Error("Active user does not have a private key, cannot complete a takeover."); @@ -312,9 +319,7 @@ export class EmergencyAccessService request.newMasterPasswordHash = masterKeyHash; request.key = encKey[1].encryptedString; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.emergencyAccessApiService.postEmergencyAccessPassword(id, request); + await this.emergencyAccessApiService.postEmergencyAccessPassword(id, request); } private async getEmergencyAccessData(): Promise { diff --git a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts index 2619e6852b3..e5c21fb82b9 100644 --- a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts @@ -115,10 +115,12 @@ export class EmergencyAccessTakeoverDialogComponent implements OnInit { this.parentSubmittingBehaviorSubject.next(true); try { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); await this.emergencyAccessService.takeover( this.dialogData.emergencyAccessId, passwordInputResult.newPassword, this.dialogData.grantorEmail, + activeUserId, ); } catch (e) { this.logService.error(e); diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts index ce46e624972..250261fb0e7 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts @@ -2,6 +2,8 @@ import { Component, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { firstValueFrom } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { EmergencyAccessId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { DialogService } from "@bitwarden/components"; @@ -27,6 +29,7 @@ export class EmergencyAccessViewComponent implements OnInit { private route: ActivatedRoute, private emergencyAccessService: EmergencyAccessService, private dialogService: DialogService, + private accountService: AccountService, ) {} async ngOnInit() { @@ -37,7 +40,8 @@ export class EmergencyAccessViewComponent implements OnInit { } this.id = qParams.id; - this.ciphers = await this.emergencyAccessService.getViewOnlyCiphers(qParams.id); + const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + this.ciphers = await this.emergencyAccessService.getViewOnlyCiphers(qParams.id, userId); this.loaded = true; } diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index 12d0998a862..72a0ba2a038 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -294,16 +294,6 @@ export abstract class KeyService { * @param encPrivateKey An encrypted private key */ abstract setPrivateKey(encPrivateKey: string, userId: UserId): Promise; - /** - * Returns the private key from memory. If not available, decrypts it - * from storage and stores it in memory - * @returns The user's private key - * - * @throws Error when no active user - * - * @deprecated Use {@link userPrivateKey$} instead. - */ - abstract getPrivateKey(): Promise; /** * Gets an observable stream of the given users decrypted private key, will emit null if the user @@ -311,6 +301,8 @@ export abstract class KeyService { * encrypted private key at all. * * @param userId The user id of the user to get the data for. + * @returns An observable stream of the decrypted private key or null. + * @throws Error when decryption of the encrypted private key fails. */ abstract userPrivateKey$(userId: UserId): Observable; diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index 1a76803a085..13cd1a1cde4 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -494,77 +494,79 @@ describe("keyService", () => { }); describe("userPrivateKey$", () => { - type SetupKeysParams = { - makeMasterKey: boolean; - makeUserKey: boolean; - }; + let mockUserKey: UserKey; + let mockUserPrivateKey: Uint8Array; + let mockEncryptedPrivateKey: EncryptedString; - function setupKeys({ - makeMasterKey, - makeUserKey, - }: SetupKeysParams): [UserKey | null, MasterKey | null] { - const userKeyState = stateProvider.singleUser.getFake(mockUserId, USER_KEY); - const fakeMasterKey = makeMasterKey ? makeSymmetricCryptoKey(64) : null; - masterPasswordService.masterKeySubject.next(fakeMasterKey); - userKeyState.nextState(null); - const fakeUserKey = makeUserKey ? makeSymmetricCryptoKey(64) : null; - userKeyState.nextState(fakeUserKey); - return [fakeUserKey, fakeMasterKey]; - } + beforeEach(() => { + mockUserKey = makeSymmetricCryptoKey(64); + mockEncryptedPrivateKey = makeEncString("encryptedPrivateKey").encryptedString!; + mockUserPrivateKey = makeStaticByteArray(10, 1); + stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(mockUserKey); + stateProvider.singleUser + .getFake(mockUserId, USER_ENCRYPTED_PRIVATE_KEY) + .nextState(mockEncryptedPrivateKey); + encryptService.unwrapDecapsulationKey.mockResolvedValue(mockUserPrivateKey); + }); - it("will return users decrypted private key when user has a user key and encrypted private key set", async () => { - const [userKey] = setupKeys({ - makeMasterKey: false, - makeUserKey: true, + it("returns the unwrapped user private key when user key and encrypted private key are set", async () => { + const result = await firstValueFrom(keyService.userPrivateKey$(mockUserId)); + + expect(result).toEqual(mockUserPrivateKey); + expect(encryptService.unwrapDecapsulationKey).toHaveBeenCalledWith( + new EncString(mockEncryptedPrivateKey), + mockUserKey, + ); + }); + + it("throws an error if unwrapping encrypted private key fails", async () => { + encryptService.unwrapDecapsulationKey.mockImplementationOnce(() => { + throw new Error("Unwrapping failed"); }); - const userEncryptedPrivateKeyState = stateProvider.singleUser.getFake( - mockUserId, - USER_ENCRYPTED_PRIVATE_KEY, + await expect(firstValueFrom(keyService.userPrivateKey$(mockUserId))).rejects.toThrow( + "Unwrapping failed", ); - - const fakeEncryptedUserPrivateKey = makeEncString("1"); - - userEncryptedPrivateKeyState.nextState(fakeEncryptedUserPrivateKey.encryptedString!); - - // Decryption of the user private key - const fakeDecryptedUserPrivateKey = makeStaticByteArray(10, 1); - encryptService.unwrapDecapsulationKey.mockResolvedValue(fakeDecryptedUserPrivateKey); - - const fakeUserPublicKey = makeStaticByteArray(10, 2); - cryptoFunctionService.rsaExtractPublicKey.mockResolvedValue(fakeUserPublicKey); - - const userPrivateKey = await firstValueFrom(keyService.userPrivateKey$(mockUserId)); - - expect(encryptService.unwrapDecapsulationKey).toHaveBeenCalledWith( - fakeEncryptedUserPrivateKey, - userKey, - ); - - expect(userPrivateKey).toBe(fakeDecryptedUserPrivateKey); }); - it("returns null user private key when no user key is found", async () => { - setupKeys({ makeMasterKey: false, makeUserKey: false }); + it("returns null if user key is not set", async () => { + stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(null); - const userPrivateKey = await firstValueFrom(keyService.userPrivateKey$(mockUserId)); + const result = await firstValueFrom(keyService.userPrivateKey$(mockUserId)); + expect(result).toBeNull(); expect(encryptService.unwrapDecapsulationKey).not.toHaveBeenCalled(); - - expect(userPrivateKey).toBeFalsy(); }); - it("returns null when user does not have a private key set", async () => { - setupKeys({ makeUserKey: true, makeMasterKey: false }); + it("returns null if encrypted private key is not set", async () => { + stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_PRIVATE_KEY).nextState(null); - const encryptedUserPrivateKeyState = stateProvider.singleUser.getFake( - mockUserId, - USER_ENCRYPTED_PRIVATE_KEY, - ); - encryptedUserPrivateKeyState.nextState(null); + const result = await firstValueFrom(keyService.userPrivateKey$(mockUserId)); - const userPrivateKey = await firstValueFrom(keyService.userPrivateKey$(mockUserId)); - expect(userPrivateKey).toBeFalsy(); + expect(result).toBeNull(); + expect(encryptService.unwrapDecapsulationKey).not.toHaveBeenCalled(); + }); + + it("reacts to changes in user key or encrypted private key", async () => { + // Initial state: both set + let result = await firstValueFrom(keyService.userPrivateKey$(mockUserId)); + + expect(result).toEqual(mockUserPrivateKey); + + // Change user key to null + stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(null); + + result = await firstValueFrom(keyService.userPrivateKey$(mockUserId)); + + expect(result).toBeNull(); + + // Restore user key, remove encrypted private key + stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(mockUserKey); + stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_PRIVATE_KEY).nextState(null); + + result = await firstValueFrom(keyService.userPrivateKey$(mockUserId)); + + expect(result).toBeNull(); }); }); @@ -1063,7 +1065,7 @@ describe("keyService", () => { }); }); - describe("userPrivateKey$", () => { + describe("userEncryptionKeyPair$", () => { type SetupKeysParams = { makeMasterKey: boolean; makeUserKey: boolean; diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index 27bc0515a8d..042fa70d9c0 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -501,16 +501,6 @@ export class DefaultKeyService implements KeyServiceAbstraction { .update(() => encPrivateKey); } - async getPrivateKey(): Promise { - const activeUserId = await firstValueFrom(this.stateProvider.activeUserId$); - - if (activeUserId == null) { - throw new Error("User must be active while attempting to retrieve private key."); - } - - return await firstValueFrom(this.userPrivateKey$(activeUserId)); - } - // TODO: Make public key required async getFingerprint(fingerprintMaterial: string, publicKey?: Uint8Array): Promise { if (publicKey == null) { From 40a1a0a2b7621d0f2f15cecf0034402149a5401d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:34:17 +0100 Subject: [PATCH 09/24] [PM-22241] Add DefaultUserCollectionName support to bulk organization user confirmation (#15873) * Add bulk user confirmation method to OrganizationUserService * Update OrganizationUserBulkConfirmRequest to include optional defaultUserCollectionName property * Implement conditional bulk user confirmation logic in BulkConfirmDialogComponent. Its gated behind the feature flag for default user collection. * Refactor OrganizationUserBulkConfirmRequest to use SdkEncString for defaultUserCollectionName * Refactor BulkConfirmDialogComponent to use organization object instead of organizationId for improved clarity and type safety. * Add unit tests for OrganizationUserService to validate user single/bulk confirmation logic * Refactor OrganizationUserService to streamline encrypted collection name retrieval by introducing getEncryptedDefaultCollectionName$ method. * Refactor unit tests for OrganizationUserService to reduce duplication by introducing a setupCommonMocks function for common mock configurations. * refactor(organization-user.service): streamline retrieval of encrypted collection name in bulk confirmation process --- .../bulk/bulk-confirm-dialog.component.ts | 35 +++- .../members/members.component.ts | 2 +- .../organization-user.service.spec.ts | 175 ++++++++++++++++++ .../organization-user.service.ts | 36 +++- .../organization-user-bulk-confirm.request.ts | 6 +- 5 files changed, 237 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.spec.ts diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts index 4ec50799ae0..01b0d7bc380 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts @@ -11,10 +11,13 @@ import { OrganizationUserBulkResponse, } from "@bitwarden/admin-console/common"; import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { ProviderUserBulkPublicKeyResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk-public-key.response"; import { ProviderUserBulkResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { StateProvider } from "@bitwarden/common/platform/state"; @@ -23,11 +26,13 @@ import { OrgKey } from "@bitwarden/common/types/key"; import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { OrganizationUserService } from "../../services/organization-user/organization-user.service"; + import { BaseBulkConfirmComponent } from "./base-bulk-confirm.component"; import { BulkUserDetails } from "./bulk-status.component"; type BulkConfirmDialogParams = { - organizationId: string; + organization: Organization; users: BulkUserDetails[]; }; @@ -36,7 +41,7 @@ type BulkConfirmDialogParams = { standalone: false, }) export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent { - organizationId: string; + organization: Organization; organizationKey$: Observable; users: BulkUserDetails[]; @@ -47,13 +52,15 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent { private organizationUserApiService: OrganizationUserApiService, protected i18nService: I18nService, private stateProvider: StateProvider, + private organizationUserService: OrganizationUserService, + private configService: ConfigService, ) { super(keyService, encryptService, i18nService); - this.organizationId = dialogParams.organizationId; + this.organization = dialogParams.organization; this.organizationKey$ = this.stateProvider.activeUserId$.pipe( switchMap((userId) => this.keyService.orgKeys$(userId)), - map((organizationKeysById) => organizationKeysById[this.organizationId as OrganizationId]), + map((organizationKeysById) => organizationKeysById[this.organization.id as OrganizationId]), takeUntilDestroyed(), ); this.users = dialogParams.users; @@ -66,7 +73,7 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent { ListResponse > => await this.organizationUserApiService.postOrganizationUsersPublicKey( - this.organizationId, + this.organization.id, this.filteredUsers.map((user) => user.id), ); @@ -76,11 +83,19 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent { protected postConfirmRequest = async ( userIdsWithKeys: { id: string; key: string }[], ): Promise> => { - const request = new OrganizationUserBulkConfirmRequest(userIdsWithKeys); - return await this.organizationUserApiService.postOrganizationUserBulkConfirm( - this.organizationId, - request, - ); + if ( + await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation)) + ) { + return await firstValueFrom( + this.organizationUserService.bulkConfirmUsers(this.organization, userIdsWithKeys), + ); + } else { + const request = new OrganizationUserBulkConfirmRequest(userIdsWithKeys); + return await this.organizationUserApiService.postOrganizationUserBulkConfirm( + this.organization.id, + request, + ); + } }; static open(dialogService: DialogService, config: DialogConfig) { diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index a9cfd79ad60..2e663115819 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -721,7 +721,7 @@ export class MembersComponent extends BaseMembersComponent const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, { data: { - organizationId: this.organization.id, + organization: this.organization, users: this.dataSource.getCheckedUsers(), }, }); diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.spec.ts new file mode 100644 index 00000000000..2ae5aa4eb98 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.spec.ts @@ -0,0 +1,175 @@ +import { TestBed } from "@angular/core/testing"; +import { of } from "rxjs"; + +import { + OrganizationUserConfirmRequest, + OrganizationUserBulkConfirmRequest, + OrganizationUserApiService, + OrganizationUserBulkResponse, +} from "@bitwarden/admin-console/common"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { KeyService } from "@bitwarden/key-management"; + +import { OrganizationUserView } from "../../../core/views/organization-user.view"; + +import { OrganizationUserService } from "./organization-user.service"; + +describe("OrganizationUserService", () => { + let service: OrganizationUserService; + let keyService: jest.Mocked; + let encryptService: jest.Mocked; + let organizationUserApiService: jest.Mocked; + let accountService: jest.Mocked; + let i18nService: jest.Mocked; + + const mockOrganization = new Organization(); + mockOrganization.id = "org-123" as OrganizationId; + + const mockOrganizationUser = new OrganizationUserView(); + mockOrganizationUser.id = "user-123"; + + const mockPublicKey = new Uint8Array(64) as CsprngArray; + const mockRandomBytes = new Uint8Array(64) as CsprngArray; + const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey; + const mockEncryptedKey = { encryptedString: "encrypted-key" } as EncString; + const mockEncryptedCollectionName = { encryptedString: "encrypted-collection-name" } as EncString; + const mockDefaultCollectionName = "My Items"; + + const setupCommonMocks = () => { + keyService.orgKeys$.mockReturnValue( + of({ [mockOrganization.id]: mockOrgKey } as Record), + ); + encryptService.encryptString.mockResolvedValue(mockEncryptedCollectionName); + i18nService.t.mockReturnValue(mockDefaultCollectionName); + }; + + beforeEach(() => { + keyService = { + orgKeys$: jest.fn(), + } as any; + + encryptService = { + encryptString: jest.fn(), + encapsulateKeyUnsigned: jest.fn(), + } as any; + + organizationUserApiService = { + postOrganizationUserConfirm: jest.fn(), + postOrganizationUserBulkConfirm: jest.fn(), + } as any; + + accountService = { + activeAccount$: of({ id: "user-123" }), + } as any; + + i18nService = { + t: jest.fn(), + } as any; + + TestBed.configureTestingModule({ + providers: [ + OrganizationUserService, + { provide: KeyService, useValue: keyService }, + { provide: EncryptService, useValue: encryptService }, + { provide: OrganizationUserApiService, useValue: organizationUserApiService }, + { provide: AccountService, useValue: accountService }, + { provide: I18nService, useValue: i18nService }, + ], + }); + + service = TestBed.inject(OrganizationUserService); + }); + + describe("confirmUser", () => { + beforeEach(() => { + setupCommonMocks(); + encryptService.encapsulateKeyUnsigned.mockResolvedValue(mockEncryptedKey); + organizationUserApiService.postOrganizationUserConfirm.mockReturnValue(Promise.resolve()); + }); + + it("should confirm a user successfully", (done) => { + service.confirmUser(mockOrganization, mockOrganizationUser, mockPublicKey).subscribe({ + next: () => { + expect(i18nService.t).toHaveBeenCalledWith("myItems"); + + expect(encryptService.encryptString).toHaveBeenCalledWith( + mockDefaultCollectionName, + mockOrgKey, + ); + expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith( + mockOrgKey, + mockPublicKey, + ); + + expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith( + mockOrganization.id, + mockOrganizationUser.id, + { + key: mockEncryptedKey.encryptedString, + defaultUserCollectionName: mockEncryptedCollectionName.encryptedString, + } as OrganizationUserConfirmRequest, + ); + + done(); + }, + error: done, + }); + }); + }); + + describe("bulkConfirmUsers", () => { + const mockUserIdsWithKeys = [ + { id: "user-1", key: "key-1" }, + { id: "user-2", key: "key-2" }, + ]; + + const mockBulkResponse = { + data: [ + { id: "user-1", error: null } as OrganizationUserBulkResponse, + { id: "user-2", error: null } as OrganizationUserBulkResponse, + ], + } as ListResponse; + + beforeEach(() => { + setupCommonMocks(); + organizationUserApiService.postOrganizationUserBulkConfirm.mockReturnValue( + Promise.resolve(mockBulkResponse), + ); + }); + + it("should bulk confirm users successfully", (done) => { + service.bulkConfirmUsers(mockOrganization, mockUserIdsWithKeys).subscribe({ + next: (response) => { + expect(i18nService.t).toHaveBeenCalledWith("myItems"); + + expect(encryptService.encryptString).toHaveBeenCalledWith( + mockDefaultCollectionName, + mockOrgKey, + ); + + expect(organizationUserApiService.postOrganizationUserBulkConfirm).toHaveBeenCalledWith( + mockOrganization.id, + new OrganizationUserBulkConfirmRequest( + mockUserIdsWithKeys, + mockEncryptedCollectionName.encryptedString, + ), + ); + + expect(response).toEqual(mockBulkResponse); + + done(); + }, + error: done, + }); + }); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.ts index 79efeebca2a..f59b377e26e 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-user/organization-user.service.ts @@ -3,12 +3,15 @@ import { combineLatest, filter, map, Observable, switchMap } from "rxjs"; import { OrganizationUserConfirmRequest, + OrganizationUserBulkConfirmRequest, OrganizationUserApiService, + OrganizationUserBulkResponse, } from "@bitwarden/admin-console/common"; 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 { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; @@ -41,11 +44,7 @@ export class OrganizationUserService { user: OrganizationUserView, publicKey: Uint8Array, ): Observable { - const encryptedCollectionName$ = this.orgKey$(organization).pipe( - switchMap((orgKey) => - this.encryptService.encryptString(this.i18nService.t("myItems"), orgKey), - ), - ); + const encryptedCollectionName$ = this.getEncryptedDefaultCollectionName$(organization); const encryptedKey$ = this.orgKey$(organization).pipe( switchMap((orgKey) => this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey)), @@ -66,4 +65,31 @@ export class OrganizationUserService { }), ); } + + bulkConfirmUsers( + organization: Organization, + userIdsWithKeys: { id: string; key: string }[], + ): Observable> { + return this.getEncryptedDefaultCollectionName$(organization).pipe( + switchMap((collectionName) => { + const request = new OrganizationUserBulkConfirmRequest( + userIdsWithKeys, + collectionName.encryptedString, + ); + + return this.organizationUserApiService.postOrganizationUserBulkConfirm( + organization.id, + request, + ); + }), + ); + } + + private getEncryptedDefaultCollectionName$(organization: Organization) { + return this.orgKey$(organization).pipe( + switchMap((orgKey) => + this.encryptService.encryptString(this.i18nService.t("myItems"), orgKey), + ), + ); + } } diff --git a/libs/admin-console/src/common/organization-user/models/requests/organization-user-bulk-confirm.request.ts b/libs/admin-console/src/common/organization-user/models/requests/organization-user-bulk-confirm.request.ts index 35e05602838..4523c3afebc 100644 --- a/libs/admin-console/src/common/organization-user/models/requests/organization-user-bulk-confirm.request.ts +++ b/libs/admin-console/src/common/organization-user/models/requests/organization-user-bulk-confirm.request.ts @@ -1,3 +1,5 @@ +import { EncString as SdkEncString } from "@bitwarden/sdk-internal"; + type OrganizationUserBulkRequestEntry = { id: string; key: string; @@ -5,8 +7,10 @@ type OrganizationUserBulkRequestEntry = { export class OrganizationUserBulkConfirmRequest { keys: OrganizationUserBulkRequestEntry[]; + defaultUserCollectionName: SdkEncString | undefined; - constructor(keys: OrganizationUserBulkRequestEntry[]) { + constructor(keys: OrganizationUserBulkRequestEntry[], defaultUserCollectionName?: SdkEncString) { this.keys = keys; + this.defaultUserCollectionName = defaultUserCollectionName; } } From 26c0176e2e49c0c7ee8bf6fecd363bf4d35fdb21 Mon Sep 17 00:00:00 2001 From: Vicki League Date: Tue, 5 Aug 2025 10:58:49 -0400 Subject: [PATCH 10/24] [CL-712] Update icon button, components using it, and affected virtual scroll heights (#15683) --- .../current-account.component.html | 2 +- .../popup/layout/popup-header.component.html | 9 +- .../popup/layout/popup-layout.stories.ts | 4 +- .../organizations/manage/groups.component.ts | 4 +- .../members/members.component.ts | 4 +- .../app/settings/domain-rules.component.html | 1 - .../vault-items/vault-items.component.ts | 4 +- .../providers/manage/members.component.ts | 4 +- .../spotlight/spotlight.component.html | 3 +- .../src/async-actions/in-forms.stories.ts | 2 +- .../src/banner/banner.component.html | 5 +- .../chip-select/chip-select.component.html | 2 +- .../src/dialog/dialog.service.stories.ts | 5 +- .../src/dialog/dialog/dialog.component.html | 6 +- .../src/drawer/drawer-header.component.html | 4 +- .../src/form-field/form-field.component.html | 12 +- .../src/form-field/form-field.stories.ts | 13 +- .../icon-button/icon-button.component.html | 4 +- .../src/icon-button/icon-button.component.ts | 124 ++++-------------- .../src/icon-button/icon-button.mdx | 20 +-- .../src/icon-button/icon-button.stories.ts | 13 +- .../src/item/item-action.component.ts | 2 +- libs/components/src/item/item.component.html | 2 +- libs/components/src/item/item.stories.ts | 2 +- .../src/navigation/nav-group.component.html | 2 +- .../src/navigation/nav-item.component.html | 2 +- .../src/navigation/nav-item.stories.ts | 4 +- .../src/navigation/side-nav.component.html | 2 +- .../src/popover/popover.component.html | 5 +- .../components/src/popover/popover.stories.ts | 2 +- .../src/search/search.component.html | 4 +- .../dialog-virtual-scroll-block.component.ts | 2 +- libs/components/src/table/cell.directive.ts | 2 +- .../components/src/toast/toast.component.html | 1 + 34 files changed, 90 insertions(+), 187 deletions(-) diff --git a/apps/browser/src/auth/popup/account-switching/current-account.component.html b/apps/browser/src/auth/popup/account-switching/current-account.component.html index f59a2b08fdd..09342c58756 100644 --- a/apps/browser/src/auth/popup/account-switching/current-account.component.html +++ b/apps/browser/src/auth/popup/account-switching/current-account.component.html @@ -1,4 +1,4 @@ -
+
`, @@ -654,7 +654,7 @@ export const WithVirtualScrollChild: Story = { @defer (on immediate) { - + diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index e63b353be9c..96d274727dd 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -28,8 +28,8 @@ import { VaultItem } from "./vault-item"; import { VaultItemEvent } from "./vault-item-event"; // Fixed manual row height required due to how cdk-virtual-scroll works -export const RowHeight = 75.5; -export const RowHeightClass = `tw-h-[75.5px]`; +export const RowHeight = 75; +export const RowHeightClass = `tw-h-[75px]`; const MaxSelectionCount = 500; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts index 9cbe8115008..69d02214717 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts @@ -52,8 +52,8 @@ export class MembersComponent extends BaseMembersComponent { dataSource = new MembersTableDataSource(); loading = true; providerId: string; - rowHeight = 69; - rowHeightClass = `tw-h-[69px]`; + rowHeight = 70; + rowHeightClass = `tw-h-[70px]`; status: ProviderUserStatusType = null; userStatusType = ProviderUserStatusType; diff --git a/libs/angular/src/vault/components/spotlight/spotlight.component.html b/libs/angular/src/vault/components/spotlight/spotlight.component.html index 29d13d2056c..e445640cff9 100644 --- a/libs/angular/src/vault/components/spotlight/spotlight.component.html +++ b/libs/angular/src/vault/components/spotlight/spotlight.component.html @@ -1,5 +1,5 @@
@@ -20,6 +20,7 @@ (click)="handleDismiss()" [attr.title]="'close' | i18n" [attr.aria-label]="'close' | i18n" + class="-tw-me-2" >
diff --git a/libs/components/src/async-actions/in-forms.stories.ts b/libs/components/src/async-actions/in-forms.stories.ts index 7f51a8bdad2..dd901cd2477 100644 --- a/libs/components/src/async-actions/in-forms.stories.ts +++ b/libs/components/src/async-actions/in-forms.stories.ts @@ -35,7 +35,7 @@ const template = ` - + `; @Component({ diff --git a/libs/components/src/banner/banner.component.html b/libs/components/src/banner/banner.component.html index 63b1126104c..581a56d86cb 100644 --- a/libs/components/src/banner/banner.component.html +++ b/libs/components/src/banner/banner.component.html @@ -1,5 +1,5 @@
@if (showClose()) { - + `, }), @@ -369,9 +367,8 @@ export const DisabledButtonInputGroup: Story = { - + + `, }), @@ -387,9 +384,7 @@ export const PartiallyDisabledButtonInputGroup: Story = { - + `, }), diff --git a/libs/components/src/icon-button/icon-button.component.html b/libs/components/src/icon-button/icon-button.component.html index ad8e32dec75..e775a868871 100644 --- a/libs/components/src/icon-button/icon-button.component.html +++ b/libs/components/src/icon-button/icon-button.component.html @@ -1,5 +1,5 @@ - - + + = { contrast: [ - "tw-bg-transparent", "!tw-text-contrast", "tw-border-transparent", - "hover:tw-bg-transparent-hover", - "hover:tw-border-text-contrast", + "hover:!tw-bg-hover-contrast", "focus-visible:before:tw-ring-text-contrast", ...focusRing, ], - main: [ - "tw-bg-transparent", - "!tw-text-main", - "tw-border-transparent", - "hover:tw-bg-transparent-hover", - "hover:tw-border-primary-600", - "focus-visible:before:tw-ring-primary-600", - ...focusRing, - ], + main: ["!tw-text-main", "focus-visible:before:tw-ring-primary-600", ...focusRing], muted: [ - "tw-bg-transparent", "!tw-text-muted", "tw-border-transparent", "aria-expanded:tw-bg-text-muted", "aria-expanded:!tw-text-contrast", - "hover:tw-bg-transparent-hover", - "hover:tw-border-primary-600", "focus-visible:before:tw-ring-primary-600", "aria-expanded:hover:tw-bg-secondary-700", "aria-expanded:hover:tw-border-secondary-700", ...focusRing, ], - primary: [ - "tw-bg-primary-600", - "!tw-text-contrast", - "tw-border-primary-600", - "hover:tw-bg-primary-600", - "hover:tw-border-primary-600", - "focus-visible:before:tw-ring-primary-600", - ...focusRing, - ], - secondary: [ - "tw-bg-transparent", - "!tw-text-muted", - "tw-border-text-muted", - "hover:!tw-text-contrast", - "hover:tw-bg-text-muted", - "focus-visible:before:tw-ring-primary-600", - ...focusRing, - ], - danger: [ - "tw-bg-transparent", - "!tw-text-danger-600", - "tw-border-transparent", - "hover:!tw-text-danger-600", - "hover:tw-bg-transparent", - "hover:tw-border-primary-600", - "focus-visible:before:tw-ring-primary-600", - ...focusRing, - ], - light: [ - "tw-bg-transparent", + primary: ["!tw-text-primary-600", "focus-visible:before:tw-ring-primary-600", ...focusRing], + danger: ["!tw-text-danger-600", "focus-visible:before:tw-ring-primary-600", ...focusRing], + "nav-contrast": [ "!tw-text-alt2", - "tw-border-transparent", - "hover:tw-bg-transparent-hover", - "hover:tw-border-text-alt2", + "hover:!tw-bg-hover-contrast", "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", - ], - unstyled: [], }; export type IconButtonSize = "default" | "small"; const sizes: Record = { - default: ["tw-px-2.5", "tw-py-1.5"], - small: ["tw-leading-none", "tw-text-base", "tw-p-1"], + default: ["tw-text-xl", "tw-p-2.5", "tw-rounded-md"], + small: ["tw-text-base", "tw-p-2", "tw-rounded"], }; /** * Icon buttons are used when no text accompanies the button. It consists of an icon that may be updated to any icon in the `bwi-font`, a `title` attribute, and an `aria-label`. @@ -164,6 +80,13 @@ const sizes: Record = { imports: [NgClass], host: { "[attr.disabled]": "disabledAttr()", + /** + * When the `bitIconButton` input is dynamic from a consumer, Angular doesn't put the + * `bitIconButton` attribute into the DOM. We use the attribute as a css selector in + * a number of components, so this manual attr binding makes sure that the css selector + * works when the input is dynamic. + */ + "[attr.bitIconButton]": "icon()", }, }) export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement { @@ -176,17 +99,20 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE @HostBinding("class") get classList() { return [ "tw-font-semibold", - "tw-border", - "tw-border-solid", - "tw-rounded-lg", + "tw-leading-[0px]", + "tw-border-none", "tw-transition", + "tw-bg-transparent", "hover:tw-no-underline", + "hover:tw-bg-hover-default", "focus:tw-outline-none", ] .concat(styles[this.buttonType()]) .concat(sizes[this.size()]) .concat( - this.showDisabledStyles() || this.disabled() ? disabledStyles[this.buttonType()] : [], + this.showDisabledStyles() || this.disabled() + ? ["disabled:tw-opacity-60", "disabled:hover:!tw-bg-transparent"] + : [], ); } diff --git a/libs/components/src/icon-button/icon-button.mdx b/libs/components/src/icon-button/icon-button.mdx index 637a9d7daa0..3fcd4a23583 100644 --- a/libs/components/src/icon-button/icon-button.mdx +++ b/libs/components/src/icon-button/icon-button.mdx @@ -23,9 +23,6 @@ Icon buttons can be found in other components such as: the ## Styles -There are 4 common styles for button main, muted, contrast, and danger. The other styles follow the -button component styles. - ### Main Used for general icon buttons appearing on the theme’s main `background` @@ -59,22 +56,11 @@ square. -### Secondary +### Nav Contrast -Used in place of the main button component if no text is used. This allows the button to display -square. +Used on the side nav background that is dark in both light theme and dark theme. - - -### Light - -Used on a background that is dark in both light theme and dark theme. Example: end user navigation -styles. - - - -**Note:** Main and contrast styles appear on backgrounds where using `primary-700` as a focus -indicator does not meet WCAG graphic contrast guidelines. + ## Sizes diff --git a/libs/components/src/icon-button/icon-button.stories.ts b/libs/components/src/icon-button/icon-button.stories.ts index f63c494f7db..fdcda07f021 100644 --- a/libs/components/src/icon-button/icon-button.stories.ts +++ b/libs/components/src/icon-button/icon-button.stories.ts @@ -49,13 +49,6 @@ export const Primary: Story = { }, }; -export const Secondary: Story = { - ...Default, - args: { - buttonType: "secondary", - }, -}; - export const Danger: Story = { ...Default, args: { @@ -77,18 +70,18 @@ export const Muted: Story = { }, }; -export const Light: Story = { +export const NavContrast: Story = { render: (args) => ({ props: args, template: /*html*/ ` -
+
`, }), args: { - buttonType: "light", + buttonType: "nav-contrast", }, }; diff --git a/libs/components/src/item/item-action.component.ts b/libs/components/src/item/item-action.component.ts index c47ee8eea69..acbc805cf90 100644 --- a/libs/components/src/item/item-action.component.ts +++ b/libs/components/src/item/item-action.component.ts @@ -10,7 +10,7 @@ import { Component } from "@angular/core"; * `top` and `bottom` units should be kept in sync with `item-content.component.ts`'s y-axis padding. * we want this `:after` element to be the same height as the `item-content` */ - "[&>button]:tw-relative [&>button:not([bit-item-content])]:after:tw-content-[''] [&>button]:after:tw-absolute [&>button]:after:tw-block bit-compact:[&>button]:after:tw-top-[-0.7rem] bit-compact:[&>button]:after:tw-bottom-[-0.7rem] [&>button]:after:tw-top-[-0.8rem] [&>button]:after:tw-bottom-[-0.80rem] [&>button]:after:tw-right-[-0.25rem] [&>button]:after:tw-left-[-0.25rem]", + "[&>button]:tw-relative [&>button:not([bit-item-content])]:after:tw-content-[''] [&>button]:after:tw-absolute [&>button]:after:tw-block bit-compact:[&>button]:after:tw-top-[-0.7rem] bit-compact:[&>button]:after:tw-bottom-[-0.7rem] [&>button]:after:tw-top-[-0.8rem] [&>button]:after:tw-bottom-[-0.80rem] [&>button]:after:tw-right-0 [&>button]:after:tw-left-0", }, }) export class ItemActionComponent {} diff --git a/libs/components/src/item/item.component.html b/libs/components/src/item/item.component.html index 2863bb2891b..bdb8ee45d0a 100644 --- a/libs/components/src/item/item.component.html +++ b/libs/components/src/item/item.component.html @@ -4,7 +4,7 @@
diff --git a/libs/components/src/item/item.stories.ts b/libs/components/src/item/item.stories.ts index d23caa63370..6187266c40c 100644 --- a/libs/components/src/item/item.stories.ts +++ b/libs/components/src/item/item.stories.ts @@ -397,7 +397,7 @@ export const VirtualScrolling: Story = { data: Array.from(Array(100000).keys()), }, template: /*html*/ ` - + @@ -100,7 +100,7 @@ export const WithChildButtons: Story = { slot="end" class="tw-ms-auto" [bitIconButton]="'bwi-check'" - [buttonType]="'light'" + [buttonType]="'nav-contrast'" size="small" aria-label="option 3" > diff --git a/libs/components/src/navigation/side-nav.component.html b/libs/components/src/navigation/side-nav.component.html index 46ccc13bc8a..a5866b5e42e 100644 --- a/libs/components/src/navigation/side-nav.component.html +++ b/libs/components/src/navigation/side-nav.component.html @@ -34,7 +34,7 @@ type="button" class="tw-mx-auto tw-block tw-max-w-fit" [bitIconButton]="data.open ? 'bwi-angle-left' : 'bwi-angle-right'" - buttonType="light" + buttonType="nav-contrast" size="small" (click)="sideNavService.toggle()" [attr.aria-label]="'toggleSideNavigation' | i18n" diff --git a/libs/components/src/popover/popover.component.html b/libs/components/src/popover/popover.component.html index dfcf3ff5d01..03b6eaf77e3 100644 --- a/libs/components/src/popover/popover.component.html +++ b/libs/components/src/popover/popover.component.html @@ -4,8 +4,8 @@
-
-

+
+

{{ title() }}

diff --git a/libs/components/src/popover/popover.stories.ts b/libs/components/src/popover/popover.stories.ts index 100990decca..d1446f917f2 100644 --- a/libs/components/src/popover/popover.stories.ts +++ b/libs/components/src/popover/popover.stories.ts @@ -68,7 +68,7 @@ const popoverContent = /*html*/ `
  • Esse labore veniam tempora
  • Adipisicing elit ipsum iustolaborum
  • - + `; diff --git a/libs/components/src/search/search.component.html b/libs/components/src/search/search.component.html index b1b92fb151a..a20b077e71d 100644 --- a/libs/components/src/search/search.component.html +++ b/libs/components/src/search/search.component.html @@ -2,7 +2,7 @@ role="search" (mouseenter)="isFormHovered.set(true)" (mouseleave)="isFormHovered.set(false)" - class="tw-relative tw-flex tw-items-center tw-w-full" + class="tw-relative tw-flex tw-items-center tw-w-full tw-h-10" >
    From 5f5f771adbecf211a44987acf542175a81d90563 Mon Sep 17 00:00:00 2001 From: Bryan Cunningham Date: Tue, 5 Aug 2025 11:06:23 -0400 Subject: [PATCH 11/24] [CL-754] Fix shift when closing sidenav (#15849) * update shield logo and container padding * Fix horizontal icon shift * use absolutel position to fix shield shifting * add new shield and admin console logo * add new logos * add business unit portal logo * delete redundant logos * add missing fill color class --- .../admin-console/icons/admin-console-logo.ts | 27 --------------- .../icons/business-unit-portal-logo.icon.ts | 33 ------------------- .../icons/provider-portal-logo.ts | 29 ---------------- .../providers/providers-layout.component.ts | 9 +++-- libs/components/src/icon/index.ts | 8 ++++- .../src/icon/logos/bitwarden/admin-console.ts | 2 +- .../logos/bitwarden/business-unit-portal.ts | 7 ++++ .../src/icon/logos/bitwarden/index.ts | 2 ++ .../icon/logos/bitwarden/password-manager.ts | 2 +- .../icon/logos/bitwarden/provider-portal.ts | 7 ++++ .../icon/logos/bitwarden/secrets-manager.ts | 2 +- .../src/icon/logos/bitwarden/shield.ts | 2 +- .../src/navigation/nav-item.component.html | 8 ++--- .../src/navigation/nav-logo.component.html | 10 ++++-- 14 files changed, 44 insertions(+), 104 deletions(-) delete mode 100644 apps/web/src/app/admin-console/icons/admin-console-logo.ts delete mode 100644 apps/web/src/app/admin-console/icons/business-unit-portal-logo.icon.ts delete mode 100644 apps/web/src/app/admin-console/icons/provider-portal-logo.ts create mode 100644 libs/components/src/icon/logos/bitwarden/business-unit-portal.ts create mode 100644 libs/components/src/icon/logos/bitwarden/provider-portal.ts diff --git a/apps/web/src/app/admin-console/icons/admin-console-logo.ts b/apps/web/src/app/admin-console/icons/admin-console-logo.ts deleted file mode 100644 index 25b463217eb..00000000000 --- a/apps/web/src/app/admin-console/icons/admin-console-logo.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { svgIcon } from "@bitwarden/components"; - -export const AdminConsoleLogo = svgIcon` - - - - - - - - - - - - - - - - - - - - - - - -`; diff --git a/apps/web/src/app/admin-console/icons/business-unit-portal-logo.icon.ts b/apps/web/src/app/admin-console/icons/business-unit-portal-logo.icon.ts deleted file mode 100644 index f913ee68ef0..00000000000 --- a/apps/web/src/app/admin-console/icons/business-unit-portal-logo.icon.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { svgIcon } from "@bitwarden/components"; - -export const BusinessUnitPortalLogo = svgIcon` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -`; diff --git a/apps/web/src/app/admin-console/icons/provider-portal-logo.ts b/apps/web/src/app/admin-console/icons/provider-portal-logo.ts deleted file mode 100644 index bc77c3f7902..00000000000 --- a/apps/web/src/app/admin-console/icons/provider-portal-logo.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { svgIcon } from "@bitwarden/components"; - -export const ProviderPortalLogo = svgIcon` - - - - - - - - - - - - - - - - - - - - - - - - - -`; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts index 0fc49067740..52260168d4c 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts @@ -12,9 +12,12 @@ import { ProviderStatusType, ProviderType } from "@bitwarden/common/admin-consol import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { Icon, IconModule } from "@bitwarden/components"; -import { BusinessUnitPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/business-unit-portal-logo.icon"; -import { ProviderPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/provider-portal-logo"; +import { + Icon, + IconModule, + ProviderPortalLogo, + BusinessUnitPortalLogo, +} from "@bitwarden/components"; import { WebLayoutModule } from "@bitwarden/web-vault/app/layouts/web-layout.module"; import { ProviderWarningsService } from "../../billing/providers/services/provider-warnings.service"; diff --git a/libs/components/src/icon/index.ts b/libs/components/src/icon/index.ts index 4f797326be3..01819a3678e 100644 --- a/libs/components/src/icon/index.ts +++ b/libs/components/src/icon/index.ts @@ -1,4 +1,10 @@ export * from "./icon.module"; export * from "./icon"; export * as Icons from "./icons"; -export { AdminConsoleLogo, PasswordManagerLogo, SecretsManagerLogo } from "./logos"; +export { + AdminConsoleLogo, + BusinessUnitPortalLogo, + PasswordManagerLogo, + ProviderPortalLogo, + SecretsManagerLogo, +} from "./logos"; diff --git a/libs/components/src/icon/logos/bitwarden/admin-console.ts b/libs/components/src/icon/logos/bitwarden/admin-console.ts index 673cf9642c8..5fa96d91dfe 100644 --- a/libs/components/src/icon/logos/bitwarden/admin-console.ts +++ b/libs/components/src/icon/logos/bitwarden/admin-console.ts @@ -1,7 +1,7 @@ import { svgIcon } from "../../icon"; const AdminConsoleLogo = svgIcon` - + `; export default AdminConsoleLogo; diff --git a/libs/components/src/icon/logos/bitwarden/business-unit-portal.ts b/libs/components/src/icon/logos/bitwarden/business-unit-portal.ts new file mode 100644 index 00000000000..6206256f88b --- /dev/null +++ b/libs/components/src/icon/logos/bitwarden/business-unit-portal.ts @@ -0,0 +1,7 @@ +import { svgIcon } from "../../icon"; + +const BusinessUnitPortalLogo = svgIcon` + +`; + +export default BusinessUnitPortalLogo; diff --git a/libs/components/src/icon/logos/bitwarden/index.ts b/libs/components/src/icon/logos/bitwarden/index.ts index ba78bdb4fe5..d74c6fe453a 100644 --- a/libs/components/src/icon/logos/bitwarden/index.ts +++ b/libs/components/src/icon/logos/bitwarden/index.ts @@ -1,4 +1,6 @@ export { default as AdminConsoleLogo } from "./admin-console"; +export { default as BusinessUnitPortalLogo } from "./business-unit-portal"; export * from "./shield"; export { default as PasswordManagerLogo } from "./password-manager"; +export { default as ProviderPortalLogo } from "./provider-portal"; export { default as SecretsManagerLogo } from "./secrets-manager"; diff --git a/libs/components/src/icon/logos/bitwarden/password-manager.ts b/libs/components/src/icon/logos/bitwarden/password-manager.ts index 37b62c5dbc2..7586e28bfae 100644 --- a/libs/components/src/icon/logos/bitwarden/password-manager.ts +++ b/libs/components/src/icon/logos/bitwarden/password-manager.ts @@ -1,7 +1,7 @@ import { svgIcon } from "../../icon"; const PasswordManagerLogo = svgIcon` - + `; export default PasswordManagerLogo; diff --git a/libs/components/src/icon/logos/bitwarden/provider-portal.ts b/libs/components/src/icon/logos/bitwarden/provider-portal.ts new file mode 100644 index 00000000000..b9c4d76b0e3 --- /dev/null +++ b/libs/components/src/icon/logos/bitwarden/provider-portal.ts @@ -0,0 +1,7 @@ +import { svgIcon } from "../../icon"; + +const ProviderPortalLogo = svgIcon` + +`; + +export default ProviderPortalLogo; diff --git a/libs/components/src/icon/logos/bitwarden/secrets-manager.ts b/libs/components/src/icon/logos/bitwarden/secrets-manager.ts index bcba5f71d1a..45f3418b1e8 100644 --- a/libs/components/src/icon/logos/bitwarden/secrets-manager.ts +++ b/libs/components/src/icon/logos/bitwarden/secrets-manager.ts @@ -1,7 +1,7 @@ import { svgIcon } from "../../icon"; const SecretsManagerLogo = svgIcon` - + `; export default SecretsManagerLogo; diff --git a/libs/components/src/icon/logos/bitwarden/shield.ts b/libs/components/src/icon/logos/bitwarden/shield.ts index b942715bb6d..d736a44ba3e 100644 --- a/libs/components/src/icon/logos/bitwarden/shield.ts +++ b/libs/components/src/icon/logos/bitwarden/shield.ts @@ -10,7 +10,7 @@ const AnonLayoutBitwardenShield = svgIcon` `; const BitwardenShield = svgIcon` - + `; export { AnonLayoutBitwardenShield, BitwardenShield }; diff --git a/libs/components/src/navigation/nav-item.component.html b/libs/components/src/navigation/nav-item.component.html index d25f3adafb5..f7d55a33362 100644 --- a/libs/components/src/navigation/nav-item.component.html +++ b/libs/components/src/navigation/nav-item.component.html @@ -17,8 +17,8 @@
    + @if (isBreadcrumbingEnabled$ | async) { + + } - + @if (loading) { {{ "loading" | i18n }} - - - - - - - {{ - "on" | i18n - }} - {{ p.description | i18n }} - - - - + } + @if (!loading) { + + + @for (p of policies; track p.name) { + @if (p.display(organization, configService) | async) { + + + + @if (policiesEnabledMap.get(p.type)) { + {{ "on" | i18n }} + } + {{ p.description | i18n }} + + + } + } + + + } diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts index 8b6894871bd..3dfc4cc0c20 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts @@ -15,7 +15,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { DialogService } from "@bitwarden/components"; import { @@ -25,7 +24,7 @@ import { import { All } from "@bitwarden/web-vault/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model"; import { PolicyListService } from "../../core/policy-list.service"; -import { BasePolicy, RestrictedItemTypesPolicy } from "../policies"; +import { BasePolicy } from "../policies"; import { CollectionDialogTabType } from "../shared/components/collection-dialog"; import { PolicyEditComponent, PolicyEditDialogResult } from "./policy-edit.component"; @@ -53,7 +52,7 @@ export class PoliciesComponent implements OnInit { private policyListService: PolicyListService, private organizationBillingService: OrganizationBillingServiceAbstraction, private dialogService: DialogService, - private configService: ConfigService, + protected configService: ConfigService, ) {} async ngOnInit() { @@ -71,35 +70,31 @@ export class PoliciesComponent implements OnInit { await this.load(); // Handle policies component launch from Event message - /* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ - this.route.queryParams.pipe(first()).subscribe(async (qParams) => { - if (qParams.policyId != null) { - const policyIdFromEvents: string = qParams.policyId; - for (const orgPolicy of this.orgPolicies) { - if (orgPolicy.id === policyIdFromEvents) { - for (let i = 0; i < this.policies.length; i++) { - if (this.policies[i].type === orgPolicy.type) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.edit(this.policies[i]); - break; + this.route.queryParams + .pipe(first()) + /* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ + .subscribe(async (qParams) => { + if (qParams.policyId != null) { + const policyIdFromEvents: string = qParams.policyId; + for (const orgPolicy of this.orgPolicies) { + if (orgPolicy.id === policyIdFromEvents) { + for (let i = 0; i < this.policies.length; i++) { + if (this.policies[i].type === orgPolicy.type) { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.edit(this.policies[i]); + break; + } } + break; } - break; } } - } - }); + }); }); } async load() { - if ( - (await this.configService.getFeatureFlag(FeatureFlag.RemoveCardItemTypePolicy)) && - this.policyListService.getPolicies().every((p) => !(p instanceof RestrictedItemTypesPolicy)) - ) { - this.policyListService.addPolicies([new RestrictedItemTypesPolicy()]); - } const response = await this.policyApiService.getPolicies(this.organizationId); this.orgPolicies = response.data != null && response.data.length > 0 ? response.data : []; this.orgPolicies.forEach((op) => { diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.html index 7f33f08f888..90cfb52e5ad 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.html @@ -22,7 +22,9 @@ {{ "loading" | i18n }}
    -

    {{ policy.description | i18n }}

    + @if (policy.showDescription) { +

    {{ policy.description | i18n }}

    + }
    diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.ts index d3d03d2aaae..2984db67d39 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit.component.ts @@ -128,13 +128,20 @@ export class PolicyEditComponent implements AfterViewInit { } submit = async () => { + if ((await this.policyComponent.confirm()) == false) { + this.dialogRef.close(); + return; + } + let request: PolicyRequest; + try { request = await this.policyComponent.buildRequest(); } catch (e) { this.toastService.showToast({ variant: "error", title: null, message: e.message }); return; } + await this.policyApiService.putPolicy(this.data.organizationId, this.data.policy.type, request); this.toastService.showToast({ variant: "success", diff --git a/apps/web/src/app/admin-console/organizations/policies/require-sso.component.ts b/apps/web/src/app/admin-console/organizations/policies/require-sso.component.ts index 21de143dea6..3a0d196c593 100644 --- a/apps/web/src/app/admin-console/organizations/policies/require-sso.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/require-sso.component.ts @@ -1,7 +1,9 @@ import { Component } from "@angular/core"; +import { of } from "rxjs"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { BasePolicy, BasePolicyComponent } from "./base-policy.component"; @@ -11,8 +13,8 @@ export class RequireSsoPolicy extends BasePolicy { type = PolicyType.RequireSso; component = RequireSsoPolicyComponent; - display(organization: Organization) { - return organization.useSso; + display(organization: Organization, configService: ConfigService) { + return of(organization.useSso); } } diff --git a/apps/web/src/app/admin-console/organizations/policies/reset-password.component.ts b/apps/web/src/app/admin-console/organizations/policies/reset-password.component.ts index 62fc42f6a06..93a42285fbc 100644 --- a/apps/web/src/app/admin-console/organizations/policies/reset-password.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/reset-password.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit } from "@angular/core"; import { FormBuilder } from "@angular/forms"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, of } from "rxjs"; import { getOrganizationById, @@ -10,6 +10,7 @@ import { PolicyType } 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 { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { BasePolicy, BasePolicyComponent } from "./base-policy.component"; @@ -19,8 +20,8 @@ export class ResetPasswordPolicy extends BasePolicy { type = PolicyType.ResetPassword; component = ResetPasswordPolicyComponent; - display(organization: Organization) { - return organization.useResetPassword; + display(organization: Organization, configService: ConfigService) { + return of(organization.useResetPassword); } } @@ -52,6 +53,10 @@ export class ResetPasswordPolicyComponent extends BasePolicyComponent implements throw new Error("No user found."); } + if (!this.policyResponse) { + throw new Error("Policies not found"); + } + const organization = await firstValueFrom( this.organizationService .organizations$(userId) diff --git a/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts b/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts index 1bee5583718..6cad0fc0170 100644 --- a/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/restricted-item-types.component.ts @@ -1,6 +1,10 @@ import { Component } from "@angular/core"; +import { Observable } from "rxjs"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { BasePolicy, BasePolicyComponent } from "./base-policy.component"; @@ -9,6 +13,10 @@ export class RestrictedItemTypesPolicy extends BasePolicy { description = "restrictedItemTypePolicyDesc"; type = PolicyType.RestrictedItemTypes; component = RestrictedItemTypesPolicyComponent; + + display(organization: Organization, configService: ConfigService): Observable { + return configService.getFeatureFlag$(FeatureFlag.RemoveCardItemTypePolicy); + } } @Component({ diff --git a/apps/web/src/app/admin-console/organizations/policies/single-org.component.ts b/apps/web/src/app/admin-console/organizations/policies/single-org.component.ts index ad32b4218bc..613253ef8d9 100644 --- a/apps/web/src/app/admin-console/organizations/policies/single-org.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/single-org.component.ts @@ -20,6 +20,9 @@ export class SingleOrgPolicyComponent extends BasePolicyComponent implements OnI async ngOnInit() { super.ngOnInit(); + if (!this.policyResponse) { + throw new Error("Policies not found"); + } if (!this.policyResponse.canToggleState) { this.enabled.disable(); } diff --git a/apps/web/src/app/admin-console/organizations/policies/vnext-organization-data-ownership.component.html b/apps/web/src/app/admin-console/organizations/policies/vnext-organization-data-ownership.component.html new file mode 100644 index 00000000000..0abc40da683 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/vnext-organization-data-ownership.component.html @@ -0,0 +1,57 @@ +

    + {{ "organizationDataOwnershipContent" | i18n }} + + {{ "organizationDataOwnershipContentAnchor" | i18n }}. + +

    + + + + {{ "turnOn" | i18n }} + + + + + {{ "organizationDataOwnershipWarningTitle" | i18n }} + +
    + {{ "organizationDataOwnershipWarningContentTop" | i18n }} +
    +
      +
    • + {{ "organizationDataOwnershipWarning1" | i18n }} +
    • +
    • + {{ "organizationDataOwnershipWarning2" | i18n }} +
    • +
    • + {{ "organizationDataOwnershipWarning3" | i18n }} +
    • +
    +
    + {{ "organizationDataOwnershipWarningContentBottom" | i18n }} + + {{ "organizationDataOwnershipContentAnchor" | i18n }}. + +
    +
    + + + + + + +
    +
    diff --git a/apps/web/src/app/admin-console/organizations/policies/vnext-organization-data-ownership.component.ts b/apps/web/src/app/admin-console/organizations/policies/vnext-organization-data-ownership.component.ts new file mode 100644 index 00000000000..11b1548d9f9 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/vnext-organization-data-ownership.component.ts @@ -0,0 +1,50 @@ +import { Component, OnInit, TemplateRef, ViewChild } from "@angular/core"; +import { lastValueFrom, Observable } from "rxjs"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { DialogService } from "@bitwarden/components"; + +import { SharedModule } from "../../../shared"; + +import { BasePolicy, BasePolicyComponent } from "./base-policy.component"; + +export class vNextOrganizationDataOwnershipPolicy extends BasePolicy { + name = "organizationDataOwnership"; + description = "organizationDataOwnershipDesc"; + type = PolicyType.OrganizationDataOwnership; + component = vNextOrganizationDataOwnershipPolicyComponent; + showDescription = false; + + override display(organization: Organization, configService: ConfigService): Observable { + return configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation); + } +} + +@Component({ + selector: "vnext-policy-organization-data-ownership", + templateUrl: "vnext-organization-data-ownership.component.html", + standalone: true, + imports: [SharedModule], +}) +export class vNextOrganizationDataOwnershipPolicyComponent + extends BasePolicyComponent + implements OnInit +{ + constructor(private dialogService: DialogService) { + super(); + } + + @ViewChild("dialog", { static: true }) warningContent!: TemplateRef; + + override async confirm(): Promise { + if (this.policyResponse?.enabled && !this.enabled.value) { + const dialogRef = this.dialogService.open(this.warningContent); + const result = await lastValueFrom(dialogRef.closed); + return Boolean(result); + } + return true; + } +} diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index ceb2c788e75..694d0c6eb9a 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -35,12 +35,14 @@ import { MasterPasswordPolicy, PasswordGeneratorPolicy, OrganizationDataOwnershipPolicy, + vNextOrganizationDataOwnershipPolicy, RequireSsoPolicy, ResetPasswordPolicy, SendOptionsPolicy, SingleOrgPolicy, TwoFactorAuthenticationPolicy, RemoveUnlockWithPinPolicy, + RestrictedItemTypesPolicy, } from "./admin-console/organizations/policies"; const BroadcasterSubscriptionId = "AppComponent"; @@ -244,8 +246,10 @@ export class AppComponent implements OnDestroy, OnInit { new SingleOrgPolicy(), new RequireSsoPolicy(), new OrganizationDataOwnershipPolicy(), + new vNextOrganizationDataOwnershipPolicy(), new DisableSendPolicy(), new SendOptionsPolicy(), + new RestrictedItemTypesPolicy(), ]); } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 62f73fd4935..edcc153bcf4 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -5429,6 +5429,37 @@ "organizationDataOwnership": { "message": "Enforce organization data ownership" }, + "organizationDataOwnershipDesc": { + "message": "Require all items to be owned by an organization, removing the option to store items at the account level.", + "description": "This is the policy description shown in the policy list." + }, + "organizationDataOwnershipContent": { + "message": "All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection be available for each member to store items. Learn more about managing the ", + "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection be available for each member to store items. Learn more about managing the credential lifecycle.'" + }, + "organizationDataOwnershipContentAnchor":{ + "message": "credential lifecycle", + "description": "This will be used as a hyperlink" + }, + "organizationDataOwnershipWarningTitle":{ + "message": "Are you sure you want to proceed?" + }, + "organizationDataOwnershipWarning1":{ + "message": "will remain accessible to members" + }, + "organizationDataOwnershipWarning2":{ + "message": "will not be automatically selected when creating new items" + }, + "organizationDataOwnershipWarning3":{ + "message": "cannot be managed from the Admin Console until the user is offboarded" + }, + "organizationDataOwnershipWarningContentTop":{ + "message": "By turning this policy off, the default collection: " + }, + "organizationDataOwnershipWarningContentBottom":{ + "message": "Learn more about the ", + "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Learn more about the credential lifecycle.'" + }, "personalOwnership": { "message": "Remove individual vault" }, diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/activate-autofill.component.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/activate-autofill.component.ts index 61e2133d059..821509b43e2 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/policies/activate-autofill.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/policies/activate-autofill.component.ts @@ -1,7 +1,9 @@ import { Component } from "@angular/core"; +import { of } from "rxjs"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { BasePolicy, BasePolicyComponent, @@ -13,8 +15,8 @@ export class ActivateAutofillPolicy extends BasePolicy { type = PolicyType.ActivateAutofill; component = ActivateAutofillPolicyComponent; - display(organization: Organization) { - return organization.useActivateAutofillPolicy; + display(organization: Organization, configService: ConfigService) { + return of(organization.useActivateAutofillPolicy); } } diff --git a/libs/common/src/admin-console/models/request/policy.request.ts b/libs/common/src/admin-console/models/request/policy.request.ts index 0f3b1be7d88..7b2e4f76063 100644 --- a/libs/common/src/admin-console/models/request/policy.request.ts +++ b/libs/common/src/admin-console/models/request/policy.request.ts @@ -1,9 +1,7 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { PolicyType } from "../../enums"; -export class PolicyRequest { +export type PolicyRequest = { type: PolicyType; enabled: boolean; data: any; -} +}; From 8980016d2d9dd0f4cc06654dd8a88abc1177d19f Mon Sep 17 00:00:00 2001 From: Ketan Mehta <45426198+ketanMehtaa@users.noreply.github.com> Date: Wed, 6 Aug 2025 19:15:38 +0530 Subject: [PATCH 19/24] [PM-23378] clear selection after event on (#15465) * clear selection after event on individual part * added changes in org * added clearSelection in refresh() --------- Co-authored-by: Jason Ng --- .../organizations/collections/vault.component.html | 1 + .../organizations/collections/vault.component.ts | 6 +++++- .../vault/components/vault-items/vault-items.component.ts | 4 ++++ .../web/src/app/vault/individual-vault/vault.component.html | 1 + apps/web/src/app/vault/individual-vault/vault.component.ts | 3 +++ 5 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.html b/apps/web/src/app/admin-console/organizations/collections/vault.component.html index ddfcda04c76..1122f10e8f7 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.html +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.html @@ -84,6 +84,7 @@ {{ trashCleanupWarning }} (0); private vaultItemDialogRef?: DialogRef | undefined; + @ViewChild("vaultItems", { static: false }) vaultItemsComponent: VaultItemsComponent; + private readonly unpaidSubscriptionDialog$ = this.accountService.activeAccount$.pipe( map((account) => account?.id), switchMap((id) => @@ -1430,6 +1433,7 @@ export class VaultComponent implements OnInit, OnDestroy { private refresh() { this.refresh$.next(); + this.vaultItemsComponent?.clearSelection(); } private go(queryParams: any = null) { diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 96d274727dd..a8dd0056806 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -166,6 +166,10 @@ export class VaultItemsComponent { ); } + clearSelection() { + this.selection.clear(); + } + get showExtraColumn() { return this.showCollections || this.showGroups || this.showOwner; } diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index c20209a0192..35b1a1876a1 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -37,6 +37,7 @@ {{ trashCleanupWarning }} implements OnInit, OnDestroy { @ViewChild("vaultFilter", { static: true }) filterComponent: VaultFilterComponent; + @ViewChild("vaultItems", { static: false }) vaultItemsComponent: VaultItemsComponent; trashCleanupWarning: string = null; kdfIterations: number; @@ -1281,6 +1283,7 @@ export class VaultComponent implements OnInit, OnDestr private refresh() { this.refresh$.next(); + this.vaultItemsComponent?.clearSelection(); } private async go(queryParams: any = null) { From 55464a0fc9e4566465234183897ea4325b4e823c Mon Sep 17 00:00:00 2001 From: Daniel Riera Date: Wed, 6 Aug 2025 09:49:23 -0400 Subject: [PATCH 20/24] PM-24242 Add IDs to AtRisk Notification for automation (#15865) * PM-24242 * fix ts issue in storybook --- .../autofill/content/components/buttons/action-button.ts | 3 +++ .../at-risk-notification/container.lit-stories.ts | 8 ++++++-- .../components/notification/at-risk-password/container.ts | 4 +++- .../components/notification/at-risk-password/footer.ts | 1 + apps/browser/src/autofill/notification/bar.ts | 3 ++- 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/browser/src/autofill/content/components/buttons/action-button.ts b/apps/browser/src/autofill/content/components/buttons/action-button.ts index 339b628875c..b43bed7f96b 100644 --- a/apps/browser/src/autofill/content/components/buttons/action-button.ts +++ b/apps/browser/src/autofill/content/components/buttons/action-button.ts @@ -8,6 +8,7 @@ import { Spinner } from "../icons"; export type ActionButtonProps = { buttonText: string | TemplateResult; + dataTestId?: string; disabled?: boolean; isLoading?: boolean; theme: Theme; @@ -17,6 +18,7 @@ export type ActionButtonProps = { export function ActionButton({ buttonText, + dataTestId, disabled = false, isLoading = false, theme, @@ -32,6 +34,7 @@ export function ActionButton({ return html`