From 02ea368446b736ca3c891c521c296b40694d9c1b Mon Sep 17 00:00:00 2001 From: Alec Rippberger <127791530+alec-livefront@users.noreply.github.com> Date: Fri, 22 Nov 2024 12:55:26 -0600 Subject: [PATCH 01/75] [PM-4816] Create shared LoginApprovalComponent (#11982) * Stub out dialog * Genericize LoginApprovalComponent * update ipc mocks * Remove changes to account component * Remove changes to account component * Remove debug * Remove test component * Remove added translations * Fix failing test * Run lint and prettier * Rename LoginApprovalServiceAbstraction to LoginApprovalComponentServiceAbstraction * Add back missing "isVisible" check before calling loginRequest * Rename classes to contain "Component" in the name * Add missing space between "login attempt" and fingerprint phrase * Require email --- apps/desktop/src/app/app.component.ts | 3 +- .../src/app/services/services.module.ts | 7 + ...p-login-approval-component.service.spec.ts | 89 +++++++++++++ ...esktop-login-approval-component.service.ts | 26 ++++ libs/auth/src/angular/index.ts | 4 + ...t-login-approval-component.service.spec.ts | 25 ++++ ...efault-login-approval-component.service.ts | 16 +++ .../login-approval.component.html | 2 +- .../login-approval.component.spec.ts | 122 ++++++++++++++++++ .../login-approval.component.ts | 15 +-- libs/auth/src/common/abstractions/index.ts | 1 + ...-approval-component.service.abstraction.ts | 9 ++ 12 files changed, 307 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/auth/login/desktop-login-approval-component.service.spec.ts create mode 100644 apps/desktop/src/auth/login/desktop-login-approval-component.service.ts create mode 100644 libs/auth/src/angular/login-approval/default-login-approval-component.service.spec.ts create mode 100644 libs/auth/src/angular/login-approval/default-login-approval-component.service.ts rename {apps/desktop/src/auth/login => libs/auth/src/angular/login-approval}/login-approval.component.html (93%) create mode 100644 libs/auth/src/angular/login-approval/login-approval.component.spec.ts rename {apps/desktop/src/auth/login => libs/auth/src/angular/login-approval}/login-approval.component.ts (94%) create mode 100644 libs/auth/src/common/abstractions/login-approval-component.service.abstraction.ts diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index e8a9e96924..7840c1dd40 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -25,7 +25,7 @@ import { import { CollectionService } from "@bitwarden/admin-console/common"; import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { FingerprintDialogComponent } from "@bitwarden/auth/angular"; +import { FingerprintDialogComponent, LoginApprovalComponent } from "@bitwarden/auth/angular"; import { LogoutReason } from "@bitwarden/auth/common"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; @@ -67,7 +67,6 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac import { KeyService, BiometricStateService } from "@bitwarden/key-management"; import { DeleteAccountComponent } from "../auth/delete-account.component"; -import { LoginApprovalComponent } from "../auth/login/login-approval.component"; import { MenuAccount, MenuUpdateRequest } from "../main/menu/menu.updater"; import { flagEnabled } from "../platform/flags"; import { PremiumComponent } from "../vault/app/accounts/premium.component"; diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 2a11c78a57..62fc93ae0b 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -26,6 +26,7 @@ import { } from "@bitwarden/auth/angular"; import { InternalUserDecryptionOptionsServiceAbstraction, + LoginApprovalComponentServiceAbstraction, LoginEmailService, PinServiceAbstraction, } from "@bitwarden/auth/common"; @@ -87,6 +88,7 @@ import { BiometricsService, } from "@bitwarden/key-management"; +import { DesktopLoginApprovalComponentService } from "../../auth/login/desktop-login-approval-component.service"; import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service"; import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service"; import { ElectronBiometricsService } from "../../key-management/biometrics/electron-biometrics.service"; @@ -349,6 +351,11 @@ const safeProviders: SafeProvider[] = [ useClass: LoginEmailService, deps: [AccountService, AuthService, StateProvider], }), + safeProvider({ + provide: LoginApprovalComponentServiceAbstraction, + useClass: DesktopLoginApprovalComponentService, + deps: [I18nServiceAbstraction], + }), ]; @NgModule({ diff --git a/apps/desktop/src/auth/login/desktop-login-approval-component.service.spec.ts b/apps/desktop/src/auth/login/desktop-login-approval-component.service.spec.ts new file mode 100644 index 0000000000..d687ae3574 --- /dev/null +++ b/apps/desktop/src/auth/login/desktop-login-approval-component.service.spec.ts @@ -0,0 +1,89 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { Subject } from "rxjs"; + +import { LoginApprovalComponent } from "@bitwarden/auth/angular"; +import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { DesktopLoginApprovalComponentService } from "./desktop-login-approval-component.service"; + +describe("DesktopLoginApprovalComponentService", () => { + let service: DesktopLoginApprovalComponentService; + let i18nService: MockProxy; + let originalIpc: any; + + beforeEach(() => { + originalIpc = (global as any).ipc; + (global as any).ipc = { + auth: { + loginRequest: jest.fn(), + }, + platform: { + isWindowVisible: jest.fn(), + }, + }; + + i18nService = mock({ + t: jest.fn(), + userSetLocale$: new Subject(), + locale$: new Subject(), + }); + + TestBed.configureTestingModule({ + providers: [ + DesktopLoginApprovalComponentService, + { provide: I18nServiceAbstraction, useValue: i18nService }, + ], + }); + + service = TestBed.inject(DesktopLoginApprovalComponentService); + }); + + afterEach(() => { + jest.clearAllMocks(); + (global as any).ipc = originalIpc; + }); + + it("is created successfully", () => { + expect(service).toBeTruthy(); + }); + + it("calls ipc.auth.loginRequest with correct parameters when window is not visible", async () => { + const title = "Log in requested"; + const email = "test@bitwarden.com"; + const message = `Confirm login attempt for ${email}`; + const closeText = "Close"; + + const loginApprovalComponent = { email } as LoginApprovalComponent; + i18nService.t.mockImplementation((key: string) => { + switch (key) { + case "logInRequested": + return title; + case "confirmLoginAtemptForMail": + return message; + case "close": + return closeText; + default: + return ""; + } + }); + + jest.spyOn(ipc.platform, "isWindowVisible").mockResolvedValue(false); + jest.spyOn(ipc.auth, "loginRequest").mockResolvedValue(); + + await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalComponent.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; + + jest.spyOn(ipc.platform, "isWindowVisible").mockResolvedValue(true); + jest.spyOn(ipc.auth, "loginRequest"); + + await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalComponent.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-component.service.ts new file mode 100644 index 0000000000..3e658f9ba0 --- /dev/null +++ b/apps/desktop/src/auth/login/desktop-login-approval-component.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from "@angular/core"; + +import { DefaultLoginApprovalComponentService } from "@bitwarden/auth/angular"; +import { LoginApprovalComponentServiceAbstraction } from "@bitwarden/auth/common"; +import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; + +@Injectable() +export class DesktopLoginApprovalComponentService + extends DefaultLoginApprovalComponentService + implements LoginApprovalComponentServiceAbstraction +{ + constructor(private i18nService: I18nServiceAbstraction) { + super(); + } + + async showLoginRequestedAlertIfWindowNotVisible(email: string): Promise { + const isVisible = await ipc.platform.isWindowVisible(); + if (!isVisible) { + await ipc.auth.loginRequest( + this.i18nService.t("logInRequested"), + this.i18nService.t("confirmLoginAtemptForMail", email), + this.i18nService.t("close"), + ); + } + } +} diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index 16ae77e937..a01b8849c8 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -66,3 +66,7 @@ export * from "./vault-timeout-input/vault-timeout-input.component"; // self hosted environment configuration dialog export * from "./self-hosted-env-config-dialog/self-hosted-env-config-dialog.component"; + +// login approval +export * from "./login-approval/login-approval.component"; +export * from "./login-approval/default-login-approval-component.service"; diff --git a/libs/auth/src/angular/login-approval/default-login-approval-component.service.spec.ts b/libs/auth/src/angular/login-approval/default-login-approval-component.service.spec.ts new file mode 100644 index 0000000000..ec274fac8b --- /dev/null +++ b/libs/auth/src/angular/login-approval/default-login-approval-component.service.spec.ts @@ -0,0 +1,25 @@ +import { TestBed } from "@angular/core/testing"; + +import { DefaultLoginApprovalComponentService } from "./default-login-approval-component.service"; +import { LoginApprovalComponent } from "./login-approval.component"; + +describe("DefaultLoginApprovalComponentService", () => { + let service: DefaultLoginApprovalComponentService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [DefaultLoginApprovalComponentService], + }); + + service = TestBed.inject(DefaultLoginApprovalComponentService); + }); + + it("is created successfully", () => { + expect(service).toBeTruthy(); + }); + + it("has showLoginRequestedAlertIfWindowNotVisible method that is a no-op", async () => { + const loginApprovalComponent = {} as LoginApprovalComponent; + await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalComponent.email); + }); +}); diff --git a/libs/auth/src/angular/login-approval/default-login-approval-component.service.ts b/libs/auth/src/angular/login-approval/default-login-approval-component.service.ts new file mode 100644 index 0000000000..8b0463be6c --- /dev/null +++ b/libs/auth/src/angular/login-approval/default-login-approval-component.service.ts @@ -0,0 +1,16 @@ +import { LoginApprovalComponentServiceAbstraction } from "../../common/abstractions/login-approval-component.service.abstraction"; + +/** + * Default implementation of the LoginApprovalComponentServiceAbstraction. + */ +export class DefaultLoginApprovalComponentService + implements LoginApprovalComponentServiceAbstraction +{ + /** + * No-op implementation of the showLoginRequestedAlertIfWindowNotVisible method. + * @returns + */ + async showLoginRequestedAlertIfWindowNotVisible(email?: string): Promise { + return; + } +} diff --git a/apps/desktop/src/auth/login/login-approval.component.html b/libs/auth/src/angular/login-approval/login-approval.component.html similarity index 93% rename from apps/desktop/src/auth/login/login-approval.component.html rename to libs/auth/src/angular/login-approval/login-approval.component.html index cc2c0536c9..ddbc48d71a 100644 --- a/apps/desktop/src/auth/login/login-approval.component.html +++ b/libs/auth/src/angular/login-approval/login-approval.component.html @@ -1,7 +1,7 @@ {{ "areYouTryingtoLogin" | i18n }} -

{{ "logInAttemptBy" | i18n: email }}

+

{{ "logInAttemptBy" | i18n: email }}

{{ "fingerprintPhraseHeader" | i18n }}

{{ fingerprintPhrase }}

diff --git a/libs/auth/src/angular/login-approval/login-approval.component.spec.ts b/libs/auth/src/angular/login-approval/login-approval.component.spec.ts new file mode 100644 index 0000000000..ff598bdeb9 --- /dev/null +++ b/libs/auth/src/angular/login-approval/login-approval.component.spec.ts @@ -0,0 +1,122 @@ +import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { + AuthRequestServiceAbstraction, + LoginApprovalComponentServiceAbstraction, +} from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; +import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { ToastService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; + +import { LoginApprovalComponent } from "./login-approval.component"; + +describe("LoginApprovalComponent", () => { + let component: LoginApprovalComponent; + let fixture: ComponentFixture; + + let authRequestService: MockProxy; + let accountService: MockProxy; + let apiService: MockProxy; + let i18nService: MockProxy; + let dialogRef: MockProxy; + let toastService: MockProxy; + + const testNotificationId = "test-notification-id"; + const testEmail = "test@bitwarden.com"; + const testPublicKey = "test-public-key"; + + beforeEach(async () => { + authRequestService = mock(); + accountService = mock(); + apiService = mock(); + i18nService = mock(); + dialogRef = mock(); + toastService = mock(); + + accountService.activeAccount$ = of({ + email: testEmail, + id: "test-user-id" as UserId, + emailVerified: true, + name: null, + }); + + await TestBed.configureTestingModule({ + imports: [LoginApprovalComponent], + providers: [ + { provide: DIALOG_DATA, useValue: { notificationId: testNotificationId } }, + { provide: AuthRequestServiceAbstraction, useValue: authRequestService }, + { provide: AccountService, useValue: accountService }, + { provide: PlatformUtilsService, useValue: mock() }, + { provide: I18nService, useValue: i18nService }, + { provide: ApiService, useValue: apiService }, + { provide: AppIdService, useValue: mock() }, + { provide: KeyService, useValue: mock() }, + { provide: DialogRef, useValue: dialogRef }, + { provide: ToastService, useValue: toastService }, + { + provide: LoginApprovalComponentServiceAbstraction, + useValue: mock(), + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(LoginApprovalComponent); + component = fixture.componentInstance; + }); + + it("creates successfully", () => { + expect(component).toBeTruthy(); + }); + + describe("ngOnInit", () => { + beforeEach(() => { + apiService.getAuthRequest.mockResolvedValue({ + publicKey: testPublicKey, + creationDate: new Date().toISOString(), + } as AuthRequestResponse); + authRequestService.getFingerprintPhrase.mockResolvedValue("test-phrase"); + }); + + it("retrieves and sets auth request data", async () => { + await component.ngOnInit(); + + expect(apiService.getAuthRequest).toHaveBeenCalledWith(testNotificationId); + expect(component.email).toBe(testEmail); + expect(component.fingerprintPhrase).toBeDefined(); + }); + + it("updates time text initially", async () => { + i18nService.t.mockReturnValue("justNow"); + + await component.ngOnInit(); + expect(component.requestTimeText).toBe("justNow"); + }); + }); + + describe("denyLogin", () => { + it("denies auth request and shows info toast", async () => { + const response = { requestApproved: false } as AuthRequestResponse; + apiService.getAuthRequest.mockResolvedValue(response); + authRequestService.approveOrDenyAuthRequest.mockResolvedValue(response); + i18nService.t.mockReturnValue("denied message"); + + await component.denyLogin(); + + expect(authRequestService.approveOrDenyAuthRequest).toHaveBeenCalledWith(false, response); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "info", + title: null, + message: "denied message", + }); + }); + }); +}); diff --git a/apps/desktop/src/auth/login/login-approval.component.ts b/libs/auth/src/angular/login-approval/login-approval.component.ts similarity index 94% rename from apps/desktop/src/auth/login/login-approval.component.ts rename to libs/auth/src/angular/login-approval/login-approval.component.ts index e6428e0020..9dff4d3e27 100644 --- a/apps/desktop/src/auth/login/login-approval.component.ts +++ b/libs/auth/src/angular/login-approval/login-approval.component.ts @@ -4,7 +4,10 @@ import { Component, OnInit, OnDestroy, Inject } from "@angular/core"; import { Subject, firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; +import { + AuthRequestServiceAbstraction, + LoginApprovalComponentServiceAbstraction as LoginApprovalComponentService, +} from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; @@ -56,6 +59,7 @@ export class LoginApprovalComponent implements OnInit, OnDestroy { protected keyService: KeyService, private dialogRef: DialogRef, private toastService: ToastService, + private loginApprovalComponentService: LoginApprovalComponentService, ) { this.notificationId = params.notificationId; } @@ -89,14 +93,7 @@ export class LoginApprovalComponent implements OnInit, OnDestroy { this.updateTimeText(); }, RequestTimeUpdate); - const isVisible = await ipc.platform.isWindowVisible(); - if (!isVisible) { - await ipc.auth.loginRequest( - this.i18nService.t("logInRequested"), - this.i18nService.t("confirmLoginAtemptForMail", this.email), - this.i18nService.t("close"), - ); - } + this.loginApprovalComponentService.showLoginRequestedAlertIfWindowNotVisible(this.email); } } diff --git a/libs/auth/src/common/abstractions/index.ts b/libs/auth/src/common/abstractions/index.ts index 093d703b74..88a13b490d 100644 --- a/libs/auth/src/common/abstractions/index.ts +++ b/libs/auth/src/common/abstractions/index.ts @@ -4,3 +4,4 @@ export * from "./login-email.service"; export * from "./login-strategy.service"; export * from "./user-decryption-options.service.abstraction"; export * from "./auth-request.service.abstraction"; +export * from "./login-approval-component.service.abstraction"; diff --git a/libs/auth/src/common/abstractions/login-approval-component.service.abstraction.ts b/libs/auth/src/common/abstractions/login-approval-component.service.abstraction.ts new file mode 100644 index 0000000000..eaa6235980 --- /dev/null +++ b/libs/auth/src/common/abstractions/login-approval-component.service.abstraction.ts @@ -0,0 +1,9 @@ +/** + * Abstraction for the LoginApprovalComponent service. + */ +export abstract class LoginApprovalComponentServiceAbstraction { + /** + * Shows a login requested alert if the window is not visible. + */ + abstract showLoginRequestedAlertIfWindowNotVisible: (email?: string) => Promise; +} From 04caec6f69835a2d3015a74897818936c469df87 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Fri, 22 Nov 2024 14:07:13 -0500 Subject: [PATCH 02/75] [PM-15187] Do not use innerHTML (#12108) * do not use innerHTML * remove unused catalog message --- apps/browser/src/_locales/en/messages.json | 17 ++++------------- .../popup/settings/autofill.component.html | 5 ++++- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 5200cf81d0..316bb23c4f 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1125,6 +1125,10 @@ "message": "WARNING", "description": "WARNING (should stay in capitalized letters if the language permits)" }, + "warningCapitalized": { + "message": "Warning", + "description": "Warning (should maintain locale-relevant capitalization)" + }, "confirmVaultExport": { "message": "Confirm vault export" }, @@ -1503,19 +1507,6 @@ "enableAutoFillOnPageLoadDesc": { "message": "If a login form is detected, autofill when the web page loads." }, - "autofillOnPageLoadWarning": { - "message": "$OPENTAG$Warning:$CLOSETAG$ Compromised or untrusted websites can exploit autofill on page load.", - "placeholders": { - "openTag": { - "content": "$1", - "example": "" - }, - "closeTag": { - "content": "$2", - "example": "" - } - } - }, "experimentalFeature": { "message": "Compromised or untrusted websites can exploit autofill on page load." }, diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.html b/apps/browser/src/autofill/popup/settings/autofill.component.html index e9c9fd9c75..18c6f51533 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.html +++ b/apps/browser/src/autofill/popup/settings/autofill.component.html @@ -160,7 +160,10 @@ {{ "enableAutoFillOnPageLoadDesc" | i18n }} - + {{ "warningCapitalized" | i18n }}: {{ "experimentalFeature" | i18n }} Date: Fri, 22 Nov 2024 11:57:24 -0800 Subject: [PATCH 03/75] remove check for apps (#12079) --- .../app/tools/access-intelligence/risk-insights.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/tools/access-intelligence/risk-insights.component.html b/apps/web/src/app/tools/access-intelligence/risk-insights.component.html index 067207160d..6df47e3c46 100644 --- a/apps/web/src/app/tools/access-intelligence/risk-insights.component.html +++ b/apps/web/src/app/tools/access-intelligence/risk-insights.component.html @@ -4,7 +4,7 @@ {{ "reviewAtRiskPasswords" | i18n }}  {{ "learnMore" | i18n }}
-
+
{{ "dataLastUpdated" | i18n: (dataLastUpdated | date: "MMMM d, y 'at' h:mm a") From 2ee14ba5ceb16e86faa0d55658bb86165382a98a Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Fri, 22 Nov 2024 20:59:05 +0100 Subject: [PATCH 04/75] Add translation to navButton.title (#12110) Co-authored-by: Daniel James Smith --- .../platform/popup/layout/popup-tab-navigation.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html index 78b859f33b..a4ae3161b4 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html @@ -9,7 +9,7 @@
- {{ "accountIsManagedMessage" | i18n: managingOrganization?.name }} + {{ "accountIsOwnedMessage" | i18n: managingOrganization?.name }} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 0c1558a35d..eb98a1d757 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1785,8 +1785,8 @@ "sessionsDeauthorized": { "message": "All sessions deauthorized" }, - "accountIsManagedMessage": { - "message": "This account is managed by $ORGANIZATIONNAME$", + "accountIsOwnedMessage": { + "message": "This account is owned by $ORGANIZATIONNAME$", "placeholders": { "organizationName": { "content": "$1", From e938bb10875e4d2bef02b22e7eefa4a49bbe53e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 27 Nov 2024 15:51:40 +0000 Subject: [PATCH 42/75] [PM-15386] Refactor DomainVerificationComponent to warn about enabling SingleOrg policy only when it is not already enabled (#12156) --- .../domain-verification.component.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts index 987888741a..9c0bae1052 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts @@ -14,6 +14,8 @@ import { import { OrgDomainApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization-domain/org-domain-api.service.abstraction"; import { OrgDomainServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization-domain/org-domain.service.abstraction"; import { OrganizationDomainResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/organization-domain.response"; +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { HttpStatusCode } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; @@ -33,6 +35,7 @@ import { }) export class DomainVerificationComponent implements OnInit, OnDestroy { private componentDestroyed$ = new Subject(); + private singleOrgPolicyEnabled = false; loading = true; @@ -48,6 +51,7 @@ export class DomainVerificationComponent implements OnInit, OnDestroy { private validationService: ValidationService, private toastService: ToastService, private configService: ConfigService, + private policyApiService: PolicyApiServiceAbstraction, ) {} // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -71,6 +75,14 @@ export class DomainVerificationComponent implements OnInit, OnDestroy { async load() { await this.orgDomainApiService.getAllByOrgId(this.organizationId); + if (await this.configService.getFeatureFlag(FeatureFlag.AccountDeprovisioning)) { + const singleOrgPolicy = await this.policyApiService.getPolicy( + this.organizationId, + PolicyType.SingleOrg, + ); + this.singleOrgPolicyEnabled = singleOrgPolicy?.enabled ?? false; + } + this.loading = false; } @@ -87,6 +99,7 @@ export class DomainVerificationComponent implements OnInit, OnDestroy { map(async ([accountDeprovisioningEnabled, organizationDomains]) => { if ( accountDeprovisioningEnabled && + !this.singleOrgPolicyEnabled && organizationDomains.every((domain) => domain.verifiedDate === null) ) { await this.dialogService.openSimpleDialog({ From 276785192551992cc6fbb8729ecb3603f7fd198b Mon Sep 17 00:00:00 2001 From: Merissa Weinstein Date: Wed, 27 Nov 2024 10:29:17 -0600 Subject: [PATCH 43/75] PM-15390 | update margin top in the filter list component (#12157) * update margin top in the filter list component * remove surrounding padding and increase margin --- .../vault-v2/vault-header/vault-header-v2.component.html | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html index 05deeec0d3..5f958433c6 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html @@ -32,7 +32,5 @@ [open]="initialDisclosureVisibility$ | async" (openChange)="toggleFilters($event)" > -
- -
+ From f79141c4213a4a205399e7ada1e1cbd5ddf45f36 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 27 Nov 2024 08:29:36 -0800 Subject: [PATCH 44/75] [PM-14990] Add password prompt for ssh key import (#12105) * Add password prompt for ssh key import * Remove empty line * Convert to switch statement --- apps/desktop/src/locales/en/messages.json | 19 ++++- .../src/vault/app/vault/add-edit.component.ts | 80 ++++++++++++------- libs/importer/src/components/dialog/index.ts | 1 + .../sshkey-password-prompt.component.html | 31 +++++++ .../sshkey-password-prompt.component.ts | 46 +++++++++++ 5 files changed, 147 insertions(+), 30 deletions(-) create mode 100644 libs/importer/src/components/dialog/sshkey-password-prompt.component.html create mode 100644 libs/importer/src/components/dialog/sshkey-password-prompt.component.ts diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 837535ddb0..e9bebb8bfc 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -207,6 +207,21 @@ "sshKeyGenerated": { "message": "A new SSH key was generated" }, + "sshKeyWrongPassword": { + "message": "The password you entered is incorrect." + }, + "importSshKey": { + "message": "Import" + }, + "confirmSshKeyPassword": { + "message": "Confirm password" + }, + "enterSshKeyPasswordDesc": { + "message": "Enter the password for the SSH key." + }, + "enterSshKeyPassword": { + "message": "Enter password" + }, "sshAgentUnlockRequired": { "message": "Please unlock your vault to approve the SSH key request." }, @@ -1752,10 +1767,10 @@ "deleteAccountWarning": { "message": "Deleting your account is permanent. It cannot be undone." }, - "cannotDeleteAccount":{ + "cannotDeleteAccount": { "message": "Cannot delete account" }, - "cannotDeleteAccountDesc":{ + "cannotDeleteAccountDesc": { "message": "This action cannot be completed because your account is owned by an organization. Contact your organization administrator for additional details." }, "accountDeleted": { diff --git a/apps/desktop/src/vault/app/vault/add-edit.component.ts b/apps/desktop/src/vault/app/vault/add-edit.component.ts index 015a7c6b21..6a3ad8d62e 100644 --- a/apps/desktop/src/vault/app/vault/add-edit.component.ts +++ b/apps/desktop/src/vault/app/vault/add-edit.component.ts @@ -2,6 +2,7 @@ import { DatePipe } from "@angular/common"; import { Component, NgZone, OnChanges, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { NgForm } from "@angular/forms"; import { sshagent as sshAgent } from "desktop_native/napi"; +import { lastValueFrom } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component"; @@ -22,6 +23,7 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService, ToastService } from "@bitwarden/components"; +import { SshKeyPasswordPromptComponent } from "@bitwarden/importer/ui"; import { PasswordRepromptService } from "@bitwarden/vault"; const BroadcasterSubscriptionId = "AddEditComponent"; @@ -170,42 +172,64 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On } } - async importSshKeyFromClipboard() { + async importSshKeyFromClipboard(password: string = "") { const key = await this.platformUtilsService.readFromClipboard(); - const parsedKey = await ipc.platform.sshAgent.importKey(key, ""); - if (parsedKey == null || parsedKey.status === sshAgent.SshKeyImportStatus.ParsingError) { + const parsedKey = await ipc.platform.sshAgent.importKey(key, password); + if (parsedKey == null) { this.toastService.showToast({ variant: "error", title: "", message: this.i18nService.t("invalidSshKey"), }); return; - } else if (parsedKey.status === sshAgent.SshKeyImportStatus.UnsupportedKeyType) { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("sshKeyTypeUnsupported"), - }); - } else if ( - parsedKey.status === sshAgent.SshKeyImportStatus.PasswordRequired || - parsedKey.status === sshAgent.SshKeyImportStatus.WrongPassword - ) { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("sshKeyPasswordUnsupported"), - }); - return; - } else { - this.cipher.sshKey.privateKey = parsedKey.sshKey.privateKey; - this.cipher.sshKey.publicKey = parsedKey.sshKey.publicKey; - this.cipher.sshKey.keyFingerprint = parsedKey.sshKey.keyFingerprint; - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("sshKeyPasted"), - }); } + + switch (parsedKey.status) { + case sshAgent.SshKeyImportStatus.ParsingError: + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("invalidSshKey"), + }); + return; + case sshAgent.SshKeyImportStatus.UnsupportedKeyType: + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("sshKeyTypeUnsupported"), + }); + return; + case sshAgent.SshKeyImportStatus.PasswordRequired: + case sshAgent.SshKeyImportStatus.WrongPassword: + if (password !== "") { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("sshKeyWrongPassword"), + }); + } else { + password = await this.getSshKeyPassword(); + await this.importSshKeyFromClipboard(password); + } + return; + default: + this.cipher.sshKey.privateKey = parsedKey.sshKey.privateKey; + this.cipher.sshKey.publicKey = parsedKey.sshKey.publicKey; + this.cipher.sshKey.keyFingerprint = parsedKey.sshKey.keyFingerprint; + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("sshKeyPasted"), + }); + } + } + + async getSshKeyPassword(): Promise { + const dialog = this.dialogService.open(SshKeyPasswordPromptComponent, { + ariaModal: true, + }); + + return await lastValueFrom(dialog.closed); } async typeChange() { diff --git a/libs/importer/src/components/dialog/index.ts b/libs/importer/src/components/dialog/index.ts index 641cd6600a..a115426eea 100644 --- a/libs/importer/src/components/dialog/index.ts +++ b/libs/importer/src/components/dialog/index.ts @@ -1,3 +1,4 @@ export * from "./import-error-dialog.component"; export * from "./import-success-dialog.component"; export * from "./file-password-prompt.component"; +export * from "./sshkey-password-prompt.component"; diff --git a/libs/importer/src/components/dialog/sshkey-password-prompt.component.html b/libs/importer/src/components/dialog/sshkey-password-prompt.component.html new file mode 100644 index 0000000000..a42615b1cd --- /dev/null +++ b/libs/importer/src/components/dialog/sshkey-password-prompt.component.html @@ -0,0 +1,31 @@ +
+ + + {{ "enterSshKeyPassword" | i18n }} + + +
+ {{ "enterSshKeyPasswordDesc" | i18n }} + + {{ "confirmSshKeyPassword" | i18n }} + + + +
+ + + + + +
+
diff --git a/libs/importer/src/components/dialog/sshkey-password-prompt.component.ts b/libs/importer/src/components/dialog/sshkey-password-prompt.component.ts new file mode 100644 index 0000000000..527dfec6e8 --- /dev/null +++ b/libs/importer/src/components/dialog/sshkey-password-prompt.component.ts @@ -0,0 +1,46 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + AsyncActionsModule, + ButtonModule, + DialogModule, + FormFieldModule, + IconButtonModule, +} from "@bitwarden/components"; + +@Component({ + templateUrl: "sshkey-password-prompt.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + DialogModule, + FormFieldModule, + AsyncActionsModule, + ButtonModule, + IconButtonModule, + ReactiveFormsModule, + ], +}) +export class SshKeyPasswordPromptComponent { + protected formGroup = this.formBuilder.group({ + sshKeyPassword: ["", Validators.required], + }); + + constructor( + public dialogRef: DialogRef, + protected formBuilder: FormBuilder, + ) {} + + submit = () => { + this.formGroup.markAsTouched(); + if (!this.formGroup.valid) { + return; + } + this.dialogRef.close(this.formGroup.value.sshKeyPassword); + }; +} From 7d6da0a68d152006c2c45db81117a7d2e0844268 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 27 Nov 2024 14:01:59 -0500 Subject: [PATCH 45/75] [deps] Design System: Update angular-cli monorepo to v17.3.11 (#10842) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 122 +++++++++++++++++++++++----------------------- package.json | 6 +-- 2 files changed, 64 insertions(+), 64 deletions(-) diff --git a/package-lock.json b/package-lock.json index a413d80b31..c52eb84dc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,12 +74,12 @@ "zxcvbn": "4.4.2" }, "devDependencies": { - "@angular-devkit/build-angular": "17.3.9", + "@angular-devkit/build-angular": "17.3.11", "@angular-eslint/eslint-plugin": "17.5.3", "@angular-eslint/eslint-plugin-template": "17.5.3", "@angular-eslint/schematics": "17.5.3", "@angular-eslint/template-parser": "17.5.3", - "@angular/cli": "17.3.9", + "@angular/cli": "17.3.11", "@angular/compiler-cli": "17.3.12", "@angular/elements": "17.3.12", "@babel/core": "7.24.9", @@ -87,7 +87,7 @@ "@compodoc/compodoc": "1.1.26", "@electron/notarize": "2.5.0", "@electron/rebuild": "3.7.0", - "@ngtools/webpack": "17.3.9", + "@ngtools/webpack": "17.3.11", "@storybook/addon-a11y": "8.4.5", "@storybook/addon-actions": "8.4.5", "@storybook/addon-designs": "8.0.4", @@ -385,16 +385,16 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "17.3.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-17.3.9.tgz", - "integrity": "sha512-EuAPSC4c2DSJLlL4ieviKLx1faTyY+ymWycq6KFwoxu1FgWly/dqBeWyXccYinLhPVZmoh6+A/5S4YWXlOGSnA==", + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-17.3.11.tgz", + "integrity": "sha512-lHX5V2dSts328yvo/9E2u9QMGcvJhbEKKDDp9dBecwvIG9s+4lTOJgi9DPUE7W+AtmPcmbbhwC2JRQ/SLQhAoA==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1703.9", - "@angular-devkit/build-webpack": "0.1703.9", - "@angular-devkit/core": "17.3.9", + "@angular-devkit/architect": "0.1703.11", + "@angular-devkit/build-webpack": "0.1703.11", + "@angular-devkit/core": "17.3.11", "@babel/core": "7.24.0", "@babel/generator": "7.23.6", "@babel/helper-annotate-as-pure": "7.22.5", @@ -405,7 +405,7 @@ "@babel/preset-env": "7.24.0", "@babel/runtime": "7.24.0", "@discoveryjs/json-ext": "0.5.7", - "@ngtools/webpack": "17.3.9", + "@ngtools/webpack": "17.3.11", "@vitejs/plugin-basic-ssl": "1.1.0", "ansi-colors": "4.1.3", "autoprefixer": "10.4.18", @@ -417,7 +417,7 @@ "css-loader": "6.10.0", "esbuild-wasm": "0.20.1", "fast-glob": "3.3.2", - "http-proxy-middleware": "2.0.6", + "http-proxy-middleware": "2.0.7", "https-proxy-agent": "7.0.4", "inquirer": "9.2.15", "jsonc-parser": "3.2.1", @@ -447,7 +447,7 @@ "tree-kill": "1.2.2", "tslib": "2.6.2", "undici": "6.11.1", - "vite": "5.1.7", + "vite": "5.1.8", "watchpack": "2.4.0", "webpack": "5.94.0", "webpack-dev-middleware": "6.1.2", @@ -515,13 +515,13 @@ } }, "node_modules/@angular-devkit/build-angular/node_modules/@angular-devkit/architect": { - "version": "0.1703.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1703.9.tgz", - "integrity": "sha512-kEPfTOVnzrJxPGTvaXy8653HU9Fucxttx9gVfQR1yafs+yIEGx3fKGKe89YPmaEay32bIm7ZUpxDF1FO14nkdQ==", + "version": "0.1703.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1703.11.tgz", + "integrity": "sha512-YNasVZk4rYdcM6M+KRH8PUBhVyJfqzUYLpO98GgRokW+taIDgifckSlmfDZzQRbw45qiwei1IKCLqcpC8nM5Tw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "17.3.9", + "@angular-devkit/core": "17.3.11", "rxjs": "7.8.1" }, "engines": { @@ -531,13 +531,13 @@ } }, "node_modules/@angular-devkit/build-angular/node_modules/@angular-devkit/build-webpack": { - "version": "0.1703.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1703.9.tgz", - "integrity": "sha512-3b0LND39Nc+DwCQ0N7Tbsd7RAFWTeIc4VDwk/7RO8EMYTP5Kfgr/TK66nwTBypHsjmD69IMKHZZaZuiDfGfx2A==", + "version": "0.1703.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1703.11.tgz", + "integrity": "sha512-qbCiiHuoVkD7CtLyWoRi/Vzz6nrEztpF5XIyWUcQu67An1VlxbMTE4yoSQiURjCQMnB/JvS1GPVed7wOq3SJ/w==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1703.9", + "@angular-devkit/architect": "0.1703.11", "rxjs": "7.8.1" }, "engines": { @@ -551,9 +551,9 @@ } }, "node_modules/@angular-devkit/build-angular/node_modules/@angular-devkit/core": { - "version": "17.3.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.9.tgz", - "integrity": "sha512-/iKyn5YT7NW5ylrg9yufUydS8byExeQ2HHIwFC4Ebwb/JYYCz+k4tBf2LdP+zXpemDpLznXTQGWia0/yJjG8Vg==", + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz", + "integrity": "sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1520,13 +1520,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "17.3.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.9.tgz", - "integrity": "sha512-9qg+uWywgAtaQlvbnCQv47hcL6ZuA+d9ucgZ0upZftBllZ2vp5WIthCPb2mB0uBkj84Csmtz9MsErFjOQtTj4g==", + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.11.tgz", + "integrity": "sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "17.3.9", + "@angular-devkit/core": "17.3.11", "jsonc-parser": "3.2.1", "magic-string": "0.30.8", "ora": "5.4.1", @@ -1539,9 +1539,9 @@ } }, "node_modules/@angular-devkit/schematics/node_modules/@angular-devkit/core": { - "version": "17.3.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.9.tgz", - "integrity": "sha512-/iKyn5YT7NW5ylrg9yufUydS8byExeQ2HHIwFC4Ebwb/JYYCz+k4tBf2LdP+zXpemDpLznXTQGWia0/yJjG8Vg==", + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz", + "integrity": "sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1723,16 +1723,16 @@ } }, "node_modules/@angular/cli": { - "version": "17.3.9", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-17.3.9.tgz", - "integrity": "sha512-b5RGu5RO4VKZlMQDatwABAn1qocgD9u4IrGN2dvHDcrz5apTKYftUdGyG42vngyDNBCg1mWkSDQEWK4f2HfuGg==", + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-17.3.11.tgz", + "integrity": "sha512-8R9LwAGL8hGAWJ4mNG9ZPUrBUzIdmst0Ldua6RJJ+PrqgjX+8IbO+lNnfrOY/XY+Z3LXbCEJflL26f9czCvTPQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1703.9", - "@angular-devkit/core": "17.3.9", - "@angular-devkit/schematics": "17.3.9", - "@schematics/angular": "17.3.9", + "@angular-devkit/architect": "0.1703.11", + "@angular-devkit/core": "17.3.11", + "@angular-devkit/schematics": "17.3.11", + "@schematics/angular": "17.3.11", "@yarnpkg/lockfile": "1.1.0", "ansi-colors": "4.1.3", "ini": "4.1.2", @@ -1758,13 +1758,13 @@ } }, "node_modules/@angular/cli/node_modules/@angular-devkit/architect": { - "version": "0.1703.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1703.9.tgz", - "integrity": "sha512-kEPfTOVnzrJxPGTvaXy8653HU9Fucxttx9gVfQR1yafs+yIEGx3fKGKe89YPmaEay32bIm7ZUpxDF1FO14nkdQ==", + "version": "0.1703.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1703.11.tgz", + "integrity": "sha512-YNasVZk4rYdcM6M+KRH8PUBhVyJfqzUYLpO98GgRokW+taIDgifckSlmfDZzQRbw45qiwei1IKCLqcpC8nM5Tw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "17.3.9", + "@angular-devkit/core": "17.3.11", "rxjs": "7.8.1" }, "engines": { @@ -1774,9 +1774,9 @@ } }, "node_modules/@angular/cli/node_modules/@angular-devkit/core": { - "version": "17.3.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.9.tgz", - "integrity": "sha512-/iKyn5YT7NW5ylrg9yufUydS8byExeQ2HHIwFC4Ebwb/JYYCz+k4tBf2LdP+zXpemDpLznXTQGWia0/yJjG8Vg==", + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz", + "integrity": "sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7173,9 +7173,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "17.3.9", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-17.3.9.tgz", - "integrity": "sha512-2+NvEQuYKRWdZaJbRJWEnR48tpW0uYbhwfHBHLDI9Kazb3mb0oAwYBVXdq+TtDLBypXnMsFpCewjRHTvkVx4/A==", + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-17.3.11.tgz", + "integrity": "sha512-SfTCbplt4y6ak5cf2IfqdoVOsnoNdh/j6Vu+wb8WWABKwZ5yfr2S/Gk6ithSKcdIZhAF8DNBOoyk1EJuf8Xkfg==", "dev": true, "license": "MIT", "engines": { @@ -7900,14 +7900,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "17.3.9", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.3.9.tgz", - "integrity": "sha512-q6N8mbcYC6cgPyjTrMH7ehULQoUUwEYN4g7uo4ylZ/PFklSLJvpSp4BuuxANgW449qHSBvQfdIoui9ayAUXQzA==", + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.3.11.tgz", + "integrity": "sha512-tvJpTgYC+hCnTyLszYRUZVyNTpPd+C44gh5CPTcG3qkqStzXQwynQAf6X/DjtwXbUiPQF0XfF0+0R489GpdZPA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "17.3.9", - "@angular-devkit/schematics": "17.3.9", + "@angular-devkit/core": "17.3.11", + "@angular-devkit/schematics": "17.3.11", "jsonc-parser": "3.2.1" }, "engines": { @@ -7917,9 +7917,9 @@ } }, "node_modules/@schematics/angular/node_modules/@angular-devkit/core": { - "version": "17.3.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.9.tgz", - "integrity": "sha512-/iKyn5YT7NW5ylrg9yufUydS8byExeQ2HHIwFC4Ebwb/JYYCz+k4tBf2LdP+zXpemDpLznXTQGWia0/yJjG8Vg==", + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz", + "integrity": "sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18809,9 +18809,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", - "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz", + "integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==", "dev": true, "license": "MIT", "dependencies": { @@ -32008,9 +32008,9 @@ } }, "node_modules/vite": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.7.tgz", - "integrity": "sha512-sgnEEFTZYMui/sTlH1/XEnVNHMujOahPLGMxn1+5sIT45Xjng1Ec1K78jRP15dSmVgg5WBin9yO81j3o9OxofA==", + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.8.tgz", + "integrity": "sha512-mB8ToUuSmzODSpENgvpFk2fTiU/YQ1tmcVJJ4WZbq4fPdGJkFNVcmVL5k7iDug6xzWjjuGDKAuSievIsD6H7Xw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 8e7b06bfb9..dc540b844e 100644 --- a/package.json +++ b/package.json @@ -35,12 +35,12 @@ "libs/*" ], "devDependencies": { - "@angular-devkit/build-angular": "17.3.9", + "@angular-devkit/build-angular": "17.3.11", "@angular-eslint/eslint-plugin": "17.5.3", "@angular-eslint/eslint-plugin-template": "17.5.3", "@angular-eslint/schematics": "17.5.3", "@angular-eslint/template-parser": "17.5.3", - "@angular/cli": "17.3.9", + "@angular/cli": "17.3.11", "@angular/compiler-cli": "17.3.12", "@angular/elements": "17.3.12", "@babel/core": "7.24.9", @@ -48,7 +48,7 @@ "@compodoc/compodoc": "1.1.26", "@electron/notarize": "2.5.0", "@electron/rebuild": "3.7.0", - "@ngtools/webpack": "17.3.9", + "@ngtools/webpack": "17.3.11", "@storybook/addon-a11y": "8.4.5", "@storybook/addon-actions": "8.4.5", "@storybook/addon-designs": "8.0.4", From 178ef353d4653cad7886cbff73354c073663f76d Mon Sep 17 00:00:00 2001 From: Will Martin Date: Wed, 27 Nov 2024 15:33:37 -0500 Subject: [PATCH 46/75] [CL-269] extension router transitions (#11989) --- .github/CODEOWNERS | 1 + .../src/popup/app-routing.animations.ts | 291 +++++------------- apps/browser/src/popup/app-routing.module.ts | 142 ++++----- apps/browser/src/popup/app.component.ts | 21 +- 4 files changed, 159 insertions(+), 296 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a90545ab57..cf656967da 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -102,6 +102,7 @@ apps/desktop/src/services/duckduckgo-message-handler.service.ts @bitwarden/team- .storybook @bitwarden/team-design-system libs/components @bitwarden/team-design-system apps/browser/src/platform/popup/layout @bitwarden/team-design-system +apps/browser/src/popup/app-routing.animations.ts @bitwarden/team-design-system apps/web/src/app/layouts @bitwarden/team-design-system ## Desktop native module ## diff --git a/apps/browser/src/popup/app-routing.animations.ts b/apps/browser/src/popup/app-routing.animations.ts index 061067c717..6af47934ef 100644 --- a/apps/browser/src/popup/app-routing.animations.ts +++ b/apps/browser/src/popup/app-routing.animations.ts @@ -1,4 +1,19 @@ -import { animate, group, query, style, transition, trigger } from "@angular/animations"; +import { + animate, + AnimationMetadata, + group, + query, + style, + transition, + trigger, +} from "@angular/animations"; + +/** + * Determines the router transition behavior. + * Changing between elevations will animate from the right. + * Navigating between two pages of the same elevation will not animate. + */ +export type RouteElevation = 0 | 1 | 2 | 3 | 4; const queryShown = query( ":enter, :leave", @@ -13,11 +28,15 @@ const queryChildRoute = query("router-outlet ~ *", [style({}), animate(1, style( optional: true, }); -const speed = "0.4s"; +const speedX = "0.225s"; +const speedY = "0.3s"; -export function queryTranslate( - direction: string, - axis: string, +type TranslateDirection = "enter" | "leave"; +type TranslationAxis = "X" | "Y"; + +function queryTranslate( + direction: TranslateDirection, + axis: TranslationAxis, from: number, to: number, zIndex = 1000, @@ -30,217 +49,67 @@ export function queryTranslate( zIndex: zIndex, boxShadow: "0 3px 2px -2px gray", }), - animate(speed + " ease-in-out", style({ transform: "translate" + axis + "(" + to + "%)" })), + animate( + (axis === "X" ? speedX : speedY) + " ease-in-out", + style({ + transform: "translate" + axis + "(" + to + "%)", + }), + ), ], - { optional: true }, + { + optional: true, + }, ); } -export function queryTranslateX(direction: string, from: number, to: number, zIndex = 1000) { - return queryTranslate(direction, "X", from, to, zIndex); -} - -export function queryTranslateY(direction: string, from: number, to: number, zIndex = 1000) { - return queryTranslate(direction, "Y", from, to, zIndex); -} - -const inSlideLeft = [ - queryShown, - group([queryTranslateX("enter", 100, 0), queryTranslateX("leave", 0, -100), queryChildRoute]), -]; - -const outSlideRight = [ - queryShown, - group([queryTranslateX("enter", -100, 0), queryTranslateX("leave", 0, 100)]), -]; - -const inSlideUp = [ - queryShown, - group([queryTranslateY("enter", 100, 0, 1010), queryTranslateY("leave", 0, 0), queryChildRoute]), -]; - -const outSlideDown = [ - queryShown, - group([queryTranslateY("enter", 0, 0), queryTranslateY("leave", 0, 100, 1010)]), -]; - -const inSlideDown = [ - queryShown, - group([queryTranslateY("enter", -100, 0, 1010), queryTranslateY("leave", 0, 0), queryChildRoute]), -]; - -// eslint-disable-next-line -const outSlideUp = [ - queryShown, - group([queryTranslateY("enter", 0, 0), queryTranslateY("leave", 0, -100, 1010)]), -]; - -export function tabsToCiphers(fromState: string, toState: string) { - if (fromState == null || toState === null || toState.indexOf("ciphers_") === -1) { - return false; - } - return ( - (fromState.indexOf("ciphers_") === 0 && fromState.indexOf("ciphers_direction=b") === -1) || - fromState === "tabs" - ); -} - -export function ciphersToTabs(fromState: string, toState: string) { - if (fromState == null || toState === null || fromState.indexOf("ciphers_") === -1) { - return false; - } - return toState.indexOf("ciphers_direction=b") === 0 || toState === "tabs"; -} - -export function ciphersToView(fromState: string, toState: string) { - if (fromState == null || toState === null) { - return false; - } - return ( - fromState.indexOf("ciphers_") === 0 && - (toState === "view-cipher" || toState === "add-cipher" || toState === "clone-cipher") - ); -} - -export function viewToCiphers(fromState: string, toState: string) { - if (fromState == null || toState === null) { - return false; - } - return ( - (fromState === "view-cipher" || fromState === "add-cipher" || fromState === "clone-cipher") && - toState.indexOf("ciphers_") === 0 - ); -} +const animations = { + slideInFromRight: [ + queryShown, + group([ + queryTranslate("enter", "X", 100, 0, 1010), + queryTranslate("leave", "X", 0, 0), + queryChildRoute, + ]), + ], + slideOutToRight: [ + queryShown, + group([queryTranslate("enter", "X", 0, 0), queryTranslate("leave", "X", 0, 100, 1010)]), + ], + /** --- Not used --- */ + // slideInFromTop: [ + // queryShown, + // group([ + // queryTranslate("enter", "Y", -100, 0, 1010), + // queryTranslate("leave", "Y", 0, 0), + // queryChildRoute, + // ]), + // ], + // slideOutToTop: [ + // queryShown, + // group([queryTranslate("enter", "Y", 0, 0), queryTranslate("leave", "Y", 0, -100, 1010)]), + // ], +} satisfies Record; export const routerTransition = trigger("routerTransition", [ - transition("void => home", inSlideLeft), - transition("void => tabs", inSlideLeft), + transition("0 => 1", animations.slideInFromRight), + transition("0 => 2", animations.slideInFromRight), + transition("0 => 3", animations.slideInFromRight), + transition("0 => 4", animations.slideInFromRight), + transition("1 => 2", animations.slideInFromRight), + transition("1 => 3", animations.slideInFromRight), + transition("1 => 4", animations.slideInFromRight), + transition("2 => 3", animations.slideInFromRight), + transition("2 => 4", animations.slideInFromRight), + transition("3 => 4", animations.slideInFromRight), - transition("home => environment, home => login, home => register", inSlideUp), - - transition("login => home", outSlideDown), - transition("login => hint", inSlideUp), - transition("login => tabs, login => 2fa, login => login-with-device", inSlideLeft), - - transition("hint => login, register => home, environment => home", outSlideDown), - - transition("2fa => login", outSlideRight), - transition("2fa => 2fa-options", inSlideUp), - transition("2fa-options => 2fa", outSlideDown), - transition("2fa => tabs", inSlideLeft), - - transition("login-with-device => tabs, login-with-device => 2fa", inSlideLeft), - transition("login-with-device => login", outSlideRight), - - transition("admin-approval-requested => tabs, admin-approval-requested => 2fa", inSlideLeft), - transition("admin-approval-requested => login", outSlideRight), - - transition(tabsToCiphers, inSlideLeft), - transition(ciphersToTabs, outSlideRight), - - transition(ciphersToView, inSlideUp), - transition(viewToCiphers, outSlideDown), - - transition("tabs => view-cipher", inSlideUp), - transition("view-cipher => tabs", outSlideDown), - - transition("view-cipher => edit-cipher, view-cipher => cipher-password-history", inSlideUp), - transition( - "edit-cipher => view-cipher, cipher-password-history => view-cipher, edit-cipher => tabs", - outSlideDown, - ), - - transition("view-cipher => clone-cipher", inSlideUp), - transition("clone-cipher => view-cipher, clone-cipher => tabs", outSlideDown), - - transition("view-cipher => share-cipher", inSlideUp), - transition("share-cipher => view-cipher", outSlideDown), - - transition("tabs => add-cipher", inSlideUp), - transition("add-cipher => tabs", outSlideDown), - - transition("generator => generator-history, tabs => generator-history", inSlideLeft), - transition("generator-history => generator, generator-history => tabs", outSlideRight), - - transition( - "add-cipher => generator, edit-cipher => generator, clone-cipher => generator", - inSlideUp, - ), - transition( - "generator => add-cipher, generator => edit-cipher, generator => clone-cipher", - outSlideDown, - ), - - transition("edit-cipher => attachments, edit-cipher => collections", inSlideLeft), - transition("attachments => edit-cipher, collections => edit-cipher", outSlideRight), - - transition("clone-cipher => attachments, clone-cipher => collections", inSlideLeft), - transition("attachments => clone-cipher, collections => clone-cipher", outSlideRight), - - transition("tabs => account-security", inSlideLeft), - transition("account-security => tabs", outSlideRight), - - transition("tabs => assign-collections", inSlideLeft), - transition("assign-collections => tabs", outSlideRight), - - // Vault settings - transition("tabs => vault-settings", inSlideLeft), - transition("vault-settings => tabs", outSlideRight), - - transition("vault-settings => import", inSlideLeft), - transition("import => vault-settings", outSlideRight), - - transition("vault-settings => export", inSlideLeft), - transition("export => vault-settings", outSlideRight), - - transition("vault-settings => folders", inSlideLeft), - transition("folders => vault-settings", outSlideRight), - - transition("folders => edit-folder, folders => add-folder", inSlideUp), - transition("edit-folder => folders, add-folder => folders", outSlideDown), - - transition("vault-settings => sync", inSlideLeft), - transition("sync => vault-settings", outSlideRight), - - transition("vault-settings => trash", inSlideLeft), - transition("trash => vault-settings", outSlideRight), - - transition("trash => view-cipher", inSlideLeft), - transition("view-cipher => trash", outSlideRight), - - // Appearance settings - transition("tabs => appearance", inSlideLeft), - transition("appearance => tabs", outSlideRight), - - transition("tabs => premium", inSlideLeft), - transition("premium => tabs", outSlideRight), - - transition("tabs => lock", inSlideDown), - - transition("tabs => about", inSlideLeft), - transition("about => tabs", outSlideRight), - - transition("tabs => send-type", inSlideLeft), - transition("send-type => tabs", outSlideRight), - - transition("tabs => add-send, send-type => add-send", inSlideUp), - transition("add-send => tabs, add-send => send-type", outSlideDown), - - transition("tabs => edit-send, send-type => edit-send", inSlideUp), - transition("edit-send => tabs, edit-send => send-type", outSlideDown), - - // Notification settings - transition("tabs => notifications", inSlideLeft), - transition("notifications => tabs", outSlideRight), - - transition("notifications => excluded-domains", inSlideLeft), - transition("excluded-domains => notifications", outSlideRight), - - transition("tabs => autofill", inSlideLeft), - transition("autofill => tabs", outSlideRight), - - transition("* => account-switcher", inSlideUp), - transition("account-switcher => *", outSlideDown), - - transition("lock => *", outSlideDown), + transition("1 => 0", animations.slideOutToRight), + transition("2 => 0", animations.slideOutToRight), + transition("2 => 1", animations.slideOutToRight), + transition("3 => 0", animations.slideOutToRight), + transition("3 => 1", animations.slideOutToRight), + transition("3 => 2", animations.slideOutToRight), + transition("4 => 0", animations.slideOutToRight), + transition("4 => 1", animations.slideOutToRight), + transition("4 => 2", animations.slideOutToRight), + transition("4 => 3", animations.slideOutToRight), ]); diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 5e6f38e80b..77a720557c 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -123,6 +123,7 @@ import { TrashComponent } from "../vault/popup/settings/trash.component"; import { VaultSettingsV2Component } from "../vault/popup/settings/vault-settings-v2.component"; import { VaultSettingsComponent } from "../vault/popup/settings/vault-settings.component"; +import { RouteElevation } from "./app-routing.animations"; import { debounceNavigationGuard } from "./services/debounce-navigation.service"; import { TabsV2Component } from "./tabs-v2.component"; import { TabsComponent } from "./tabs.component"; @@ -131,13 +132,10 @@ import { TabsComponent } from "./tabs.component"; * Data properties acceptable for use in extension route objects */ export interface RouteDataProperties { + elevation: RouteElevation; + /** - * A state string which identifies the current route for the sake of transition animation logic. - * The state string is passed into [@routerTransition] in the app.component. - */ - state: string; - /** - * A boolean to indicate that the URL should not be saved in memory in the BrowserRouterSvc. + * A boolean to indicate that the URL should not be saved in memory in the BrowserRouterService. */ doNotSaveUrl?: boolean; } @@ -167,19 +165,19 @@ const routes: Routes = [ path: "home", component: HomeComponent, canActivate: [unauthGuardFn(unauthRouteOverrides), unauthUiRefreshRedirect("/login")], - data: { state: "home" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, ...extensionRefreshSwap(Fido2V1Component, Fido2Component, { path: "fido2", canActivate: [fido2AuthGuard], - data: { state: "fido2" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), { path: "lock", component: LockComponent, canActivate: [lockGuard()], canMatch: [extensionRefreshRedirect("/lockV2")], - data: { state: "lock", doNotSaveUrl: true } satisfies RouteDataProperties, + data: { elevation: 1, doNotSaveUrl: true } satisfies RouteDataProperties, }, ...twofactorRefactorSwap( TwoFactorComponent, @@ -187,12 +185,12 @@ const routes: Routes = [ { path: "2fa", canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { state: "2fa" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "2fa", canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { state: "2fa" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, children: [ { path: "", @@ -205,201 +203,207 @@ const routes: Routes = [ path: "2fa-options", component: TwoFactorOptionsComponent, canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { state: "2fa-options" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "sso", component: SsoComponent, canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { state: "sso" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "set-password", component: SetPasswordComponent, - data: { state: "set-password" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "remove-password", component: RemovePasswordComponent, canActivate: [authGuard], - data: { state: "remove-password" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "register", component: RegisterComponent, canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { state: "register" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "environment", component: EnvironmentComponent, canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { state: "environment" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "ciphers", component: VaultItemsComponent, canActivate: [authGuard], - data: { state: "ciphers" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, ...extensionRefreshSwap(ViewComponent, ViewV2Component, { path: "view-cipher", canActivate: [authGuard], - data: { state: "view-cipher" } satisfies RouteDataProperties, + data: { + // Above "trash" + elevation: 3, + } satisfies RouteDataProperties, }), ...extensionRefreshSwap(PasswordHistoryComponent, PasswordHistoryV2Component, { path: "cipher-password-history", canActivate: [authGuard], - data: { state: "cipher-password-history" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(AddEditComponent, AddEditV2Component, { path: "add-cipher", canActivate: [authGuard, debounceNavigationGuard()], - data: { state: "add-cipher" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, runGuardsAndResolvers: "always", }), ...extensionRefreshSwap(AddEditComponent, AddEditV2Component, { path: "edit-cipher", canActivate: [authGuard, debounceNavigationGuard()], - data: { state: "edit-cipher" } satisfies RouteDataProperties, + data: { + // Above "trash" + elevation: 3, + } satisfies RouteDataProperties, runGuardsAndResolvers: "always", }), { path: "share-cipher", component: ShareComponent, canActivate: [authGuard], - data: { state: "share-cipher" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "collections", component: CollectionsComponent, canActivate: [authGuard], - data: { state: "collections" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, ...extensionRefreshSwap(AttachmentsComponent, AttachmentsV2Component, { path: "attachments", canActivate: [authGuard], - data: { state: "attachments" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), { path: "generator", component: GeneratorComponent, canActivate: [authGuard], - data: { state: "generator" } satisfies RouteDataProperties, + data: { elevation: 0 } satisfies RouteDataProperties, }, ...extensionRefreshSwap(PasswordGeneratorHistoryComponent, CredentialGeneratorHistoryComponent, { path: "generator-history", canActivate: [authGuard], - data: { state: "generator-history" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(ImportBrowserComponent, ImportBrowserV2Component, { path: "import", canActivate: [authGuard], - data: { state: "import" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(ExportBrowserComponent, ExportBrowserV2Component, { path: "export", canActivate: [authGuard], - data: { state: "export" } satisfies RouteDataProperties, + data: { elevation: 2 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(AutofillV1Component, AutofillComponent, { path: "autofill", canActivate: [authGuard], - data: { state: "autofill" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(AccountSecurityV1Component, AccountSecurityComponent, { path: "account-security", canActivate: [authGuard], - data: { state: "account-security" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(NotificationsSettingsV1Component, NotificationsSettingsComponent, { path: "notifications", canActivate: [authGuard], - data: { state: "notifications" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(VaultSettingsComponent, VaultSettingsV2Component, { path: "vault-settings", canActivate: [authGuard], - data: { state: "vault-settings" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(FoldersComponent, FoldersV2Component, { path: "folders", canActivate: [authGuard], - data: { state: "folders" } satisfies RouteDataProperties, + data: { elevation: 2 } satisfies RouteDataProperties, }), { path: "add-folder", component: FolderAddEditComponent, canActivate: [authGuard], - data: { state: "add-folder" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "edit-folder", component: FolderAddEditComponent, canActivate: [authGuard], - data: { state: "edit-folder" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "sync", component: SyncComponent, canActivate: [authGuard], - data: { state: "sync" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, ...extensionRefreshSwap(ExcludedDomainsV1Component, ExcludedDomainsComponent, { path: "excluded-domains", canActivate: [authGuard], - data: { state: "excluded-domains" } satisfies RouteDataProperties, + data: { elevation: 2 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(PremiumComponent, PremiumV2Component, { path: "premium", component: PremiumComponent, canActivate: [authGuard], - data: { state: "premium" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(AppearanceComponent, AppearanceV2Component, { path: "appearance", canActivate: [authGuard], - data: { state: "appearance" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(AddEditComponent, AddEditV2Component, { path: "clone-cipher", canActivate: [authGuard], - data: { state: "clone-cipher" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), { path: "send-type", component: SendTypeComponent, canActivate: [authGuard], - data: { state: "send-type" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, ...extensionRefreshSwap(SendAddEditComponent, SendAddEditV2Component, { path: "add-send", canActivate: [authGuard], - data: { state: "add-send" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(SendAddEditComponent, SendAddEditV2Component, { path: "edit-send", canActivate: [authGuard], - data: { state: "edit-send" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), { path: "send-created", component: SendCreatedComponent, canActivate: [authGuard], - data: { state: "send" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "update-temp-password", component: UpdateTempPasswordComponent, canActivate: [authGuard], - data: { state: "update-temp-password" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, ...unauthUiRefreshSwap( LoginViaAuthRequestComponentV1, ExtensionAnonLayoutWrapperComponent, { path: "login-with-device", - data: { state: "login-with-device" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "login-with-device", @@ -413,7 +417,7 @@ const routes: Routes = [ }, showLogo: false, showBackButton: true, - state: "login-with-device", + elevation: 1, } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, children: [ { path: "", component: LoginViaAuthRequestComponent }, @@ -430,7 +434,7 @@ const routes: Routes = [ ExtensionAnonLayoutWrapperComponent, { path: "admin-approval-requested", - data: { state: "admin-approval-requested" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "admin-approval-requested", @@ -444,7 +448,7 @@ const routes: Routes = [ }, showLogo: false, showBackButton: true, - state: "admin-approval-requested", + elevation: 1, } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, children: [{ path: "", component: LoginViaAuthRequestComponent }], }, @@ -456,7 +460,7 @@ const routes: Routes = [ path: "hint", canActivate: [unauthGuardFn(unauthRouteOverrides)], data: { - state: "hint", + elevation: 1, } satisfies RouteDataProperties, }, { @@ -474,7 +478,7 @@ const routes: Routes = [ }, pageIcon: UserLockIcon, showBackButton: true, - state: "hint", + elevation: 1, } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, children: [ { path: "", component: PasswordHintComponent }, @@ -497,7 +501,7 @@ const routes: Routes = [ { path: "login", canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { state: "login" }, + data: { elevation: 1 }, }, { path: "", @@ -510,7 +514,7 @@ const routes: Routes = [ pageTitle: { key: "logInToBitwarden", }, - state: "login", + elevation: 1, showAcctSwitcher: true, } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, children: [ @@ -535,7 +539,7 @@ const routes: Routes = [ { path: "login-initiated", canActivate: [tdeDecryptionRequiredGuard()], - data: { state: "login-initiated" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "login-initiated", @@ -554,7 +558,7 @@ const routes: Routes = [ path: "signup", canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()], data: { - state: "signup", + elevation: 1, pageIcon: RegistrationUserAddIcon, pageTitle: { key: "createAccount", @@ -581,7 +585,7 @@ const routes: Routes = [ canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()], data: { pageIcon: RegistrationLockAltIcon, - state: "finish-signup", + elevation: 1, showBackButton: true, } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, children: [ @@ -626,7 +630,7 @@ const routes: Routes = [ pageSubtitle: { key: "finishJoiningThisOrganizationBySettingAMasterPassword", }, - state: "set-password-jit", + elevation: 1, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, ], @@ -635,21 +639,21 @@ const routes: Routes = [ path: "assign-collections", component: AssignCollections, canActivate: [canAccessFeature(FeatureFlag.ExtensionRefresh, true, "/")], - data: { state: "assign-collections" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, ...extensionRefreshSwap(AboutPageComponent, AboutPageV2Component, { path: "about", canActivate: [authGuard], - data: { state: "about" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(MoreFromBitwardenPageComponent, MoreFromBitwardenPageV2Component, { path: "more-from-bitwarden", canActivate: [authGuard], - data: { state: "moreFromBitwarden" } satisfies RouteDataProperties, + data: { elevation: 2 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(TabsComponent, TabsV2Component, { path: "tabs", - data: { state: "tabs" } satisfies RouteDataProperties, + data: { elevation: 0 } satisfies RouteDataProperties, children: [ { path: "", @@ -661,42 +665,42 @@ const routes: Routes = [ component: CurrentTabComponent, canActivate: [authGuard], canMatch: [extensionRefreshRedirect("/tabs/vault")], - data: { state: "tabs_current" } satisfies RouteDataProperties, + data: { elevation: 0 } satisfies RouteDataProperties, runGuardsAndResolvers: "always", }, ...extensionRefreshSwap(VaultFilterComponent, VaultV2Component, { path: "vault", canActivate: [authGuard], canDeactivate: [clearVaultStateGuard], - data: { state: "tabs_vault" } satisfies RouteDataProperties, + data: { elevation: 0 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(GeneratorComponent, CredentialGeneratorComponent, { path: "generator", canActivate: [authGuard], - data: { state: "tabs_generator" } satisfies RouteDataProperties, + data: { elevation: 0 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(SettingsComponent, SettingsV2Component, { path: "settings", canActivate: [authGuard], - data: { state: "tabs_settings" } satisfies RouteDataProperties, + data: { elevation: 0 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(SendGroupingsComponent, SendV2Component, { path: "send", canActivate: [authGuard], - data: { state: "tabs_send" } satisfies RouteDataProperties, + data: { elevation: 0 } satisfies RouteDataProperties, }), ], }), { path: "account-switcher", component: AccountSwitcherComponent, - data: { state: "account-switcher", doNotSaveUrl: true } satisfies RouteDataProperties, + data: { elevation: 4, doNotSaveUrl: true } satisfies RouteDataProperties, }, { path: "trash", component: TrashComponent, canActivate: [authGuard], - data: { state: "trash" } satisfies RouteDataProperties, + data: { elevation: 2 } satisfies RouteDataProperties, }, ]; diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 31f610b7e7..15dfcabe5f 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -38,8 +38,8 @@ import { DesktopSyncVerificationDialogComponent } from "./components/desktop-syn selector: "app-root", styles: [], animations: [routerTransition], - template: `
- + template: `
+
`, }) export class AppComponent implements OnInit, OnDestroy { @@ -223,23 +223,12 @@ export class AppComponent implements OnInit, OnDestroy { this.destroy$.complete(); } - getState(outlet: RouterOutlet) { + getRouteElevation(outlet: RouterOutlet) { if (!this.routerAnimations) { return; - } else if (outlet.activatedRouteData.state === "ciphers") { - const routeDirection = - (window as any).routeDirection != null ? (window as any).routeDirection : ""; - return ( - "ciphers_direction=" + - routeDirection + - "_" + - (outlet.activatedRoute.queryParams as any).value.folderId + - "_" + - (outlet.activatedRoute.queryParams as any).value.collectionId - ); - } else { - return outlet.activatedRouteData.state; } + + return outlet.activatedRouteData.elevation; } private async recordActivity() { From c0ab62fad0a42ed8afb5c774de7d767f6c1bb1d4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 28 Nov 2024 10:02:24 +0100 Subject: [PATCH 47/75] [deps] Platform: Update Rust crate homedir to v0.3.4 (#12129) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/desktop/desktop_native/Cargo.lock | 70 +++++++-------------- apps/desktop/desktop_native/core/Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 50 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index bf82c1bb74..a2c732f434 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -298,12 +298,6 @@ dependencies = [ "sha2", ] -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.6.0" @@ -1099,7 +1093,7 @@ version = "0.19.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39650279f135469465018daae0ba53357942a5212137515777d5fdca74984a44" dependencies = [ - "bitflags 2.6.0", + "bitflags", "futures-channel", "futures-core", "futures-executor", @@ -1190,12 +1184,12 @@ dependencies = [ [[package]] name = "homedir" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bed305c13ce3829a09d627f5d43ff738482a09361ae4eb8039993b55fb10e5e" +checksum = "5bdbbd5bc8c5749697ccaa352fa45aff8730cf21c68029c0eef1ffed7c3d6ba2" dependencies = [ "cfg-if", - "nix 0.26.4", + "nix 0.29.0", "widestring", "windows 0.57.0", ] @@ -1299,7 +1293,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags 2.6.0", + "bitflags", "libc", ] @@ -1366,15 +1360,6 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" -[[package]] -name = "memoffset" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" -dependencies = [ - "autocfg", -] - [[package]] name = "memoffset" version = "0.9.1" @@ -1417,7 +1402,7 @@ version = "2.16.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "214f07a80874bb96a8433b3cdfc84980d56c7b02e1a0d7ba4ba0db5cef785e2b" dependencies = [ - "bitflags 2.6.0", + "bitflags", "ctor", "napi-derive", "napi-sys", @@ -1469,26 +1454,13 @@ dependencies = [ "libloading", ] -[[package]] -name = "nix" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" -dependencies = [ - "bitflags 1.3.2", - "cfg-if", - "libc", - "memoffset 0.7.1", - "pin-utils", -] - [[package]] name = "nix" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.6.0", + "bitflags", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -1500,11 +1472,11 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.6.0", + "bitflags", "cfg-if", "cfg_aliases 0.2.1", "libc", - "memoffset 0.9.1", + "memoffset", ] [[package]] @@ -1601,7 +1573,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.6.0", + "bitflags", "block2", "libc", "objc2", @@ -1617,7 +1589,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.6.0", + "bitflags", "block2", "objc2", "objc2-foundation", @@ -1647,7 +1619,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.6.0", + "bitflags", "block2", "libc", "objc2", @@ -1659,7 +1631,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.6.0", + "bitflags", "block2", "objc2", "objc2-foundation", @@ -1671,7 +1643,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.6.0", + "bitflags", "block2", "objc2", "objc2-foundation", @@ -2001,7 +1973,7 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" dependencies = [ - "bitflags 2.6.0", + "bitflags", ] [[package]] @@ -2105,7 +2077,7 @@ version = "0.38.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ - "bitflags 2.6.0", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -2156,7 +2128,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d0283c0a4a22a0f1b0e4edca251aa20b92fc96eaa09b84bec052f9415e9d71" dependencies = [ - "bitflags 2.6.0", + "bitflags", "core-foundation", "core-foundation-sys", "libc", @@ -2623,7 +2595,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ - "memoffset 0.9.1", + "memoffset", "tempfile", "winapi", ] @@ -2694,7 +2666,7 @@ version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b66249d3fc69f76fd74c82cc319300faa554e9d865dab1f7cd66cc20db10b280" dependencies = [ - "bitflags 2.6.0", + "bitflags", "rustix", "wayland-backend", "wayland-scanner", @@ -2706,7 +2678,7 @@ version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" dependencies = [ - "bitflags 2.6.0", + "bitflags", "wayland-backend", "wayland-client", "wayland-scanner", @@ -2718,7 +2690,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad1f61b76b6c2d8742e10f9ba5c3737f6530b4c243132c2a2ccc8aa96fe25cd6" dependencies = [ - "bitflags 2.6.0", + "bitflags", "wayland-backend", "wayland-client", "wayland-protocols", diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index b3883506c1..abfca3e080 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -31,7 +31,7 @@ async-stream = "=0.3.6" base64 = "=0.22.1" byteorder = "=1.5.0" cbc = { version = "=0.1.2", features = ["alloc"] } -homedir = "=0.3.3" +homedir = "=0.3.4" libc = "=0.2.162" pin-project = "=1.1.7" dirs = "=5.0.1" From 927c2fce43343183521ac8ee42f300f462f1da6f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 28 Nov 2024 10:12:09 +0100 Subject: [PATCH 48/75] [deps] Platform: Update Rust crate ssh-key to v0.6.7 (#12133) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/desktop/desktop_native/Cargo.lock | 4 ++-- apps/desktop/desktop_native/core/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index a2c732f434..8226461733 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -2320,9 +2320,9 @@ dependencies = [ [[package]] name = "ssh-key" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca9b366a80cf18bb6406f4cf4d10aebfb46140a8c0c33f666a144c5c76ecbafc" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" dependencies = [ "bcrypt-pbkdf", "ed25519-dalek", diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index abfca3e080..6f333c480e 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -44,7 +44,7 @@ russh-cryptovec = "=0.7.3" scopeguard = "=1.2.0" sha2 = "=0.10.8" ssh-encoding = "=0.2.0" -ssh-key = { version = "=0.6.6", default-features = false, features = [ +ssh-key = { version = "=0.6.7", default-features = false, features = [ "encryption", "ed25519", "rsa", From ab21b78c53627425167d8a0a248180432c7351e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Thu, 28 Nov 2024 05:02:21 -0500 Subject: [PATCH 49/75] [PM-15061] extract encryptors from generator service (#12068) * introduce legacy encryptor provider * port credential generation service to encryptor provider --- ...-service-legacy-encryptor-provider.spec.ts | 492 ++++++++++++++++++ .../key-service-legacy-encryptor-provider.ts | 132 +++++ .../cryptography/legacy-encryptor-provider.ts | 42 ++ .../organization-encryptor.abstraction.ts | 33 ++ .../organization-key-encryptor.spec.ts | 125 +++++ .../organization-key-encryptor.ts | 60 +++ .../user-encryptor.abstraction.ts | 0 .../user-key-encryptor.spec.ts | 2 +- .../user-key-encryptor.ts | 2 +- libs/common/src/tools/dependencies.ts | 61 ++- libs/common/src/tools/rx.spec.ts | 99 ++++ libs/common/src/tools/rx.ts | 52 ++ libs/common/src/tools/state/object-key.ts | 25 +- .../src/tools/state/secret-state.spec.ts | 2 +- libs/common/src/tools/state/secret-state.ts | 2 +- .../tools/state/user-state-subject.spec.ts | 3 +- .../src/tools/state/user-state-subject.ts | 176 ++++--- .../src/forwarder-settings.component.html | 2 +- .../src/generator-services.module.ts | 54 ++ .../components/src/generator.module.ts | 39 +- libs/tools/generator/components/src/index.ts | 1 + .../generator/core/src/data/generators.ts | 2 +- .../generator/core/src/integration/addy-io.ts | 4 +- .../core/src/integration/duck-duck-go.ts | 3 +- .../core/src/integration/fastmail.ts | 3 +- .../core/src/integration/firefox-relay.ts | 3 +- .../core/src/integration/forward-email.ts | 3 +- .../core/src/integration/simple-login.ts | 4 +- .../credential-generator.service.spec.ts | 149 ++---- .../services/credential-generator.service.ts | 66 +-- .../forwarder-generator-strategy.ts | 2 +- .../src/local-generator-history.service.ts | 2 +- .../send-ui/src/send-form/send-form.module.ts | 38 +- 33 files changed, 1384 insertions(+), 299 deletions(-) create mode 100644 libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.spec.ts create mode 100644 libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.ts create mode 100644 libs/common/src/tools/cryptography/legacy-encryptor-provider.ts create mode 100644 libs/common/src/tools/cryptography/organization-encryptor.abstraction.ts create mode 100644 libs/common/src/tools/cryptography/organization-key-encryptor.spec.ts create mode 100644 libs/common/src/tools/cryptography/organization-key-encryptor.ts rename libs/common/src/tools/{state => cryptography}/user-encryptor.abstraction.ts (100%) rename libs/common/src/tools/{state => cryptography}/user-key-encryptor.spec.ts (98%) rename libs/common/src/tools/{state => cryptography}/user-key-encryptor.ts (96%) create mode 100644 libs/tools/generator/components/src/generator-services.module.ts diff --git a/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.spec.ts b/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.spec.ts new file mode 100644 index 0000000000..12257905d1 --- /dev/null +++ b/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.spec.ts @@ -0,0 +1,492 @@ +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, Subject } from "rxjs"; + +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrgKey, UserKey } from "@bitwarden/common/types/key"; +import { KeyService } from "@bitwarden/key-management"; + +import { OrganizationBound, UserBound } from "../dependencies"; + +import { KeyServiceLegacyEncryptorProvider } from "./key-service-legacy-encryptor-provider"; +import { OrganizationEncryptor } from "./organization-encryptor.abstraction"; +import { OrganizationKeyEncryptor } from "./organization-key-encryptor"; +import { UserEncryptor } from "./user-encryptor.abstraction"; +import { UserKeyEncryptor } from "./user-key-encryptor"; + +const encryptService = mock(); +const keyService = mock(); + +const SomeCsprngArray = new Uint8Array(64) as CsprngArray; +const SomeUser = "some user" as UserId; +const AnotherUser = "another user" as UserId; +const SomeUserKey = new SymmetricCryptoKey(SomeCsprngArray) as UserKey; +const SomeOrganization = "some organization" as OrganizationId; +const AnotherOrganization = "another organization" as OrganizationId; +const SomeOrgKey = new SymmetricCryptoKey(SomeCsprngArray) as OrgKey; +const AnotherOrgKey = new SymmetricCryptoKey(SomeCsprngArray) as OrgKey; +const OrgRecords: Record = { + [SomeOrganization]: SomeOrgKey, + [AnotherOrganization]: AnotherOrgKey, +}; + +// Many tests examine the private members of the objects constructed by the +// provider. This is necessary because it's not presently possible to spy +// on the constructors directly. +describe("KeyServiceLegacyEncryptorProvider", () => { + describe("userEncryptor$", () => { + it("emits a user key encryptor bound to the user", async () => { + const userKey$ = new BehaviorSubject(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(SomeUser); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: UserBound<"encryptor", UserEncryptor>[] = []; + + provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v)); + + expect(keyService.userKey$).toHaveBeenCalledWith(SomeUser); + expect(results.length).toBe(1); + expect(results[0]).toMatchObject({ + userId: SomeUser, + encryptor: { + userId: SomeUser, + key: SomeUserKey, + dataPacker: { frameSize: 1 }, + }, + }); + expect(results[0].encryptor).toBeInstanceOf(UserKeyEncryptor); + }); + + it("waits until `dependencies.singleUserId$` emits", () => { + const userKey$ = new BehaviorSubject(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new Subject(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: UserBound<"encryptor", UserEncryptor>[] = []; + provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v)); + // precondition: no emissions occur on subscribe + expect(results.length).toBe(0); + + singleUserId$.next(SomeUser); + + expect(results.length).toBe(1); + }); + + it("emits a new user key encryptor each time `dependencies.singleUserId$` emits", () => { + const userKey$ = new BehaviorSubject(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new Subject(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: UserBound<"encryptor", UserEncryptor>[] = []; + provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v)); + + singleUserId$.next(SomeUser); + singleUserId$.next(SomeUser); + + expect(results.length).toBe(2); + expect(results[0]).not.toBe(results[1]); + }); + + it("waits until `userKey$` emits a truthy value", () => { + const userKey$ = new BehaviorSubject(null); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(SomeUser); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: UserBound<"encryptor", UserEncryptor>[] = []; + provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v)); + // precondition: no emissions occur on subscribe + expect(results.length).toBe(0); + + userKey$.next(SomeUserKey); + + expect(results.length).toBe(1); + expect(results[0]).toMatchObject({ + userId: SomeUser, + encryptor: { + userId: SomeUser, + key: SomeUserKey, + dataPacker: { frameSize: 1 }, + }, + }); + }); + + it("emits a user key encryptor each time `userKey$` emits", () => { + const userKey$ = new Subject(); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(SomeUser); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: UserBound<"encryptor", UserEncryptor>[] = []; + provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v)); + + userKey$.next(SomeUserKey); + userKey$.next(SomeUserKey); + + expect(results.length).toBe(2); + }); + + it("errors when the userId changes", () => { + const userKey$ = new BehaviorSubject(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new Subject(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let error: unknown = false; + provider + .userEncryptor$(1, { singleUserId$ }) + .subscribe({ error: (e: unknown) => (error = e) }); + + singleUserId$.next(SomeUser); + singleUserId$.next(AnotherUser); + + expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: AnotherUser }); + }); + + it("errors when `dependencies.singleUserId$` errors", () => { + const userKey$ = new BehaviorSubject(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new Subject(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let error: unknown = false; + provider + .userEncryptor$(1, { singleUserId$ }) + .subscribe({ error: (e: unknown) => (error = e) }); + + singleUserId$.error({ some: "error" }); + + expect(error).toEqual({ some: "error" }); + }); + + it("errors once `dependencies.singleUserId$` emits and `userKey$` errors", () => { + const userKey$ = new Subject(); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(SomeUser); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let error: unknown = false; + provider + .userEncryptor$(1, { singleUserId$ }) + .subscribe({ error: (e: unknown) => (error = e) }); + + userKey$.error({ some: "error" }); + + expect(error).toEqual({ some: "error" }); + }); + + it("completes when `dependencies.singleUserId$` completes", () => { + const userKey$ = new Subject(); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(SomeUser); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let completed = false; + provider + .userEncryptor$(1, { singleUserId$ }) + .subscribe({ complete: () => (completed = true) }); + + singleUserId$.complete(); + + expect(completed).toBeTrue(); + }); + + it("completes when `userKey$` emits a falsy value after emitting a truthy value", () => { + const userKey$ = new BehaviorSubject(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(SomeUser); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let completed = false; + provider + .userEncryptor$(1, { singleUserId$ }) + .subscribe({ complete: () => (completed = true) }); + + userKey$.next(null); + + expect(completed).toBeTrue(); + }); + + it("completes once `dependencies.singleUserId$` emits and `userKey$` completes", () => { + const userKey$ = new BehaviorSubject(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(SomeUser); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let completed = false; + provider + .userEncryptor$(1, { singleUserId$ }) + .subscribe({ complete: () => (completed = true) }); + + userKey$.complete(); + + expect(completed).toBeTrue(); + }); + }); + + describe("organizationEncryptor$", () => { + it("emits an organization key encryptor bound to the organization", () => { + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new BehaviorSubject< + UserBound<"organizationId", OrganizationId> + >({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = []; + + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe((v) => results.push(v)); + + expect(keyService.orgKeys$).toHaveBeenCalledWith(SomeUser); + expect(results.length).toBe(1); + expect(results[0]).toMatchObject({ + organizationId: SomeOrganization, + encryptor: { + organizationId: SomeOrganization, + key: SomeOrgKey, + dataPacker: { frameSize: 1 }, + }, + }); + expect(results[0].encryptor).toBeInstanceOf(OrganizationKeyEncryptor); + }); + + it("waits until `dependencies.singleOrganizationId$` emits", () => { + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new Subject>(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = []; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe((v) => results.push(v)); + // precondition: no emissions occur on subscribe + expect(results.length).toBe(0); + + singleOrganizationId$.next({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + + expect(results.length).toBe(1); + }); + + it("emits a new organization key encryptor when `dependencies.singleOrganizationId$` emits", () => { + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new Subject>(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = []; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe((v) => results.push(v)); + // precondition: no emissions occur on subscribe + expect(results.length).toBe(0); + + singleOrganizationId$.next({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + singleOrganizationId$.next({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + + expect(results.length).toBe(2); + expect(results[0]).not.toBe(results[1]); + }); + + it("waits until `orgKeys$` emits a truthy value", () => { + const orgKey$ = new BehaviorSubject>(null); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new BehaviorSubject< + UserBound<"organizationId", OrganizationId> + >({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = []; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe((v) => results.push(v)); + // precondition: no emissions occur on subscribe + expect(results.length).toBe(0); + + orgKey$.next(OrgRecords); + + expect(results.length).toBe(1); + expect(results[0]).toMatchObject({ + organizationId: SomeOrganization, + encryptor: { + organizationId: SomeOrganization, + key: SomeOrgKey, + dataPacker: { frameSize: 1 }, + }, + }); + }); + + it("emits an organization key encryptor each time `orgKeys$` emits", () => { + const orgKey$ = new Subject>(); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new BehaviorSubject< + UserBound<"organizationId", OrganizationId> + >({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = []; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe((v) => results.push(v)); + + orgKey$.next(OrgRecords); + orgKey$.next(OrgRecords); + + expect(results.length).toBe(2); + }); + + it("errors when the userId changes", () => { + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new Subject>(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let error: unknown = false; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe({ error: (e: unknown) => (error = e) }); + + singleOrganizationId$.next({ userId: SomeUser, organizationId: SomeOrganization }); + singleOrganizationId$.next({ userId: AnotherUser, organizationId: SomeOrganization }); + + expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: AnotherUser }); + }); + + it("errors when the organizationId changes", () => { + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new Subject>(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let error: unknown = false; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe({ error: (e: unknown) => (error = e) }); + + singleOrganizationId$.next({ userId: SomeUser, organizationId: SomeOrganization }); + singleOrganizationId$.next({ userId: SomeUser, organizationId: AnotherOrganization }); + + expect(error).toEqual({ + expectedOrganizationId: SomeOrganization, + actualOrganizationId: AnotherOrganization, + }); + }); + + it("errors when `dependencies.singleOrganizationId$` errors", () => { + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new Subject>(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let error: unknown = false; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe({ error: (e: unknown) => (error = e) }); + + singleOrganizationId$.error({ some: "error" }); + + expect(error).toEqual({ some: "error" }); + }); + + it("errors once `dependencies.singleOrganizationId$` emits and `orgKeys$` errors", () => { + const orgKey$ = new Subject>(); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new BehaviorSubject< + UserBound<"organizationId", OrganizationId> + >({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let error: unknown = false; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe({ error: (e: unknown) => (error = e) }); + + orgKey$.error({ some: "error" }); + + expect(error).toEqual({ some: "error" }); + }); + + it("errors when the user lacks the requested org key", () => { + const orgKey$ = new BehaviorSubject>({}); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new BehaviorSubject< + UserBound<"organizationId", OrganizationId> + >({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let error: unknown = false; + + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe({ error: (e: unknown) => (error = e) }); + + expect(error).toBeInstanceOf(Error); + }); + + it("completes when `dependencies.singleOrganizationId$` completes", () => { + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new Subject>(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let completed = false; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe({ complete: () => (completed = true) }); + + singleOrganizationId$.complete(); + + expect(completed).toBeTrue(); + }); + + it("completes when `orgKeys$` emits a falsy value after emitting a truthy value", () => { + const orgKey$ = new Subject>(); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new BehaviorSubject< + UserBound<"organizationId", OrganizationId> + >({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let completed = false; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe({ complete: () => (completed = true) }); + + orgKey$.next(OrgRecords); + orgKey$.next(null); + + expect(completed).toBeTrue(); + }); + + it("completes once `dependencies.singleOrganizationId$` emits and `userKey$` completes", () => { + const orgKey$ = new Subject>(); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new BehaviorSubject< + UserBound<"organizationId", OrganizationId> + >({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let completed = false; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe({ complete: () => (completed = true) }); + + orgKey$.complete(); + + expect(completed).toBeTrue(); + }); + }); +}); diff --git a/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.ts b/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.ts new file mode 100644 index 0000000000..f3d6c82ffc --- /dev/null +++ b/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.ts @@ -0,0 +1,132 @@ +import { + connect, + dematerialize, + map, + materialize, + ReplaySubject, + skipWhile, + switchMap, + takeUntil, + takeWhile, +} from "rxjs"; + +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { KeyService } from "@bitwarden/key-management"; + +import { + OrganizationBound, + SingleOrganizationDependency, + SingleUserDependency, + UserBound, +} from "../dependencies"; +import { anyComplete, errorOnChange } from "../rx"; +import { PaddedDataPacker } from "../state/padded-data-packer"; + +import { LegacyEncryptorProvider } from "./legacy-encryptor-provider"; +import { OrganizationEncryptor } from "./organization-encryptor.abstraction"; +import { OrganizationKeyEncryptor } from "./organization-key-encryptor"; +import { UserEncryptor } from "./user-encryptor.abstraction"; +import { UserKeyEncryptor } from "./user-key-encryptor"; + +/** Creates encryptors + */ +export class KeyServiceLegacyEncryptorProvider implements LegacyEncryptorProvider { + /** Instantiates the legacy encryptor provider. + * @param encryptService injected into encryptors to perform encryption + * @param keyService looks up keys for construction into an encryptor + */ + constructor( + private readonly encryptService: EncryptService, + private readonly keyService: KeyService, + ) {} + + userEncryptor$(frameSize: number, dependencies: SingleUserDependency) { + const packer = new PaddedDataPacker(frameSize); + const encryptor$ = dependencies.singleUserId$.pipe( + errorOnChange( + (userId) => userId, + (expectedUserId, actualUserId) => ({ expectedUserId, actualUserId }), + ), + connect((singleUserId$) => { + const singleUserId = new ReplaySubject(1); + singleUserId$.subscribe(singleUserId); + + return singleUserId.pipe( + switchMap((userId) => + this.keyService.userKey$(userId).pipe( + // wait until the key becomes available + skipWhile((key) => !key), + // complete when the key becomes unavailable + takeWhile((key) => !!key), + map((key) => { + const encryptor = new UserKeyEncryptor(userId, this.encryptService, key, packer); + + return { userId, encryptor } satisfies UserBound<"encryptor", UserEncryptor>; + }), + materialize(), + ), + ), + dematerialize(), + takeUntil(anyComplete(singleUserId)), + ); + }), + ); + + return encryptor$; + } + + organizationEncryptor$(frameSize: number, dependencies: SingleOrganizationDependency) { + const packer = new PaddedDataPacker(frameSize); + const encryptor$ = dependencies.singleOrganizationId$.pipe( + errorOnChange( + (pair) => pair.userId, + (expectedUserId, actualUserId) => ({ expectedUserId, actualUserId }), + ), + errorOnChange( + (pair) => pair.organizationId, + (expectedOrganizationId, actualOrganizationId) => ({ + expectedOrganizationId, + actualOrganizationId, + }), + ), + connect((singleOrganizationId$) => { + const singleOrganizationId = new ReplaySubject>( + 1, + ); + singleOrganizationId$.subscribe(singleOrganizationId); + + return singleOrganizationId.pipe( + switchMap((pair) => + this.keyService.orgKeys$(pair.userId).pipe( + // wait until the key becomes available + skipWhile((keys) => !keys), + // complete when the key becomes unavailable + takeWhile((keys) => !!keys), + map((keys) => { + const organizationId = pair.organizationId; + const key = keys[organizationId]; + const encryptor = new OrganizationKeyEncryptor( + organizationId, + this.encryptService, + key, + packer, + ); + + return { organizationId, encryptor } satisfies OrganizationBound< + "encryptor", + OrganizationEncryptor + >; + }), + materialize(), + ), + ), + dematerialize(), + takeUntil(anyComplete(singleOrganizationId)), + ); + }), + ); + + return encryptor$; + } +} diff --git a/libs/common/src/tools/cryptography/legacy-encryptor-provider.ts b/libs/common/src/tools/cryptography/legacy-encryptor-provider.ts new file mode 100644 index 0000000000..5e83cb0671 --- /dev/null +++ b/libs/common/src/tools/cryptography/legacy-encryptor-provider.ts @@ -0,0 +1,42 @@ +import { Observable } from "rxjs"; + +import { + OrganizationBound, + SingleOrganizationDependency, + SingleUserDependency, + UserBound, +} from "../dependencies"; + +import { OrganizationEncryptor } from "./organization-encryptor.abstraction"; +import { UserEncryptor } from "./user-encryptor.abstraction"; + +/** Creates encryptors + * @deprecated this logic will soon be replaced with a design that provides for + * key rotation. Use it at your own risk + */ +export abstract class LegacyEncryptorProvider { + /** Retrieves an encryptor populated with the user's most recent key instance that + * uses a padded data packer to encode data. + * @param frameSize length of the padded data packer's frames. + * @param dependencies.singleUserId$ identifies the user to which the encryptor is bound + * @returns an observable that emits when the key becomes available and completes + * when the key becomes unavailable. + */ + userEncryptor$: ( + frameSize: number, + dependencies: SingleUserDependency, + ) => Observable>; + + /** Retrieves an encryptor populated with the organization's most recent key instance that + * uses a padded data packer to encode data. + * @param frameSize length of the padded data packer's frames. + * @param dependencies.singleOrganizationId$ identifies the user/org combination + * to which the encryptor is bound. + * @returns an observable that emits when the key becomes available and completes + * when the key becomes unavailable. + */ + organizationEncryptor$: ( + frameSize: number, + dependences: SingleOrganizationDependency, + ) => Observable>; +} diff --git a/libs/common/src/tools/cryptography/organization-encryptor.abstraction.ts b/libs/common/src/tools/cryptography/organization-encryptor.abstraction.ts new file mode 100644 index 0000000000..6884cdf38a --- /dev/null +++ b/libs/common/src/tools/cryptography/organization-encryptor.abstraction.ts @@ -0,0 +1,33 @@ +import { Jsonify } from "type-fest"; + +import { OrganizationId } from "@bitwarden/common/types/guid"; + +import { EncString } from "../../platform/models/domain/enc-string"; + +/** An encryption strategy that protects a type's secrets with + * organization-specific keys. This strategy is bound to a specific organization. + */ +export abstract class OrganizationEncryptor { + /** Identifies the organization bound to the encryptor. */ + readonly organizationId: OrganizationId; + + /** Protects secrets in `value` with an organization-specific key. + * @param secret the object to protect. This object is mutated during encryption. + * @returns a promise that resolves to a tuple. The tuple's first property contains + * the encrypted secret and whose second property contains an object w/ disclosed + * properties. + * @throws If `value` is `null` or `undefined`, the promise rejects with an error. + */ + abstract encrypt(secret: Jsonify): Promise; + + /** Combines protected secrets and disclosed data into a type that can be + * rehydrated into a domain object. + * @param secret an encrypted JSON payload containing encrypted secrets. + * @returns a promise that resolves to the raw state. This state *is not* a + * class. It contains only data that can be round-tripped through JSON, + * and lacks members such as a prototype or bound functions. + * @throws If `secret` or `disclosed` is `null` or `undefined`, the promise + * rejects with an error. + */ + abstract decrypt(secret: EncString): Promise>; +} diff --git a/libs/common/src/tools/cryptography/organization-key-encryptor.spec.ts b/libs/common/src/tools/cryptography/organization-key-encryptor.spec.ts new file mode 100644 index 0000000000..62c8ea24ae --- /dev/null +++ b/libs/common/src/tools/cryptography/organization-key-encryptor.spec.ts @@ -0,0 +1,125 @@ +import { mock } from "jest-mock-extended"; + +import { EncryptService } from "../../platform/abstractions/encrypt.service"; +import { EncString } from "../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "../../types/csprng"; +import { OrganizationId } from "../../types/guid"; +import { OrgKey } from "../../types/key"; +import { DataPacker } from "../state/data-packer.abstraction"; + +import { OrganizationKeyEncryptor } from "./organization-key-encryptor"; + +describe("OrgKeyEncryptor", () => { + const encryptService = mock(); + const dataPacker = mock(); + const orgKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as OrgKey; + const anyOrgId = "foo" as OrganizationId; + + beforeEach(() => { + // The OrgKeyEncryptor is, in large part, a facade coordinating a handful of worker + // objects, so its tests focus on how data flows between components. The defaults rely + // on this property--that the facade treats its data like a opaque objects--to trace + // the data through several function calls. Should the encryptor interact with the + // objects themselves, these mocks will break. + encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString)); + encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c as unknown as string)); + dataPacker.pack.mockImplementation((v) => v as string); + dataPacker.unpack.mockImplementation((v: string) => v as T); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("constructor", () => { + it("should set organizationId", async () => { + const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, dataPacker); + expect(encryptor.organizationId).toEqual(anyOrgId); + }); + + it("should throw if organizationId was not supplied", async () => { + expect(() => new OrganizationKeyEncryptor(null, encryptService, orgKey, dataPacker)).toThrow( + "organizationId cannot be null or undefined", + ); + expect(() => new OrganizationKeyEncryptor(null, encryptService, orgKey, dataPacker)).toThrow( + "organizationId cannot be null or undefined", + ); + }); + + it("should throw if encryptService was not supplied", async () => { + expect(() => new OrganizationKeyEncryptor(anyOrgId, null, orgKey, dataPacker)).toThrow( + "encryptService cannot be null or undefined", + ); + expect(() => new OrganizationKeyEncryptor(anyOrgId, null, orgKey, dataPacker)).toThrow( + "encryptService cannot be null or undefined", + ); + }); + + it("should throw if key was not supplied", async () => { + expect( + () => new OrganizationKeyEncryptor(anyOrgId, encryptService, null, dataPacker), + ).toThrow("key cannot be null or undefined"); + expect( + () => new OrganizationKeyEncryptor(anyOrgId, encryptService, null, dataPacker), + ).toThrow("key cannot be null or undefined"); + }); + + it("should throw if dataPacker was not supplied", async () => { + expect(() => new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, null)).toThrow( + "dataPacker cannot be null or undefined", + ); + expect(() => new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, null)).toThrow( + "dataPacker cannot be null or undefined", + ); + }); + }); + + describe("encrypt", () => { + it("should throw if value was not supplied", async () => { + const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, dataPacker); + + await expect(encryptor.encrypt>(null)).rejects.toThrow( + "secret cannot be null or undefined", + ); + await expect(encryptor.encrypt>(undefined)).rejects.toThrow( + "secret cannot be null or undefined", + ); + }); + + it("should encrypt a packed value using the organization's key", async () => { + const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, dataPacker); + const value = { foo: true }; + + const result = await encryptor.encrypt(value); + + // these are data flow expectations; the operations all all pass-through mocks + expect(dataPacker.pack).toHaveBeenCalledWith(value); + expect(encryptService.encrypt).toHaveBeenCalledWith(value, orgKey); + expect(result).toBe(value); + }); + }); + + describe("decrypt", () => { + it("should throw if secret was not supplied", async () => { + const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, dataPacker); + + await expect(encryptor.decrypt(null)).rejects.toThrow("secret cannot be null or undefined"); + await expect(encryptor.decrypt(undefined)).rejects.toThrow( + "secret cannot be null or undefined", + ); + }); + + it("should declassify a decrypted packed value using the organization's key", async () => { + const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, dataPacker); + const secret = "encrypted" as any; + + const result = await encryptor.decrypt(secret); + + // these are data flow expectations; the operations all all pass-through mocks + expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(secret, orgKey); + expect(dataPacker.unpack).toHaveBeenCalledWith(secret); + expect(result).toBe(secret); + }); + }); +}); diff --git a/libs/common/src/tools/cryptography/organization-key-encryptor.ts b/libs/common/src/tools/cryptography/organization-key-encryptor.ts new file mode 100644 index 0000000000..5bd7e36ee2 --- /dev/null +++ b/libs/common/src/tools/cryptography/organization-key-encryptor.ts @@ -0,0 +1,60 @@ +import { Jsonify } from "type-fest"; + +import { OrganizationId } from "@bitwarden/common/types/guid"; + +import { EncryptService } from "../../platform/abstractions/encrypt.service"; +import { EncString } from "../../platform/models/domain/enc-string"; +import { OrgKey } from "../../types/key"; +import { DataPacker } from "../state/data-packer.abstraction"; + +import { OrganizationEncryptor } from "./organization-encryptor.abstraction"; + +/** A classification strategy that protects a type's secrets by encrypting them + * with an `OrgKey` + */ +export class OrganizationKeyEncryptor extends OrganizationEncryptor { + /** Instantiates the encryptor + * @param organizationId identifies the organization bound to the encryptor. + * @param encryptService protects properties of `Secret`. + * @param key the key instance protecting the data. + * @param dataPacker packs and unpacks data classified as secrets. + */ + constructor( + readonly organizationId: OrganizationId, + private readonly encryptService: EncryptService, + private readonly key: OrgKey, + private readonly dataPacker: DataPacker, + ) { + super(); + this.assertHasValue("organizationId", organizationId); + this.assertHasValue("key", key); + this.assertHasValue("dataPacker", dataPacker); + this.assertHasValue("encryptService", encryptService); + } + + async encrypt(secret: Jsonify): Promise { + this.assertHasValue("secret", secret); + + let packed = this.dataPacker.pack(secret); + const encrypted = await this.encryptService.encrypt(packed, this.key); + packed = null; + + return encrypted; + } + + async decrypt(secret: EncString): Promise> { + this.assertHasValue("secret", secret); + + let decrypted = await this.encryptService.decryptToUtf8(secret, this.key); + const unpacked = this.dataPacker.unpack(decrypted); + decrypted = null; + + return unpacked; + } + + private assertHasValue(name: string, value: any) { + if (value === undefined || value === null) { + throw new Error(`${name} cannot be null or undefined`); + } + } +} diff --git a/libs/common/src/tools/state/user-encryptor.abstraction.ts b/libs/common/src/tools/cryptography/user-encryptor.abstraction.ts similarity index 100% rename from libs/common/src/tools/state/user-encryptor.abstraction.ts rename to libs/common/src/tools/cryptography/user-encryptor.abstraction.ts diff --git a/libs/common/src/tools/state/user-key-encryptor.spec.ts b/libs/common/src/tools/cryptography/user-key-encryptor.spec.ts similarity index 98% rename from libs/common/src/tools/state/user-key-encryptor.spec.ts rename to libs/common/src/tools/cryptography/user-key-encryptor.spec.ts index 37c1155488..5b0ee5103c 100644 --- a/libs/common/src/tools/state/user-key-encryptor.spec.ts +++ b/libs/common/src/tools/cryptography/user-key-encryptor.spec.ts @@ -6,8 +6,8 @@ import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypt import { CsprngArray } from "../../types/csprng"; import { UserId } from "../../types/guid"; import { UserKey } from "../../types/key"; +import { DataPacker } from "../state/data-packer.abstraction"; -import { DataPacker } from "./data-packer.abstraction"; import { UserKeyEncryptor } from "./user-key-encryptor"; describe("UserKeyEncryptor", () => { diff --git a/libs/common/src/tools/state/user-key-encryptor.ts b/libs/common/src/tools/cryptography/user-key-encryptor.ts similarity index 96% rename from libs/common/src/tools/state/user-key-encryptor.ts rename to libs/common/src/tools/cryptography/user-key-encryptor.ts index d0316636d2..b2ccc51301 100644 --- a/libs/common/src/tools/state/user-key-encryptor.ts +++ b/libs/common/src/tools/cryptography/user-key-encryptor.ts @@ -5,8 +5,8 @@ import { UserId } from "@bitwarden/common/types/guid"; import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { EncString } from "../../platform/models/domain/enc-string"; import { UserKey } from "../../types/key"; +import { DataPacker } from "../state/data-packer.abstraction"; -import { DataPacker } from "./data-packer.abstraction"; import { UserEncryptor } from "./user-encryptor.abstraction"; /** A classification strategy that protects a type's secrets by encrypting them diff --git a/libs/common/src/tools/dependencies.ts b/libs/common/src/tools/dependencies.ts index 84e2f53fa2..cdae45bc94 100644 --- a/libs/common/src/tools/dependencies.ts +++ b/libs/common/src/tools/dependencies.ts @@ -1,9 +1,10 @@ import { Observable } from "rxjs"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { UserEncryptor } from "./state/user-encryptor.abstraction"; +import { OrganizationEncryptor } from "./cryptography/organization-encryptor.abstraction"; +import { UserEncryptor } from "./cryptography/user-encryptor.abstraction"; /** error emitted when the `SingleUserDependency` changes Ids */ export type UserChangedError = { @@ -13,6 +14,14 @@ export type UserChangedError = { actualUserId: UserId; }; +/** error emitted when the `SingleOrganizationDependency` changes Ids */ +export type OrganizationChangedError = { + /** the organizationId pinned by the single organization dependency */ + expectedOrganizationId: OrganizationId; + /** the organizationId received in error */ + actualOrganizationId: OrganizationId; +}; + /** A pattern for types that depend upon a dynamic policy stream and return * an observable. * @@ -55,6 +64,54 @@ export type UserBound = { [P in K]: T } & { userId: UserId; }; +/** Decorates a type to indicate the organization, if any, that the type is usable only by + * a specific organization. + */ +export type OrganizationBound = { [P in K]: T } & { + /** The organization to which T is bound. */ + organizationId: OrganizationId; +}; + +/** A pattern for types that depend upon a fixed-key encryptor and return + * an observable. + * + * Consumers of this dependency should emit a `OrganizationChangedError` if + * the bound OrganizationId changes or if the encryptor changes. If + * `singleOrganizationEncryptor$` completes, the consumer should complete + * once all events received prior to the completion event are + * finished processing. The consumer should, where possible, + * prioritize these events in order to complete as soon as possible. + * If `singleOrganizationEncryptor$` emits an unrecoverable error, the consumer + * should also emit the error. + */ +export type SingleOrganizationEncryptorDependency = { + /** A stream that emits an encryptor when subscribed and the org key + * is available, and completes when the org key is no longer available. + * The stream should not emit null or undefined. + */ + singleOrgEncryptor$: Observable>; +}; + +/** A pattern for types that depend upon a fixed-value organizationId and return + * an observable. + * + * Consumers of this dependency should emit a `OrganizationChangedError` if + * the value of `singleOrganizationId$` changes. If `singleOrganizationId$` completes, + * the consumer should also complete. If `singleOrganizationId$` errors, the + * consumer should also emit the error. + * + * @remarks Check the consumer's documentation to determine how it + * responds to repeat emissions. + */ +export type SingleOrganizationDependency = { + /** A stream that emits an organization Id and the user to which it is bound + * when subscribed and the user's account is unlocked, and completes when the + * account is locked or logged out. + * The stream should not emit null or undefined. + */ + singleOrganizationId$: Observable>; +}; + /** A pattern for types that depend upon a fixed-key encryptor and return * an observable. * diff --git a/libs/common/src/tools/rx.spec.ts b/libs/common/src/tools/rx.spec.ts index f6932f01dc..9ce147a3ff 100644 --- a/libs/common/src/tools/rx.spec.ts +++ b/libs/common/src/tools/rx.spec.ts @@ -8,6 +8,7 @@ import { awaitAsync, trackEmissions } from "../../spec"; import { anyComplete, + errorOnChange, distinctIfShallowMatch, on, ready, @@ -15,6 +16,104 @@ import { withLatestReady, } from "./rx"; +describe("errorOnChange", () => { + it("emits a single value when the input emits only once", async () => { + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(errorOnChange()).subscribe((v) => results.push(v)); + + source$.next(1); + + expect(results).toEqual([1]); + }); + + it("emits when the input emits", async () => { + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(errorOnChange()).subscribe((v) => results.push(v)); + + source$.next(1); + source$.next(1); + + expect(results).toEqual([1, 1]); + }); + + it("errors when the input errors", async () => { + const source$ = new Subject(); + const expected = {}; + let error: any = null; + source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) }); + + source$.error(expected); + + expect(error).toBe(expected); + }); + + it("completes when the input completes", async () => { + const source$ = new Subject(); + let complete: boolean = false; + source$.pipe(errorOnChange()).subscribe({ complete: () => (complete = true) }); + + source$.complete(); + + expect(complete).toBeTrue(); + }); + + it("errors when the input changes", async () => { + const source$ = new Subject(); + let error: any = null; + source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) }); + + source$.next(1); + source$.next(2); + + expect(error).toEqual({ expectedValue: 1, actualValue: 2 }); + }); + + it("emits when the extracted value remains constant", async () => { + type Foo = { foo: string }; + const source$ = new Subject(); + const results: Foo[] = []; + source$.pipe(errorOnChange((v) => v.foo)).subscribe((v) => results.push(v)); + + source$.next({ foo: "bar" }); + source$.next({ foo: "bar" }); + + expect(results).toEqual([{ foo: "bar" }, { foo: "bar" }]); + }); + + it("errors when an extracted value changes", async () => { + type Foo = { foo: string }; + const source$ = new Subject(); + let error: any = null; + source$.pipe(errorOnChange((v) => v.foo)).subscribe({ error: (v: unknown) => (error = v) }); + + source$.next({ foo: "bar" }); + source$.next({ foo: "baz" }); + + expect(error).toEqual({ expectedValue: "bar", actualValue: "baz" }); + }); + + it("constructs an error when the extracted value changes", async () => { + type Foo = { foo: string }; + const source$ = new Subject(); + let error: any = null; + source$ + .pipe( + errorOnChange( + (v) => v.foo, + (expected, actual) => ({ expected, actual }), + ), + ) + .subscribe({ error: (v: unknown) => (error = v) }); + + source$.next({ foo: "bar" }); + source$.next({ foo: "baz" }); + + expect(error).toEqual({ expected: "bar", actual: "baz" }); + }); +}); + describe("reduceCollection", () => { it.each([[null], [undefined], [[]]])( "should return the default value when the collection is %p", diff --git a/libs/common/src/tools/rx.ts b/libs/common/src/tools/rx.ts index d5d0b499ff..5c4f6a0a70 100644 --- a/libs/common/src/tools/rx.ts +++ b/libs/common/src/tools/rx.ts @@ -15,8 +15,60 @@ import { takeUntil, withLatestFrom, concatMap, + startWith, + pairwise, } from "rxjs"; +/** Returns its input. */ +function identity(value: any): any { + return value; +} + +/** Combines its arguments into a plain old javascript object. */ +function expectedAndActualValue(expectedValue: any, actualValue: any) { + return { + expectedValue, + actualValue, + }; +} + +/** + * An observable operator that throws an error when the stream's + * value changes. Uses strict (`===`) comparison checks. + * @param extract a function that identifies the member to compare; + * defaults to the identity function + * @param error a function that packages the expected and failed + * values into an error. + * @returns a stream of values that emits when the input emits, + * completes when the input completes, and errors when either the + * input errors or the comparison fails. + */ +export function errorOnChange( + extract: (value: Input) => Extracted = identity, + error: (expectedValue: Extracted, actualValue: Extracted) => unknown = expectedAndActualValue, +): OperatorFunction { + return pipe( + startWith(null), + pairwise(), + map(([expected, actual], i) => { + // always let the first value through + if (i === 0) { + return actual; + } + + const expectedValue = extract(expected); + const actualValue = extract(actual); + + // fail the stream if the state desyncs from its initial value + if (expectedValue === actualValue) { + return actual; + } else { + throw error(expectedValue, actualValue); + } + }), + ); +} + /** * An observable operator that reduces an emitted collection to a single object, * returning a default if all items are ignored. diff --git a/libs/common/src/tools/state/object-key.ts b/libs/common/src/tools/state/object-key.ts index 0593186ec4..260a2412b2 100644 --- a/libs/common/src/tools/state/object-key.ts +++ b/libs/common/src/tools/state/object-key.ts @@ -5,6 +5,17 @@ import type { StateDefinition } from "../../platform/state/state-definition"; import { ClassifiedFormat } from "./classified-format"; import { Classifier } from "./classifier"; +/** Determines the format of persistent storage. + * `plain` storage is a plain-old javascript object. Use this type + * when you are performing your own encryption and decryption. + * `classified` uses the `ClassifiedFormat` type as its format. + * `secret-state` uses `Array` with a length of 1. + * @remarks - CAUTION! If your on-disk data is not in a correct format, + * the storage system treats the data as corrupt and returns your initial + * value. + */ +export type ObjectStorageFormat = "plain" | "classified" | "secret-state"; + /** A key for storing JavaScript objects (`{ an: "example" }`) * in a UserStateSubject. */ @@ -20,7 +31,7 @@ export type ObjectKey> key: string; state: StateDefinition; classifier: Classifier; - format: "plain" | "classified"; + format: ObjectStorageFormat; options: UserKeyDefinitionOptions; initial?: State; }; @@ -47,6 +58,18 @@ export function toUserKeyDefinition( }, ); + return classified; + } else if (key.format === "secret-state") { + const classified = new UserKeyDefinition<[ClassifiedFormat]>( + key.state, + key.key, + { + cleanupDelayMs: key.options.cleanupDelayMs, + deserializer: (jsonValue) => jsonValue as [ClassifiedFormat], + clearOn: key.options.clearOn, + }, + ); + return classified; } else { throw new Error(`unknown format: ${key.format}`); diff --git a/libs/common/src/tools/state/secret-state.spec.ts b/libs/common/src/tools/state/secret-state.spec.ts index d4727492b3..5f679644fc 100644 --- a/libs/common/src/tools/state/secret-state.spec.ts +++ b/libs/common/src/tools/state/secret-state.spec.ts @@ -11,11 +11,11 @@ import { import { EncString } from "../../platform/models/domain/enc-string"; import { GENERATOR_DISK } from "../../platform/state"; import { UserId } from "../../types/guid"; +import { UserEncryptor } from "../cryptography/user-encryptor.abstraction"; import { SecretClassifier } from "./secret-classifier"; import { SecretKeyDefinition } from "./secret-key-definition"; import { SecretState } from "./secret-state"; -import { UserEncryptor } from "./user-encryptor.abstraction"; type FooBar = { foo: boolean; bar: boolean; date?: Date }; const classifier = SecretClassifier.allSecret(); diff --git a/libs/common/src/tools/state/secret-state.ts b/libs/common/src/tools/state/secret-state.ts index 45ce855cc8..fe7c025ccf 100644 --- a/libs/common/src/tools/state/secret-state.ts +++ b/libs/common/src/tools/state/secret-state.ts @@ -8,10 +8,10 @@ import { CombinedState, } from "../../platform/state"; import { UserId } from "../../types/guid"; +import { UserEncryptor } from "../cryptography/user-encryptor.abstraction"; import { ClassifiedFormat } from "./classified-format"; import { SecretKeyDefinition } from "./secret-key-definition"; -import { UserEncryptor } from "./user-encryptor.abstraction"; const ONE_MINUTE = 1000 * 60; diff --git a/libs/common/src/tools/state/user-state-subject.spec.ts b/libs/common/src/tools/state/user-state-subject.spec.ts index ee78a5c048..6a50a1dd66 100644 --- a/libs/common/src/tools/state/user-state-subject.spec.ts +++ b/libs/common/src/tools/state/user-state-subject.spec.ts @@ -4,13 +4,13 @@ import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/st import { UserId } from "@bitwarden/common/types/guid"; import { awaitAsync, FakeSingleUserState, ObservableTracker } from "../../../spec"; +import { UserEncryptor } from "../cryptography/user-encryptor.abstraction"; import { UserBound } from "../dependencies"; import { PrivateClassifier } from "../private-classifier"; import { StateConstraints } from "../types"; import { ClassifiedFormat } from "./classified-format"; import { ObjectKey } from "./object-key"; -import { UserEncryptor } from "./user-encryptor.abstraction"; import { UserStateSubject } from "./user-state-subject"; const SomeUser = "some user" as UserId; @@ -734,6 +734,7 @@ describe("UserStateSubject", () => { error = e as any; }, }); + singleUserEncryptor$.next({ userId: SomeUser, encryptor: SomeEncryptor }); singleUserEncryptor$.next({ userId: errorUserId, encryptor: SomeEncryptor }); await awaitAsync(); diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index 0b562cc7a1..4a2dab1234 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -6,10 +6,8 @@ import { filter, map, takeUntil, - pairwise, distinctUntilChanged, BehaviorSubject, - startWith, Observable, Subscription, last, @@ -30,15 +28,15 @@ import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SingleUserState, UserKeyDefinition } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; +import { UserEncryptor } from "../cryptography/user-encryptor.abstraction"; import { UserBound } from "../dependencies"; -import { anyComplete, ready, withLatestReady } from "../rx"; +import { anyComplete, errorOnChange, ready, withLatestReady } from "../rx"; import { Constraints, SubjectConstraints, WithConstraints } from "../types"; import { ClassifiedFormat, isClassifiedFormat } from "./classified-format"; import { unconstrained$ } from "./identity-state-constraint"; import { isObjectKey, ObjectKey, toUserKeyDefinition } from "./object-key"; import { isDynamic } from "./state-constraints-dependency"; -import { UserEncryptor } from "./user-encryptor.abstraction"; import { UserStateSubjectDependencies } from "./user-state-subject-dependencies"; type Constrained = { constraints: Readonly>; state: State }; @@ -195,24 +193,13 @@ export class UserStateSubject< } }), // fail the stream if the state desyncs from the bound userId - startWith({ userId: this.state.userId, encryptor: null } as UserBound< - "encryptor", - UserEncryptor - >), - pairwise(), - map(([expected, actual]) => { - if (expected.userId === actual.userId) { - return actual; - } else { - throw { - expectedUserId: expected.userId, - actualUserId: actual.userId, - }; - } - }), + errorOnChange( + ({ userId }) => userId, + (expectedUserId, actualUserId) => ({ expectedUserId, actualUserId }), + ), // reduce emissions to when encryptor changes - distinctUntilChanged(), map(({ encryptor }) => encryptor), + distinctUntilChanged(), ); } @@ -317,36 +304,63 @@ export class UserStateSubject< return (input$) => input$ as Observable; } - // if the key supports encryption, enable encryptor support + // all other keys support encryption; enable encryptor support + return pipe( + this.mapToClassifiedFormat(), + combineLatestWith(encryptor$), + concatMap(async ([input, encryptor]) => { + // pass through null values + if (input === null || input === undefined) { + return null; + } + + // decrypt classified data + const { secret, disclosed } = input; + const encrypted = EncString.fromJSON(secret); + const decryptedSecret = await encryptor.decrypt(encrypted); + + // assemble into proper state + const declassified = this.objectKey.classifier.declassify(disclosed, decryptedSecret); + const state = this.objectKey.options.deserializer(declassified); + + return state; + }), + ); + } + + private mapToClassifiedFormat(): OperatorFunction> { + // FIXME: warn when data is dropped in the console and/or report an error + // through the observable; consider redirecting dropped data to a recovery + // location + + // user-state subject's default format is object-aware if (this.objectKey && this.objectKey.format === "classified") { - return pipe( - combineLatestWith(encryptor$), - concatMap(async ([input, encryptor]) => { - // pass through null values - if (input === null || input === undefined) { - return null; - } + return map((input) => { + if (!isClassifiedFormat(input)) { + return null; + } - // fail fast if the format is incorrect - if (!isClassifiedFormat(input)) { - throw new Error(`Cannot declassify ${this.key.key}; unknown format.`); - } - - // decrypt classified data - const { secret, disclosed } = input; - const encrypted = EncString.fromJSON(secret); - const decryptedSecret = await encryptor.decrypt(encrypted); - - // assemble into proper state - const declassified = this.objectKey.classifier.declassify(disclosed, decryptedSecret); - const state = this.objectKey.options.deserializer(declassified); - - return state; - }), - ); + return input; + }); } - throw new Error(`unknown serialization format: ${this.objectKey.format}`); + // secret state's format wraps objects in an array + if (this.objectKey && this.objectKey.format === "secret-state") { + return map((input) => { + if (!Array.isArray(input)) { + return null; + } + + const [unwrapped] = input; + if (!isClassifiedFormat(unwrapped)) { + return null; + } + + return unwrapped; + }); + } + + throw new Error(`unsupported serialization format: ${this.objectKey.format}`); } private classify(encryptor$: Observable): OperatorFunction { @@ -359,41 +373,49 @@ export class UserStateSubject< ); } - // if the key supports encryption, enable encryptor support + // all other keys support encryption; enable encryptor support + return pipe( + withLatestReady(encryptor$), + concatMap(async ([input, encryptor]) => { + // fail fast if there's no value + if (input === null || input === undefined) { + return null; + } + + // split data by classification level + const serialized = JSON.parse(JSON.stringify(input)); + const classified = this.objectKey.classifier.classify(serialized); + + // protect data + const encrypted = await encryptor.encrypt(classified.secret); + const secret = JSON.parse(JSON.stringify(encrypted)); + + // wrap result in classified format envelope for storage + const envelope = { + id: null as void, + secret, + disclosed: classified.disclosed, + } satisfies ClassifiedFormat; + + // deliberate type erasure; the type is restored during `declassify` + return envelope as ClassifiedFormat; + }), + this.mapToStorageFormat(), + ); + } + + private mapToStorageFormat(): OperatorFunction, unknown> { + // user-state subject's default format is object-aware if (this.objectKey && this.objectKey.format === "classified") { - return pipe( - withLatestReady(encryptor$), - concatMap(async ([input, encryptor]) => { - // fail fast if there's no value - if (input === null || input === undefined) { - return null; - } - - // split data by classification level - const serialized = JSON.parse(JSON.stringify(input)); - const classified = this.objectKey.classifier.classify(serialized); - - // protect data - const encrypted = await encryptor.encrypt(classified.secret); - const secret = JSON.parse(JSON.stringify(encrypted)); - - // wrap result in classified format envelope for storage - const envelope = { - id: null as void, - secret, - disclosed: classified.disclosed, - } satisfies ClassifiedFormat; - - // deliberate type erasure; the type is restored during `declassify` - return envelope as unknown; - }), - ); + return map((input) => input as unknown); } - // FIXME: add "encrypted" format --> key contains encryption logic - // CONSIDER: should "classified format" algorithm be embedded in subject keys...? + // secret state's format wraps objects in an array + if (this.objectKey && this.objectKey.format === "secret-state") { + return map((input) => [input] as unknown); + } - throw new Error(`unknown serialization format: ${this.objectKey.format}`); + throw new Error(`unsupported serialization format: ${this.objectKey.format}`); } /** The userId to which the subject is bound. diff --git a/libs/tools/generator/components/src/forwarder-settings.component.html b/libs/tools/generator/components/src/forwarder-settings.component.html index 0e15c2e89a..d610f53d59 100644 --- a/libs/tools/generator/components/src/forwarder-settings.component.html +++ b/libs/tools/generator/components/src/forwarder-settings.component.html @@ -12,7 +12,7 @@ {{ "apiKey" | i18n }} - + - + {{ "typeLogin" | i18n }} - + {{ "typeCard" | i18n }} - + {{ "typeIdentity" | i18n }} - + {{ "note" | i18n }} diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts index 6842f35ea6..b5dc2a2f03 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts @@ -1,141 +1,163 @@ import { CommonModule } from "@angular/common"; import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { Router } from "@angular/router"; +import { ActivatedRoute, RouterLink } from "@angular/router"; +import { mock } from "jest-mock-extended"; -import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; -import { ButtonModule, DialogService, MenuModule } from "@bitwarden/components"; +import { ButtonModule, DialogService, MenuModule, NoItemsModule } from "@bitwarden/components"; import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; -import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; -import { AddEditFolderDialogComponent } from "../add-edit-folder-dialog/add-edit-folder-dialog.component"; import { NewItemDropdownV2Component, NewItemInitialValues } from "./new-item-dropdown-v2.component"; describe("NewItemDropdownV2Component", () => { let component: NewItemDropdownV2Component; let fixture: ComponentFixture; - const open = jest.fn(); - const navigate = jest.fn(); + let dialogServiceMock: jest.Mocked; + let browserApiMock: jest.Mocked; - jest - .spyOn(BrowserApi, "getTabFromCurrentWindow") - .mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab); + const mockTab = { url: "https://example.com" }; + + beforeAll(() => { + jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(mockTab as chrome.tabs.Tab); + jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false); + jest.spyOn(Utils, "getHostname").mockReturnValue("example.com"); + }); beforeEach(async () => { - open.mockClear(); - navigate.mockClear(); + dialogServiceMock = mock(); + dialogServiceMock.open.mockClear(); + + const activatedRouteMock = { + snapshot: { paramMap: { get: jest.fn() } }, + }; + + const i18nServiceMock = mock(); + const folderServiceMock = mock(); + const folderApiServiceAbstractionMock = mock(); + const accountServiceMock = mock(); await TestBed.configureTestingModule({ - imports: [NewItemDropdownV2Component, MenuModule, ButtonModule, JslibModule, CommonModule], - providers: [ - { provide: I18nService, useValue: { t: (key: string) => key } }, - { provide: Router, useValue: { navigate } }, + imports: [ + CommonModule, + RouterLink, + ButtonModule, + MenuModule, + NoItemsModule, + NewItemDropdownV2Component, ], - }) - .overrideProvider(DialogService, { useValue: { open } }) - .compileComponents(); + providers: [ + { provide: DialogService, useValue: dialogServiceMock }, + { provide: I18nService, useValue: i18nServiceMock }, + { provide: ActivatedRoute, useValue: activatedRouteMock }, + { provide: BrowserApi, useValue: browserApiMock }, + { provide: FolderService, useValue: folderServiceMock }, + { provide: FolderApiServiceAbstraction, useValue: folderApiServiceAbstractionMock }, + { provide: AccountService, useValue: accountServiceMock }, + ], + }).compileComponents(); + }); + beforeEach(() => { fixture = TestBed.createComponent(NewItemDropdownV2Component); component = fixture.componentInstance; fixture.detectChanges(); }); - it("opens new folder dialog", () => { - component.openFolderDialog(); - - expect(open).toHaveBeenCalledWith(AddEditFolderDialogComponent); - }); - - describe("new item", () => { - const emptyParams: AddEditQueryParams = { - collectionId: undefined, - organizationId: undefined, - folderId: undefined, - }; - - beforeEach(() => { - jest.spyOn(component, "newItemNavigate"); - }); - - it("navigates to new login", async () => { - await component.newItemNavigate(CipherType.Login); - - expect(navigate).toHaveBeenCalledWith(["/add-cipher"], { - queryParams: { - type: CipherType.Login.toString(), - name: "example.com", - uri: "https://example.com", - ...emptyParams, - }, - }); - }); - - it("navigates to new card", async () => { - await component.newItemNavigate(CipherType.Card); - - expect(navigate).toHaveBeenCalledWith(["/add-cipher"], { - queryParams: { type: CipherType.Card.toString(), ...emptyParams }, - }); - }); - - it("navigates to new identity", async () => { - await component.newItemNavigate(CipherType.Identity); - - expect(navigate).toHaveBeenCalledWith(["/add-cipher"], { - queryParams: { type: CipherType.Identity.toString(), ...emptyParams }, - }); - }); - - it("navigates to new note", async () => { - await component.newItemNavigate(CipherType.SecureNote); - - expect(navigate).toHaveBeenCalledWith(["/add-cipher"], { - queryParams: { type: CipherType.SecureNote.toString(), ...emptyParams }, - }); - }); - - it("includes initial values", async () => { + describe("buildQueryParams", () => { + it("should build query params for a Login cipher when not popped out", async () => { + await component.ngOnInit(); component.initialValues = { folderId: "222-333-444", organizationId: "444-555-666", collectionId: "777-888-999", } as NewItemInitialValues; - await component.newItemNavigate(CipherType.Login); + jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false); + jest.spyOn(Utils, "getHostname").mockReturnValue("example.com"); - expect(navigate).toHaveBeenCalledWith(["/add-cipher"], { - queryParams: { - type: CipherType.Login.toString(), - folderId: "222-333-444", - organizationId: "444-555-666", - collectionId: "777-888-999", - uri: "https://example.com", - name: "example.com", - }, + const params = component.buildQueryParams(CipherType.Login); + + expect(params).toEqual({ + type: CipherType.Login.toString(), + collectionId: "777-888-999", + organizationId: "444-555-666", + folderId: "222-333-444", + uri: "https://example.com", + name: "example.com", }); }); - it("does not include name or uri when the extension is popped out", async () => { + it("should build query params for a Login cipher when popped out", () => { + component.initialValues = { + collectionId: "777-888-999", + } as NewItemInitialValues; + jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true); + const params = component.buildQueryParams(CipherType.Login); + + expect(params).toEqual({ + type: CipherType.Login.toString(), + collectionId: "777-888-999", + }); + }); + + it("should build query params for a secure note", () => { component.initialValues = { - folderId: "222-333-444", - organizationId: "444-555-666", collectionId: "777-888-999", } as NewItemInitialValues; - await component.newItemNavigate(CipherType.Login); + const params = component.buildQueryParams(CipherType.SecureNote); - expect(navigate).toHaveBeenCalledWith(["/add-cipher"], { - queryParams: { - type: CipherType.Login.toString(), - folderId: "222-333-444", - organizationId: "444-555-666", - collectionId: "777-888-999", - }, + expect(params).toEqual({ + type: CipherType.SecureNote.toString(), + collectionId: "777-888-999", + }); + }); + + it("should build query params for an Identity", () => { + component.initialValues = { + collectionId: "777-888-999", + } as NewItemInitialValues; + + const params = component.buildQueryParams(CipherType.Identity); + + expect(params).toEqual({ + type: CipherType.Identity.toString(), + collectionId: "777-888-999", + }); + }); + + it("should build query params for a Card", () => { + component.initialValues = { + collectionId: "777-888-999", + } as NewItemInitialValues; + + const params = component.buildQueryParams(CipherType.Card); + + expect(params).toEqual({ + type: CipherType.Card.toString(), + collectionId: "777-888-999", + }); + }); + + it("should build query params for a SshKey", () => { + component.initialValues = { + collectionId: "777-888-999", + } as NewItemInitialValues; + + const params = component.buildQueryParams(CipherType.SshKey); + + expect(params).toEqual({ + type: CipherType.SshKey.toString(), + collectionId: "777-888-999", }); }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts index a1d5cbd332..e2062101e1 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from "@angular/common"; -import { Component, Input } from "@angular/core"; -import { Router, RouterLink } from "@angular/router"; +import { Component, Input, OnInit } from "@angular/core"; +import { RouterLink } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -25,31 +25,31 @@ export interface NewItemInitialValues { standalone: true, imports: [NoItemsModule, JslibModule, CommonModule, ButtonModule, RouterLink, MenuModule], }) -export class NewItemDropdownV2Component { +export class NewItemDropdownV2Component implements OnInit { cipherType = CipherType; - + private tab?: chrome.tabs.Tab; /** * Optional initial values to pass to the add cipher form */ @Input() initialValues: NewItemInitialValues; - constructor( - private router: Router, - private dialogService: DialogService, - ) {} + constructor(private dialogService: DialogService) {} - private async buildQueryParams(type: CipherType): Promise { - const tab = await BrowserApi.getTabFromCurrentWindow(); + async ngOnInit() { + this.tab = await BrowserApi.getTabFromCurrentWindow(); + } + + buildQueryParams(type: CipherType): AddEditQueryParams { const poppedOut = BrowserPopupUtils.inPopout(window); const loginDetails: { uri?: string; name?: string } = {}; // When a Login Cipher is created and the extension is not popped out, // pass along the uri and name - if (!poppedOut && type === CipherType.Login && tab) { - loginDetails.uri = tab.url; - loginDetails.name = Utils.getHostname(tab.url); + if (!poppedOut && type === CipherType.Login && this.tab) { + loginDetails.uri = this.tab.url; + loginDetails.name = Utils.getHostname(this.tab.url); } return { @@ -61,10 +61,6 @@ export class NewItemDropdownV2Component { }; } - async newItemNavigate(type: CipherType) { - await this.router.navigate(["/add-cipher"], { queryParams: await this.buildQueryParams(type) }); - } - openFolderDialog() { this.dialogService.open(AddEditFolderDialogComponent); } diff --git a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html index 75334b68ef..9a65f7d98c 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html +++ b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.html @@ -3,11 +3,19 @@ {{ (hideIcon ? "createSend" : "new") | i18n }} - + {{ "sendTypeText" | i18n }} - + {{ "sendTypeFile" | i18n }} - + diff --git a/apps/web/src/app/tools/credential-generator/credential-generator.component.ts b/apps/web/src/app/tools/credential-generator/credential-generator.component.ts index f252796d06..8d7b56a09a 100644 --- a/apps/web/src/app/tools/credential-generator/credential-generator.component.ts +++ b/apps/web/src/app/tools/credential-generator/credential-generator.component.ts @@ -1,6 +1,6 @@ import { Component } from "@angular/core"; -import { ButtonModule, DialogService, ItemModule, LinkModule } from "@bitwarden/components"; +import { ButtonModule, DialogService, LinkModule } from "@bitwarden/components"; import { CredentialGeneratorHistoryDialogComponent, GeneratorModule, @@ -13,7 +13,7 @@ import { SharedModule } from "../../shared"; standalone: true, selector: "credential-generator", templateUrl: "credential-generator.component.html", - imports: [SharedModule, HeaderModule, GeneratorModule, ItemModule, ButtonModule, LinkModule], + imports: [SharedModule, HeaderModule, GeneratorModule, ButtonModule, LinkModule], }) export class CredentialGeneratorComponent { constructor(private dialogService: DialogService) {} From 194aa943028e8795798ebf864958c3e03fb3850a Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Tue, 3 Dec 2024 13:46:17 +0100 Subject: [PATCH 66/75] [PM-13202][Defect] MSP name with an apostrophe displaying dummy character in Delete provider screen (#11488) * Resolve the msp name with apostrophe * qParams.name exists and is a string before sanitization --- .../verify-recover-delete-provider.component.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/verify-recover-delete-provider.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/verify-recover-delete-provider.component.ts index a4461b3e11..68264593b8 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/verify-recover-delete-provider.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/verify-recover-delete-provider.component.ts @@ -1,4 +1,5 @@ -import { Component, OnInit } from "@angular/core"; +import { Component, OnInit, SecurityContext } from "@angular/core"; +import { DomSanitizer } from "@angular/platform-browser"; import { ActivatedRoute, Router } from "@angular/router"; import { firstValueFrom } from "rxjs"; @@ -24,6 +25,7 @@ export class VerifyRecoverDeleteProviderComponent implements OnInit { private i18nService: I18nService, private route: ActivatedRoute, private toastService: ToastService, + private sanitizer: DomSanitizer, ) {} async ngOnInit() { @@ -31,7 +33,10 @@ export class VerifyRecoverDeleteProviderComponent implements OnInit { if (qParams.providerId != null && qParams.token != null && qParams.name != null) { this.providerId = qParams.providerId; this.token = qParams.token; - this.name = qParams.name; + this.name = + qParams.name && typeof qParams.name === "string" + ? this.sanitizer.sanitize(SecurityContext.HTML, qParams.name) || "" + : ""; } else { await this.router.navigate(["/"]); } From 9c03cffe30cf48e48605a6b025b864b0f711eb5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Tue, 3 Dec 2024 12:58:26 +0000 Subject: [PATCH 67/75] [PM-15156] Copy update on Organization member Delete modals (#12182) * Rename 'Remove users' to 'Remove members' in bulk remove dialog * Update warning messages for bulk delete dialog and single member deletion --- .../bulk/bulk-delete-dialog.component.html | 2 +- .../bulk/bulk-remove-dialog.component.html | 4 ++-- .../member-dialog/member-dialog.component.ts | 5 ++++- .../members/members.component.ts | 5 ++++- apps/web/src/locales/en/messages.json | 19 ++++++++++++++++--- 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.html b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.html index 9a4ce89671..bb5294ebf0 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-delete-dialog.component.html @@ -8,7 +8,7 @@ -

{{ "deleteOrganizationUserWarning" | i18n }}

+

{{ "deleteManyOrganizationUsersWarningDesc" | i18n }}

diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.html b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.html index 8b921d6981..8727148f4f 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.html @@ -1,4 +1,4 @@ - + {{ "noSelectedUsersApplicable" | i18n }} @@ -79,7 +79,7 @@ [disabled]="loading" [bitAction]="submit" > - {{ "removeUsers" | i18n }} + {{ "removeMembers" | i18n }}
-